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.

Tone.js for game audio covers procedural sound generation and music systems using a higher-level library.

Where to Find Free Game Assets lists free sound effect and music sources like Freesound and Poly Haven.

External Resources ​