← Back to DevBytes

How to Create a Video Chat App with WebRTC

What Is WebRTC?

WebRTC (Web Real-Time Communication) is a free, open-source technology that enables peer-to-peer audio, video, and data communication directly inside web browsers without requiring any plugins, native apps, or third-party media servers. It is built into modern browsers like Chrome, Firefox, Safari, and Edge, and is standardized by the W3C and IETF.

At its core, WebRTC provides three primary APIs:

For a video chat application, we primarily leverage the first two APIs to capture media streams and transmit them between participants in real time.

Why WebRTC Matters for Video Chat

πŸš€ Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

Before WebRTC, building a real-time video chat application meant relying on proprietary protocols, browser plugins like Flash, or heavy native SDKs that required installation. WebRTC changed everything:

These advantages make WebRTC the de facto standard for modern video conferencing apps, telehealth platforms, remote collaboration tools, and live broadcasting solutions.

Core Architecture of a WebRTC Video Chat App

Understanding the architecture before diving into code is essential. A WebRTC video chat application consists of three main components:

1. The Signaling Server

WebRTC does not define how peers discover each other or negotiate the session. This is handled by a signaling server that relays session description metadata (SDP) and ICE candidates between peers. The signaling server can be built using WebSockets, HTTP long polling, or any bidirectional communication protocol. Importantly, the signaling server never touches the media streamsβ€”it only exchanges control messages.

2. STUN and TURN Servers

Peer-to-peer connections face challenges when devices sit behind NATs or firewalls. STUN (Session Traversal Utilities for NAT) servers help peers discover their public IP address. When direct peer-to-peer communication fails (typically in 8-10% of calls), a TURN (Traversal Using Relays around NAT) server relays media traffic as a fallback. For production applications, you should deploy your own TURN server (using tools like Coturn) or use a reliable third-party provider.

3. RTCPeerConnection

This is the browser API that manages the entire peer-to-peer connection lifecycle: codec negotiation, media transport, encryption, bandwidth adaptation, and more. It takes the SDP offers and answers created via signaling and processes ICE candidates to establish the optimal network path between peers.

Step-by-Step Implementation

We will build a complete one-on-one video chat application with a Node.js signaling server and a vanilla HTML/JavaScript client. The code is production-ready in structure and covers all essential concepts.

Project Structure

Create a new directory with the following files:

video-chat-app/
β”œβ”€β”€ server.js          # Node.js signaling server
β”œβ”€β”€ package.json       # Dependencies
└── public/
    └── index.html     # Client-side application

Step 1: Initialize the Project and Install Dependencies

Create package.json:

{
  "name": "webrtc-video-chat",
  "version": "1.0.0",
  "description": "A WebRTC video chat application",
  "main": "server.js",
  "scripts": {
    "start": "node server.js"
  },
  "dependencies": {
    "express": "^4.18.2",
    "ws": "^8.14.2",
    "uuid": "^9.0.0"
  }
}

Run npm install to install Express, the WebSocket library (ws), and uuid for generating unique peer identifiers.

Step 2: Build the Signaling Server (server.js)

The signaling server handles three critical message types: offer, answer, and ice-candidate. It also manages room membership so two peers can find each other. Here is the complete server code:

const express = require('express');
const http = require('http');
const WebSocket = require('ws');
const { v4: uuidv4 } = require('uuid');

const app = express();
const server = http.createServer(app);
const wss = new WebSocket.Server({ server });

// Serve static files from the public directory
app.use(express.static('public'));

// Store active rooms and their participants
const rooms = new Map();

// Map each WebSocket connection to a client object
const clients = new Map();

