Behind the Mic: Building Real-Time Multiplayer Games with WebRTC and Web Audio

We’ve all seen the classic "Show HN" posts that make us stop scrolling, but every now and then, one comes along that makes you immediately open a new tab, call your friends, and wonder, "How did they actually build this without agonizing lag?"

That’s exactly what happened this week when Mic Drop, a real-time multiplayer karaoke game, hit the Hacker News homepage. On the surface, it’s a fun, social web app where you sing your heart out with friends. But under the hood? It’s a masterclass in modern web engineering. It tackles some of the absolute hardest problems in web development: ultra-low latency audio streaming, real-time state synchronization, Web Audio API processing, and clock synchronization across different client machines.

As developers, we love a good engineering challenge. So today, we’re going to deconstruct the architecture of a real-time multiplayer audio game like Mic Drop. We'll explore how to handle real-time audio pipeline processing, manage WebSocket vs. WebRTC trade-offs, and implement client-side audio analysis using the Web Audio API.

The Core Challenge: The Latency Boss Fight

If you’ve ever tried to sing over a standard Zoom or Teams call, you know how terrible the experience is. Standard VoIP pipelines are optimized for speech intelligibility and bandwidth saving, not musical timing. They introduce compression, echo cancellation algorithms that eat music, and, worst of all, latency.

In a multiplayer karaoke or rhythm game, timing is everything. Here is the latency breakdown we have to defeat:

  • Acoustic Latency (10-30ms): The time it takes for sound to travel from the speakers to the microphone.
  • Audio Processing Latency (5-20ms): The time the OS and browser take to sample mic input and process it through the Web Audio graph.
  • Network Latency (20-150ms+): The time it takes for audio packets to travel across the wire.

To make a multiplayer singing game feel "real-time," we need a total round-trip latency of under 100ms. If we go over that, players can’t stay in sync with the backing track or each other. Let’s look at how we build a system to handle this.

The Architecture: WebRTC vs. WebSockets

When building multiplayer web apps, our default instinct is often to reach for WebSockets. They are easy to set up, highly compatible, and great for structured data. However, WebSockets run over TCP. TCP guarantees packet delivery and packet order. If a packet is lost, TCP pauses everything (head-of-line blocking) until that packet is retransmitted.

For real-time audio, TCP is a non-starter. A missed audio packet is better off ignored; we prefer a tiny, imperceptible click or silence over a 300ms delay while the browser waits for a retransmission.

This is why a real-time audio game must split its networking architecture into two distinct pipelines:

+------------------+                   +------------------+
|   Client A       |                   |   Client B       |
|                  |                   |                  |
|  [Web Audio API] |                   |  [Web Audio API] |
+--------+---------+                   +--------+---------+
         |                                      ^
         | WebRTC MediaStream (UDP)             | WebRTC MediaStream (UDP)
         | Ultra-low latency audio              | Ultra-low latency audio
         v                                      |
+--------+--------------------------------------+---------+
|                  WebRTC SFU / TURN Server               |
+------------------------+--------------------------------+
                         ^
                         | WebSockets / WebTransport
                         | Game State Sync (Scores, Lyrics)
                         v
+------------------------+--------------------------------+
|                   Central Game Server                   |
+---------------------------------------------------------+

By separation of concerns:

  • The Game State Channel (WebSockets/WebTransport): Handles lobby creation, player joining, chat, lyric synchronization, and final score distribution.
  • The Audio Channel (WebRTC): Handles peer-to-peer (or peer-to-SFU-to-peer) raw audio streaming using UDP.

Step-by-Step: Capturing and Processing Audio on the Client

To build a game like Mic Drop, we need to capture the player's voice, analyze their pitch in real-time to grade their singing, and stream that audio to other players. Let's look at how we set up the Web Audio API to do this.

1. Accessing the Microphone and Setting Up the Context

First, we need to request access to the user's microphone with specific constraints. For music and singing, we want to disable default browser processing like automatic gain control and noise suppression, which can distort singing voices.

const audioContext = new (window.AudioContext || window.webkitAudioContext)({
  latencyHint: 'interactive' // Tells the OS to prioritize low latency over battery
});

async function initAudio() {
  try {
    const stream = await navigator.mediaDevices.getUserMedia({
      audio: {
        echoCancellation: false, // Turn off to prevent music ducking
        noiseSuppression: false,  // Turn off to keep vocal dynamics
        autoGainControl: false,   // Prevents volume pumping
        latency: { ideal: 0.005 } // Request lowest possible latency
      }
    });

    const source = audioContext.createMediaStreamSource(stream);
    setupAudioPipeline(source);
  } catch (err) {
    console.error("Microphone access denied!", err);
  }
}

2. The Web Audio Pipeline and Pitch Detection

Once we have our audio source, we want to run it through an analyzer. In a game like Mic Drop, we need to analyze the user's pitch in real-time to compare it to the target song's melody. We can achieve this using an AnalyserNode and a pitch detection algorithm like Autocorrelation or YIN.

