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.
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:
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:
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:
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:
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:
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:
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:
const audioUrl = canPlayOgg() ? 'sound.ogg' : 'sound.mp3'Useful Tricks
Pitch variation makes repeated sounds feel less robotic:
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:
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.