wss.on('connection', (ws) => {
  const clientId = uuidv4();
  clients.set(ws, { id: clientId, roomId: null });

  console.log(`Client connected: ${clientId}`);

  ws.on('message', (message) => {
    try {
      const data = JSON.parse(message.toString());
      handleMessage(ws, data);
    } catch (err) {
      console.error('Invalid message format:', err.message);
      ws.send(JSON.stringify({ type: 'error', message: 'Invalid JSON' }));
    }
  });

  ws.on('close', () => {
    const client = clients.get(ws);
    if (client && client.roomId) {
      leaveRoom(ws, client.roomId);
    }
    clients.delete(ws);
    console.log(`Client disconnected: ${clientId}`);
  });

  ws.on('error', (error) => {
    console.error(`WebSocket error for client ${clientId}:`, error.message);
  });
});

function handleMessage(ws, data) {
  const client = clients.get(ws);
  const { type, roomId } = data;

  switch (type) {
    case 'join-room': {
      const requestedRoomId = data.roomId || uuidv4().substring(0, 6);
      joinRoom(ws, requestedRoomId);
      break;
    }
    case 'offer': {
      forwardToRoom(ws, data, 'offer');
      break;
    }
    case 'answer': {
      forwardToRoom(ws, data, 'answer');
      break;
    }
    case 'ice-candidate': {
      forwardToRoom(ws, data, 'ice-candidate');
      break;
    }
    case 'hang-up': {
      if (client && client.roomId) {
        leaveRoom(ws, client.roomId);
      }
      break;
    }
    default: {
      ws.send(JSON.stringify({ 
        type: 'error', 
        message: `Unknown message type: ${type}` 
      }));
    }
  }
}

function joinRoom(ws, roomId) {
  const client = clients.get(ws);
  client.roomId = roomId;

  if (!rooms.has(roomId)) {
    rooms.set(roomId, new Set());
  }

  const room = rooms.get(roomId);
  const participantCount = room.size;

  // Limit rooms to 2 participants for one-on-one chat
  if (participantCount >= 2) {
    ws.send(JSON.stringify({ 
      type: 'room-full', 
      message: 'Room is full (max 2 participants)' 
    }));
    return;
  }

  room.add(ws);
  console.log(`Client ${client.id} joined room ${roomId} (${room.size} participants)`);

  // Notify the joining client that they are connected
  ws.send(JSON.stringify({ 
    type: 'joined-room', 
    roomId: roomId,
    participantCount: room.size
  }));

  // If there are now 2 participants, tell the new peer to create an offer
  if (room.size === 2) {
    // Find the other participant (not the one that just joined)
    for (const peerWs of room) {
      if (peerWs !== ws) {
        // Tell the newly joined peer to initiate the connection
        ws.send(JSON.stringify({ 
          type: 'peer-connected', 
          peerId: clients.get(peerWs).id,
          initiator: true
        }));
        // Tell the existing peer that someone joined
        peerWs.send(JSON.stringify({ 
          type: 'peer-connected', 
          peerId: client.id,
          initiator: false
        }));
      }
    }
  }
}

function leaveRoom(ws, roomId) {
  const client = clients.get(ws);
  if (!roomId || !rooms.has(roomId)) return;

  const room = rooms.get(roomId);
  room.delete(ws);
  console.log(`Client ${client.id} left room ${roomId}`);

  // Notify remaining peers about the departure
  for (const peerWs of room) {
    peerWs.send(JSON.stringify({ 
      type: 'peer-disconnected', 
      peerId: client.id 
    }));
  }

  // Clean up empty rooms
  if (room.size === 0) {
    rooms.delete(roomId);
    console.log(`Room ${roomId} deleted (empty)`);
  }
}

function forwardToRoom(ws, data, messageType) {
  const client = clients.get(ws);
  const roomId = client.roomId;

  if (!roomId || !rooms.has(roomId)) {
    ws.send(JSON.stringify({ 
      type: 'error', 
      message: 'You are not in a room' 
    }));
    return;
  }

  const room = rooms.get(roomId);
  
  // Forward the message to all other participants in the room
  for (const peerWs of room) {
    if (peerWs !== ws && peerWs.readyState === WebSocket.OPEN) {
      peerWs.send(JSON.stringify({
        type: messageType,
        [messageType === 'ice-candidate' ? 'candidate' : 'sdp']: 
          data[messageType === 'ice-candidate' ? 'candidate' : 'sdp'],
        peerId: client.id,
        roomId: roomId
      }));
    }
  }
}