function setupAudioPipeline(sourceNode) {
  const analyser = audioContext.createAnalyser();
  analyser.fftSize = 2048; // Balance between frequency resolution and time resolution
  
  // Connect the mic source to our analyzer
  sourceNode.connect(analyser);
  
  // NOTE: We do NOT connect analyser to audioContext.destination!
  // Doing so would loop the user's microphone back into their own headphones with delay,
  // causing an annoying echo. We only want to analyze the data locally.

  const bufferLength = analyser.frequencyBinCount;
  const dataArray = new Float32Array(bufferLength);

  function drawAndAnalyze() {
    requestAnimationFrame(drawAndAnalyze);
    analyser.getFloatTimeDomainData(dataArray);
    
    const pitch = autoCorrelate(dataArray, audioContext.sampleRate);
    if (pitch !== -1) {
      // Send pitch data to our game state machine to calculate points!
      updateGameScore(pitch);
    }
  }
  
  drawAndAnalyze();
}

3. Implementing Basic Autocorrelation

Autocorrelation is a mathematical tool for finding repeating patterns in a signal. In simple terms, it helps us find the fundamental frequency (pitch) of a voice. Here is a lightweight implementation you can run directly inside your animation or processing loop:

function autoCorrelate(buffer, sampleRate) {
  // Perform a quick check to see if we even have enough signal
  let totalVolume = 0;
  for (let i = 0; i < buffer.length; i++) {
    totalVolume += buffer[i] * buffer[i];
  }
  const rms = Math.sqrt(totalVolume / buffer.length);
  if (rms < 0.01) return -1; // Too quiet, ignore noise

  // Walk through different offsets (delays) and compare the signal to itself
  let r1 = 0, r2 = buffer.length - 1;
  const thres = 0.2;
  for (let i = 0; i < buffer.length / 2; i++) {
    if (Math.abs(buffer[i]) < thres) { r1 = i; break; }
  }
  for (let i = buffer.length / 2; i < buffer.length; i++) {
    if (Math.abs(buffer[i]) < thres) { r2 = i; break; }
  }

  const prunedBuffer = buffer.slice(r1, r2);
  const size = prunedBuffer.length;

  let bestOffset = -1;
  let bestCorrelation = 0;
  let relations = new Array(size).fill(0);

  for (let offset = 0; offset < size; offset++) {
    let correlation = 0;
    for (let i = 0; i < size - offset; i++) {
      correlation += prunedBuffer[i] * prunedBuffer[i + offset];
    }
    relations[offset] = correlation;
    if (correlation > bestCorrelation && correlation > relations[offset - 1]) {
      bestCorrelation = correlation;
      bestOffset = offset;
    }
  }

  if (bestCorrelation > 0.01 && bestOffset > 0) {
    const frequency = sampleRate / bestOffset;
    return frequency; // Returns frequency in Hz (e.g., 440 for A4)
  }
  return -1;
}

Synchronizing the Room

Now that we can analyze a player's pitch locally, how do we make sure everyone in the digital lobby is hearing the music at the exact same millisecond? If Player A's backing track is 200ms ahead of Player B's, Player A will sound terribly out of time to Player B.

To solve this, we must implement NTP-style (Network Time Protocol) clock synchronization over our WebSocket connection.

  1. The client sends a ping to the server containing its current client-side timestamp ($T_1$).
  2. The server receives the ping, records its server-side timestamp ($T_2$), and immediately sends a pong back with both $T_1$ and $T_2$, plus the server's departure timestamp ($T_3$).
  3. The client receives the pong at client-side timestamp ($T_4$).
  4. The client can now calculate the round-trip time (RTT) and the clock offset relative to the server:
const rtt = (t4 - t1) - (t3 - t2);
const clockOffset = ((t2 - t1) + (t3 - t4)) / 2;
const trueServerTime = Date.now() + clockOffset;

By running this synchronization handshake every few seconds, the client can calculate the precise server-side time. When the host player clicks "Start Song," the server broadcasts an event: { action: "PLAY", startTime: trueServerTime + 2000 } (telling everyone to start the song exactly two seconds in the future). Every browser uses its synchronized clock to trigger the audio playback at the exact same absolute timestamp, achieving near-perfect sync.

Conclusion: The Web is Now a Real-Time Playground

Projects like Mic Drop highlight just how incredibly capable the web platform has become. By combining the low-level processing power of the Web Audio API, the ultra-low-latency transport of WebRTC, and smart clock synchronization over WebSockets, we can build highly interactive, multiplayer experiences that previously required heavy native desktop installations.

Whether you're building a karaoke game, a collaborative design tool, or a cloud gaming platform, mastering these real-time web primitives is an incredibly valuable skill in a developer's toolkit.

Have you experimented with WebAudio or WebRTC in your own projects? What strategies do you use to battle network jitter and audio lag? Let me know in the comments below, or share this article with your dev team!

Until next time, keep coding. — Alex

Post a Comment

Previous Post Next Post