Skip to content

Web Audio API for Games

Good audio transforms a game. A silent game feels lifeless. The Web Audio API gives you low-latency sound effects, music playback, and even 3D spatial audio, all in the browser.

Try it (click a button to hear synthesized sounds):

The Autoplay Problem

Before anything else, you need to understand autoplay restrictions. Browsers block audio until the user interacts with your page. This is a good thing (nobody wants websites blasting sound on load), but it means you have to initialize audio in response to a click, tap, or keypress.

js
let audioCtx = null

function initAudio() {
  if (!audioCtx) {
    audioCtx = new AudioContext()
  }
  if (audioCtx.state === 'suspended') {
    audioCtx.resume()
  }
  return audioCtx
}

// Initialize on first interaction
document.addEventListener('click', () => initAudio(), { once: true })
document.addEventListener('keydown', () => initAudio(), { once: true })

If you try to play audio before this, it silently fails or the AudioContext stays suspended. This catches a lot of developers off guard.

Loading Sounds

Audio files need to be decoded before playback. Fetch the file, decode it, and keep the buffer around for reuse:

js
async function loadSound(url) {
  const response = await fetch(url)
  const arrayBuffer = await response.arrayBuffer()
  const audioBuffer = await audioCtx.decodeAudioData(arrayBuffer)
  return audioBuffer
}

Decoding takes time, so load all your sounds during a loading screen, not when you need to play them.

Playing Sounds

To play a sound, create a buffer source, connect it through a gain node for volume control, and start it:

js
function playSound(buffer, volume = 1) {
  const source = audioCtx.createBufferSource()
  source.buffer = buffer
  
  const gainNode = audioCtx.createGain()
  gainNode.gain.value = volume
  
  source.connect(gainNode)
  gainNode.connect(audioCtx.destination)
  
  source.start(0)
  return source
}

Buffer sources are one-shot. Once a source finishes playing, you can't restart it. Create a new one each time you play. This feels wasteful but it's how the API works, and browsers optimize for it.

A Sound Manager

For a real game, wrap everything in a manager class:

js
class SoundManager {
  constructor() {
    this.ctx = null
    this.sounds = new Map()
    this.masterGain = null
  }
  
  init() {
    this.ctx = new AudioContext()
    this.masterGain = this.ctx.createGain()
    this.masterGain.connect(this.ctx.destination)
  }
  
  async load(name, url) {
    const response = await fetch(url)
    const buffer = await this.ctx.decodeAudioData(await response.arrayBuffer())
    this.sounds.set(name, buffer)
  }
  
  play(name, volume = 1) {
    const buffer = this.sounds.get(name)
    if (!buffer) return
    
    const source = this.ctx.createBufferSource()
    source.buffer = buffer
    
    const gain = this.ctx.createGain()
    gain.gain.value = volume
    
    source.connect(gain)
    gain.connect(this.masterGain)
    source.start(0)
    
    return source
  }
  
  setMasterVolume(v) {
    this.masterGain.gain.value = v
  }
}

The master gain node lets you implement a global volume slider. All sounds route through it.

Background Music

Music needs looping and the ability to stop or fade out:

js
class MusicPlayer {
  constructor(ctx, masterGain) {
    this.ctx = ctx
    this.masterGain = masterGain
    this.currentTrack = null
    this.gain = ctx.createGain()
    this.gain.connect(masterGain)
  }
  
  play(buffer, volume = 0.5) {
    this.stop()
    
    this.currentTrack = this.ctx.createBufferSource()
    this.currentTrack.buffer = buffer
    this.currentTrack.loop = true
    this.currentTrack.connect(this.gain)
    this.gain.gain.value = volume
    this.currentTrack.start(0)
  }
  
  stop() {
    if (this.currentTrack) {
      this.currentTrack.stop()
      this.currentTrack = null
    }
  }
  
  fadeOut(duration = 1) {
    this.gain.gain.linearRampToValueAtTime(0, this.ctx.currentTime + duration)
  }
}

The linearRampToValueAtTime method gives you smooth fades instead of abrupt cuts.

Spatial Audio

For 3D games, you can position sounds in space. Sounds get quieter as they move away from the listener:

js
function playSpatialSound(buffer, x, y, z) {
  const source = audioCtx.createBufferSource()
  source.buffer = buffer
  
  const panner = audioCtx.createPanner()
  panner.panningModel = 'HRTF'
  panner.distanceModel = 'inverse'
  panner.refDistance = 1
  panner.maxDistance = 100
  panner.setPosition(x, y, z)
  
  source.connect(panner)
  panner.connect(audioCtx.destination)
  source.start(0)
}

function updateListener(x, y, z, fx, fy, fz) {
  const listener = audioCtx.listener
  listener.setPosition(x, y, z)
  listener.setOrientation(fx, fy, fz, 0, 1, 0)
}

Call updateListener each frame with the camera or player position. The panner automatically adjusts volume and stereo panning based on distance and direction.

Rapid Fire Sounds

For sounds that play many times per second (gunfire, footsteps), you don't want overlapping instances to pile up. A simple pool pattern helps:

js
class SoundPool {
  constructor(ctx, buffer, size = 8) {
    this.sources = []
    this.index = 0
    this.ctx = ctx
    this.buffer = buffer
    this.size = size
  }
  
  play(volume = 1) {
    const source = this.ctx.createBufferSource()
    source.buffer = this.buffer
    
    const gain = this.ctx.createGain()
    gain.gain.value = volume
    
    source.connect(gain)
    gain.connect(this.ctx.destination)
    source.start(0)
    
    this.index = (this.index + 1) % this.size
  }
}

The pool limits how many instances of the same sound can play simultaneously. When you hit the limit, the oldest one gets replaced.

Audio Formats

Different formats work better in different situations:

MP3 works everywhere and is good for music. OGG Vorbis has better quality at the same file size but Safari doesn't support it. AAC/M4A works well on Apple devices. WAV is uncompressed and large, but decodes instantly, which is good for short sound effects where you can afford the file size. WebM/Opus is the best quality-to-size ratio but only works in modern browsers.

For maximum compatibility, provide fallbacks:

js
const audioUrl = canPlayOgg() ? 'sound.ogg' : 'sound.mp3'

Useful Tricks

Pitch variation makes repeated sounds feel less robotic:

js
function playWithPitchVariation(buffer, variance = 0.1) {
  const source = audioCtx.createBufferSource()
  source.buffer = buffer
  source.playbackRate.value = 1 + (Math.random() - 0.5) * variance
  source.connect(audioCtx.destination)
  source.start(0)
}

Each gunshot or footstep sounds slightly different.

Ducking lowers music volume during dialogue or important sounds:

js
function duckMusic(musicGain, duration = 0.3) {
  musicGain.gain.linearRampToValueAtTime(0.2, audioCtx.currentTime + duration)
}

function unduckMusic(musicGain, duration = 0.3) {
  musicGain.gain.linearRampToValueAtTime(1, audioCtx.currentTime + duration)
}

iOS Gotchas

iOS has stricter audio restrictions than other platforms. The AudioContext must be created and resumed during a user gesture. Some iOS versions also require you to play a sound (even a silent one) during the gesture, not just create the context. If audio works on desktop but not iOS, this is probably why.

More Resources

Ship a web game that loads fast covers asset compression including audio.

Mobile-friendly web games has more on iOS-specific issues.

PWA for offline games shows how to cache audio files for offline play.