const PORT = process.env.PORT || 3000;
server.listen(PORT, () => {
  console.log(`Signaling server running on http://localhost:${PORT}`);
  console.log(`WebSocket server ready at ws://localhost:${PORT}`);
});

This server handles room management, limits each room to two participants, and relays SDP offers, answers, and ICE candidates between peers without inspecting or modifying the content. When a peer disconnects, the remaining participant is notified so they can update their UI.

Step 3: Build the Client Interface (public/index.html)

The client handles media capture, peer connection establishment, ICE candidate exchange, and the offer/answer negotiation flow. Here is the complete HTML file with embedded CSS and JavaScript:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>WebRTC Video Chat</title>
  <style>
    * {
      margin: 0;
      padding: 0;
      box-sizing: border-box;
    }
    body {
      font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
      background: #1a1a2e;
      color: #e0e0e0;
      min-height: 100vh;
      display: flex;
      flex-direction: column;
      align-items: center;
      padding: 20px;
    }
    h1 {
      font-size: 2rem;
      margin-bottom: 20px;
      color: #a8dadc;
    }
    .container {
      width: 100%;
      max-width: 1200px;
      display: flex;
      flex-direction: column;
      gap: 20px;
    }
    .video-grid {
      display: grid;
      grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
      gap: 20px;
    }
    .video-box {
      position: relative;
      background: #16213e;
      border-radius: 12px;
      overflow: hidden;
      box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
      aspect-ratio: 16 / 9;
    }
    .video-box video {
      width: 100%;
      height: 100%;
      object-fit: cover;
      border-radius: 12px;
    }
    .video-label {
      position: absolute;
      bottom: 12px;
      left: 12px;
      background: rgba(0, 0, 0, 0.6);
      padding: 6px 14px;
      border-radius: 20px;
      font-size: 0.85rem;
      font-weight: 500;
      color: #fff;
    }
    .placeholder {
      display: flex;
      align-items: center;
      justify-content: center;
      height: 100%;
      color: #888;
      font-size: 1.2rem;
    }
    .controls {
      display: flex;
      gap: 16px;
      justify-content: center;
      flex-wrap: wrap;
    }
    .btn {
      padding: 14px 28px;
      border: none;
      border-radius: 30px;
      font-size: 1rem;
      font-weight: 600;
      cursor: pointer;
      transition: all 0.2s ease;
      background: #2d3a5c;
      color: #e0e0e0;
    }
    .btn:hover {
      background: #3f5182;
      transform: translateY(-2px);
    }
    .btn:active {
      transform: translateY(0);
    }
    .btn-primary {
      background: #457b9d;
    }
    .btn-primary:hover {
      background: #5a91b8;
    }
    .btn-danger {
      background: #e63946;
    }
    .btn-danger:hover {
      background: #f1515e;
    }
    .btn-success {
      background: #2a9d8f;
    }
    .btn-success:hover {
      background: #35b8a9;
    }
    .room-info {
      display: flex;
      gap: 16px;
      align-items: center;
      justify-content: center;
      flex-wrap: wrap;
    }
    .room-id-display {
      background: #16213e;
      padding: 10px 20px;
      border-radius: 8px;
      font-family: monospace;
      font-size: 1.1rem;
      color: #a8dadc;
    }
    .status {
      text-align: center;
      font-size: 0.95rem;
      color: #aaa;
      min-height: 24px;
    }
    input {
      padding: 12px 18px;
      border-radius: 8px;
      border: 2px solid #2d3a5c;
      background: #16213e;
      color: #e0e0e0;
      font-size: 1rem;
      width: 220px;
      outline: none;
      transition: border-color 0.2s;
    }
    input:focus {
      border-color: #457b9d;
    }
    .hidden {
      display: none !important;
    }
  </style>
