Multiplayer Audio Sync Using Howler.js

This article explains how to achieve precise audio synchronization in real-time multiplayer games using the Howler.js library. We will cover the core challenges of network latency, how to calculate time offsets between clients and servers, and how to use Howler.js methods like play() and seek() to ensure all players hear game audio, such as background music and ambient sounds, at the exact same moment.

The Challenge of Network Latency

In multiplayer games, triggering an audio event over the network introduces latency. If a server tells all clients to play a sound “now,” players with faster connections will hear it before players with slower connections. For atmospheric background music, cutscenes, or rhythm-based elements, this offset breaks immersion.

To solve this, you must rely on a synchronized clock rather than the arrival time of a network message.

Step 1: Establish a Synchronized Game Clock

Before syncing audio, clients must calculate their time offset relative to the game server. This is typically done using a simplified Network Time Protocol (NTP) handshake:

  1. The client sends a ping to the server with a local timestamp (\(T_1\)).
  2. The server receives it and replies with its server timestamp (\(T_{server}\)).
  3. The client receives the reply and records the local arrival time (\(T_2\)).
  4. The Round Trip Time (RTT) is calculated as \(RTT = T_2 - T_1\).
  5. The client’s time offset relative to the server is: \[\text{Offset} = T_{server} - \left(T_1 + \frac{RTT}{2}\right)\]

By adding this offset to the local system clock, the client can estimate the current server time at any moment:

function getCurrentServerTime(clientOffset) {
    return Date.now() + clientOffset;
}

Step 2: Scheduling Audio Playback

Instead of sending a “play now” command, the server must broadcast a command containing the exact server timestamp when the sound should start.

For example, the server broadcasts this payload:

{
  "eventId": "spawn_boss_theme",
  "soundFile": "boss_music.mp3",
  "startAtServerTime": 1700000050000
}

Step 3: Handling Playback in Howler.js

When a client receives the message, they compare the startAtServerTime to their synchronized local clock. There are two scenarios: the sound is scheduled in the future, or the sound has already started.

Scenario A: The Sound Starts in the Future

If the scheduled start time is in the future, calculate the delay and use a standard JavaScript setTimeout to trigger the playback.

Scenario B: The Sound Already Started (Late Join or Latency)

If the scheduled start time has already passed, the client must calculate how far into the track they should be and jump to that position using Howler’s .seek() method.

Here is how to implement this logic:

const sound = new Howl({
  src: ['boss_music.mp3'],
  html5: true // Best for long tracks and seamless seeking
});

function syncAndPlay(sound, startAtServerTime, clientOffset) {
  const currentServerTime = Date.now() + clientOffset;
  const timeDifferenceMs = startAtServerTime - currentServerTime;

  if (timeDifferenceMs > 0) {
    // Scenario A: Schedule future playback
    setTimeout(() => {
      sound.play();
    }, timeDifferenceMs);
  } else {
    // Scenario B: Late join or latency recovery
    const seekPositionSeconds = Math.abs(timeDifferenceMs) / 1000;
    
    sound.once('load', () => {
      const duration = sound.duration();
      
      // Only play if the sound has not already ended
      if (seekPositionSeconds < duration) {
        const id = sound.play();
        sound.seek(seekPositionSeconds, id);
      }
    });

    // If already loaded, trigger immediately
    if (sound.state() === 'loaded') {
      const duration = sound.duration();
      if (seekPositionSeconds < duration) {
        const id = sound.play();
        sound.seek(seekPositionSeconds, id);
      }
    }
  }
}

Step 4: Continuous Synchronization for Loops

Long-running looped ambient tracks can drift over time due to differences in hardware clock rates and CPU throttling. To prevent this, implement a periodic check.

Every 5 to 10 seconds, compare the current playhead of Howler.js with the expected synchronized elapsed time:

function correctDrift(sound, soundId, startAtServerTime, clientOffset) {
  const currentServerTime = Date.now() + clientOffset;
  const expectedPosition = (currentServerTime - startAtServerTime) / 1000;
  const actualPosition = sound.seek(soundId);

  // If the drift is greater than 150ms, force-align the playhead
  if (Math.abs(expectedPosition - actualPosition) > 0.15) {
    sound.seek(expectedPosition, soundId);
  }
}

By combining NTP clock synchronization with Howler’s explicit .seek() control, your multiplayer game will maintain cohesive audio alignment across all connected peers.