</head>
<body>
  <h1>πŸŽ₯ WebRTC Video Chat</h1>
  <div class="container">
    <div class="room-info">
      <input type="text" id="roomIdInput" placeholder="Enter Room ID to join..." maxlength="20">
      <button class="btn btn-primary" id="joinBtn">Join Room</button>
      <button class="btn btn-success" id="createBtn">Create New Room</button>
      <span class="room-id-display hidden" id="roomIdDisplay"></span>
      <button class="btn btn-danger hidden" id="hangupBtn">Hang Up</button>
    </div>
    <div class="video-grid">
      <div class="video-box">
        <video id="localVideo" autoplay muted playsinline></video>
        <div class="video-label">You (Local)</div>
      </div>
      <div class="video-box">
        <video id="remoteVideo" autoplay playsinline></video>
        <div class="video-label">Remote Peer</div>
        <div class="placeholder" id="remotePlaceholder">Waiting for peer to join...</div>
      </div>
    </div>
    <div class="controls">
      <button class="btn" id="muteBtn">πŸ”Š Mute Mic</button>
      <button class="btn" id="cameraBtn">πŸ“· Stop Camera</button>
    </div>
    <p class="status" id="status">Enter a room ID or create a new one to get started.</p>
  </div>

  <script>
    // --- Configuration ---
    const SIGNALING_SERVER_URL = `ws://${window.location.host}`;
    
    // STUN/TURN servers (use your own TURN server in production)
    const ICE_SERVERS = {
      iceServers: [
        { urls: 'stun:stun.l.google.com:19302' },
        { urls: 'stun:stun1.l.google.com:19302' },
        // Add your TURN server credentials here for production
        // {
        //   urls: 'turn:your-turn-server.com:3478',
        //   username: 'username',
        //   credential: 'password'
        // }
      ]
    };

    // --- DOM Elements ---
    const localVideo = document.getElementById('localVideo');
    const remoteVideo = document.getElementById('remoteVideo');
    const remotePlaceholder = document.getElementById('remotePlaceholder');
    const roomIdInput = document.getElementById('roomIdInput');
    const roomIdDisplay = document.getElementById('roomIdDisplay');
    const joinBtn = document.getElementById('joinBtn');
    const createBtn = document.getElementById('createBtn');
    const hangupBtn = document.getElementById('hangupBtn');
    const muteBtn = document.getElementById('muteBtn');
    const cameraBtn = document.getElementById('cameraBtn');
    const statusEl = document.getElementById('status');

    // --- State ---
    let ws = null;
    let localStream = null;
    let peerConnection = null;
    let currentRoomId = null;
    let isInitiator = false;
    let isMuted = false;
    let isCameraOff = false;
    let pendingCandidates = [];

    // --- Helper: Update Status ---
    function setStatus(message, isError = false) {
      statusEl.textContent = message;
      statusEl.style.color = isError ? '#e63946' : '#aaa';
    }

    // --- Helper: Toggle UI based on connection state ---
    function setConnectedState(connected) {
      if (connected) {
        joinBtn.classList.add('hidden');
        createBtn.classList.add('hidden');
        roomIdInput.classList.add('hidden');
        hangupBtn.classList.remove('hidden');
        roomIdDisplay.classList.remove('hidden');
      } else {
        joinBtn.classList.remove('hidden');
        createBtn.classList.remove('hidden');
        roomIdInput.classList.remove('hidden');
        hangupBtn.classList.add('hidden');
        roomIdDisplay.classList.add('hidden');
      }
    }

    // --- Step 1: Get local media ---
    async function getLocalMedia() {
      try {
        localStream = await navigator.mediaDevices.getUserMedia({
          video: {
            width: { ideal: 1280 },
            height: { ideal: 720 },
            facingMode: 'user'
          },
          audio: {
            echoCancellation: true,
            noiseSuppression: true,
            autoGainControl: true
          }
        });
        localVideo.srcObject = localStream;
        setStatus('Camera and microphone ready.');
        return true;
      } catch (err) {
        console.error('Error accessing media devices:', err);
        setStatus('Failed to access camera/microphone. Please check permissions.', true);
        return false;
      }
    }

    // --- Step 2: Connect to signaling server ---
    function connectToSignalingServer() {
      return new Promise((resolve, reject) => {
        ws = new WebSocket(SIGNALING_SERVER_URL);
        
        ws.onopen = () => {
          console.log('Connected to signaling server');
          setStatus('Connected to signaling server.');
          resolve();
        };
        
        ws.onerror = (error) => {
          console.error('WebSocket error:', error);
          setStatus('Failed to connect to signaling server.', true);
          reject(error);
        };
        
        ws.onclose = (event) => {
          console.log('WebSocket closed:', event.code, event.reason);
          setStatus('Disconnected from signaling server. Please refresh.', true);
          // Clean up if connection drops unexpectedly
          if (peerConnection) {
            peerConnection.close();
            peerConnection = null;
          }
          remoteVideo.srcObject = null;
          remotePlaceholder.style.display = 'flex';
          setConnectedState(false);
        };
        
        ws.onmessage = (event) => {
          handleSignalingMessage(JSON.parse(event.data));
        };
      });
    }

    // --- Step 3: Handle incoming signaling messages ---
    function handleSignalingMessage(data) {
      const { type } = data;
      
      switch (type) {
        case 'joined-room':
          currentRoomId = data.roomId;
          roomIdDisplay.textContent = `Room: ${currentRoomId}`;
          setStatus(`Joined room ${currentRoomId}. Waiting for peer...`);
          setConnectedState(true);
          break;
          
        case 'peer-connected':
          setStatus('Peer joined! Establishing connection...');
          if (data.initiator) {
            isInitiator = true;
            createOffer();
          }
          break;
          
        case 'offer':
          handleOffer(data);
          break;
          
        case 'answer':
          handleAnswer(data);
          break;
          
        case 'ice-candidate':
          handleIceCandidate(data);
          break;
          
        case 'peer-disconnected':
          setStatus('Remote peer disconnected.');
          closePeerConnection();
          remoteVideo.srcObject = null;
          remotePlaceholder.style.display = 'flex';
          setConnectedState(true); // Stay in room, wait for new peer
          break;
          
        case 'room-full':
          setStatus('Room is full. Please try another room ID.', true);
          setConnectedState(false);
          currentRoomId = null;
          break;
          
        case 'error':
          setStatus(data.message, true);
          break;
          
        default:
          console.log('Unknown message type:', type, data);
      }
    }

    // --- Step 4: Create RTCPeerConnection ---
    function createPeerConnection() {
      if (peerConnection) {
        peerConnection.close();
      }
      
      peerConnection = new RTCPeerConnection(ICE_SERVERS);
      
      // Add local tracks to the peer connection
      if (localStream) {
        localStream.getTracks().forEach(track => {
          peerConnection.addTrack(track, localStream);
        });
      }
      
      // Handle incoming remote tracks
      peerConnection.ontrack = (event) => {
        console.log('Received remote track:', event.track.kind);
        if (event.streams && event.streams[0]) {
          remoteVideo.srcObject = event.streams[0];
          remotePlaceholder.style.display = 'none';
          setStatus('Video call established!');
        }
      };
      
      // Handle ICE candidates (trickle ICE)
      peerConnection.onicecandidate = (event) => {
        if (event.candidate) {
          sendSignalingMessage({
            type: 'ice-candidate',
            candidate: event.candidate,
            roomId: currentRoomId
          });
        }
      };
      
      // Handle ICE gathering state
      peerConnection.onicegatheringstatechange = () => {
        console.log('ICE gathering state:', peerConnection.iceGatheringState);
      };
      
      // Handle connection state changes
      peerConnection.onconnectionstatechange = () => {
        console.log('Connection state:', peerConnection.connectionState);
        if (peerConnection.connectionState === 'connected') {
          setStatus('Peer connection established! πŸŽ‰');
        } else if (peerConnection.connectionState === 'disconnected') {
          setStatus('Peer connection lost. Attempting to reconnect...');
        } else if (peerConnection.connectionState === 'failed') {
          setStatus('Connection failed. Please try rejoining.', true);
          closePeerConnection();
        }
      };
      
      // Handle ICE connection state
      peerConnection.oniceconnectionstatechange = () => {
        console.log('ICE connection state:', peerConnection.iceConnectionState);
      };
      
      return peerConnection;
    }

    // --- Step 5: Create Offer (Initiator) ---
    async function createOffer() {
      createPeerConnection();
      
      // Process any ICE candidates that arrived before the offer was ready
      pendingCandidates = [];
      
      try {
        const offer = await peerConnection.createOffer({
          offerToReceiveVideo: true,
          offerToReceiveAudio: true
        });
        
        await peerConnection.setLocalDescription(offer);
        
        sendSignalingMessage({
          type: 'offer',
          sdp: peerConnection.localDescription,
          roomId: currentRoomId
        });
        
        setStatus('Offer sent. Waiting for answer...');
      } catch (err) {
        console.error('Error creating offer:', err);
        setStatus('Failed to create offer.', true);
      }
    }

    // --- Step 6: Handle Offer (Non-Initiator) ---
    async function handleOffer(data) {
      createPeerConnection();
      
      try {
        await peerConnection.setRemoteDescription(new RTCSessionDescription(data.sdp));
        
        // Process any queued ICE candidates
        if (pendingCandidates.length > 0) {
          for (const candidate of pendingCandidates) {
            await peerConnection.addIceCandidate(candidate);
          }
          pendingCandidates = [];
        }
        
        const answer = await peerConnection.createAnswer({
          offerToReceiveVideo: true,
          offerToReceiveAudio: true
        });
        
        await peerConnection.setLocalDescription(answer);
        
        sendSignalingMessage({
          type: 'answer',
          sdp: peerConnection.localDescription,
          roomId: currentRoomId
        });
        
        setStatus('Answer sent. Establishing connection...');
      } catch (err) {
        console.error('Error handling offer:', err);
        setStatus('Failed to process offer.', true);
      }
    }

    // --- Step 7: Handle Answer ---
    async function handleAnswer(data) {
      try {
        await peerConnection.setRemoteDescription(new RTCSessionDescription(data.sdp));
        
        // Process any queued ICE candidates
        if (pendingCandidates.length > 0) {
          for (const candidate of pendingCandidates) {
            await peerConnection.addIceCandidate(candidate);
          }
          pendingCandidates = [];
        }
        
        setStatus('Answer received. Finalizing connection...');
      } catch (err) {
        console.error('Error handling answer:', err);
        setStatus('Failed to process answer.', true);
      }
    }

    // --- Step 8: Handle ICE Candidates ---
    async function handleIceCandidate(data) {
      const candidate = new RTCIceCandidate(data.candidate);
      
      // If the remote description is not set yet, queue the candidate
      if (!peerConnection || !peerConnection.remoteDescription) {
        pendingCandidates.push(candidate);
        console.log('Queued ICE candidate (remote description not set yet)');
        return;
      }
      
      try {
        await peerConnection.addIceCandidate(candidate);
        console.log('Added ICE candidate');
      } catch (err) {
        console.error('Error adding ICE candidate:', err);
      }
    }

    // --- Step 9: Send Signaling Message ---
    function sendSignalingMessage(message) {
      if (ws && ws.readyState === WebSocket.OPEN) {
        ws.send(JSON.stringify(message));
      } else {
        console.error('WebSocket is not open. Cannot send message.');
      }
    }

    // --- Step 10: Close Peer Connection ---
    function closePeerConnection() {
      if (peerConnection) {
        peerConnection.close();
        peerConnection = null;
      }
      pendingCandidates = [];
      isInitiator = false;
    }

    // --- Step 11: Hang Up ---
    function hangUp() {
      sendSignalingMessage({
        type: 'hang-up',
        roomId: currentRoomId
      });
      closePeerConnection();
      remoteVideo.srcObject = null;
      remotePlaceholder.style.display = 'flex';
      currentRoomId = null;
      setConnectedState(false);
      setStatus('Call ended. Enter a room ID or create a new one.');
    }

    // --- Step 12: Join a Room ---
    async function joinRoom(roomId) {
      if (!localStream) {
        const success = await getLocalMedia();
        if (!success) return;
      }
      
      if (!ws || ws.readyState !== WebSocket.OPEN) {
        await connectToSignalingServer();
      }
      
      sendSignalingMessage({
        type: 'join-room',
        roomId: roomId
      });
      
      roomIdInput.value = '';
      setStatus(`Joining room ${roomId}...`);
    }

    // --- Step 13: Create a Room ---
    async function createRoom() {
      if (!localStream) {
        const success = await getLocalMedia();
        if (!success) return;
      }
      
      if (!ws || ws.readyState !== WebSocket.OPEN) {
        await connectToSignalingServer();
      }
      
      // Generate a random room ID
      const roomId = Math.random().toString(36).substring(2, 8).toUpperCase();
      
      sendSignalingMessage({
        type: 'join-room',
        roomId: roomId
      });
      
      roomIdInput.value = '';
      setStatus(`Creating room ${roomId}... Share this ID with your peer!`);
    }

    // --- Mute/Unmute ---
    function toggleMute() {
      if (!localStream) return;
      const audioTrack = localStream.getAudioTracks()[0];
      if (audioTrack) {
        audioTrack.enabled = !audioTrack.enabled;
        isMuted = !audioTrack.enabled;
        muteBtn.textContent = isMuted ? 'πŸ”‡ Unmute Mic' : 'πŸ”Š Mute Mic';
      }
    }

    // --- Camera On/Off ---
    function toggleCamera() {
      if (!localStream) return;
      const videoTrack = localStream.getVideoTracks()[0];
      if (videoTrack) {
        videoTrack.enabled = !videoTrack.enabled;
        isCameraOff = !videoTrack.enabled;
        cameraBtn.textContent = isCameraOff ? 'πŸ“· Start Camera' : 'πŸ“· Stop Camera';
        // Show a dark overlay on local video when camera is off
        localVideo.style.opacity = isCameraOff ? '0.3' : '1';
      }
    }

    // --- Event Listeners ---
    joinBtn.addEventListener('click', () => {
      const roomId = roomIdInput.value.trim();
      if (!roomId) {
        setStatus('Please enter a room ID.', true);
        return;
      }
      joinRoom(roomId);
    });

    createBtn.addEventListener('click', () => {
      createRoom();
    });

    hangupBtn.addEventListener('click', () => {
      hangUp();
    });

    muteBtn.addEventListener('click', toggleMute);
    cameraBtn.addEventListener('click', toggleCamera);

    // Allow pressing Enter to join a room
    roomIdInput.addEventListener('keypress', (e) => {
      if (e.key === 'Enter') {
        const roomId = roomIdInput.value.trim();
        if (roomId) joinRoom(roomId);
      }
    });

    // --- Initialization ---
    async function initialize() {
      try {
        await getLocalMedia();
        await connectToSignalingServer();
        setStatus('Ready! Create a room or join an existing one.');
      } catch (err) {
        console.error('Initialization failed:', err);
        setStatus('Failed to initialize. Please check your connection and permissions.', true);
      }
    }

    // Start the application
    initialize();
  </script>
</body>
</html>

Understanding the Client-Side Flow

Let's walk through the key stages of the WebRTC connection lifecycle as implemented in the client code:

πŸš€ Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started β€” $23.99/mo
← Back to all articles