Synthesising Game Audio With Zero Audio Files
Every sound in OmniPlay is generated in the browser at runtime — no MP3s, no sprite sheet, nothing to precache. Here's the Web Audio graph behind a dice knock, and the autoplay rule that breaks it.
OmniPlay ships no audio files. Not compressed ones, not a sprite sheet — zero bytes of audio in the bundle. Every sound in the game is generated in the browser the moment it's needed, through the Web Audio API.
This started as a size decision and turned out to be a better design for reasons that had nothing to do with bytes.
The size argument, briefly
A modest set of game sounds — dice roll, token move, capture, ladder, snake, win, button tap — is a few hundred kilobytes even as compressed audio. For an app whose entire point is loading fast on a mid-range Android phone over a metered connection, that's a meaningful fraction of the download, and it all has to be precached for offline play.
Synthesised, the same set is a few dozen lines of JavaScript, already in a bundle you were shipping anyway.
A sound is an oscillator and an envelope
Nearly every simple game sound is two things: a waveform, and a volume curve that shapes it over time. The curve matters far more than the waveform. A sine wave with a sharp attack and fast decay is a blip; the same sine wave faded in over half a second is a hum.
Here's a complete sound:
function blip(ctx, { freq = 440, duration = 0.12, type = "sine" } = {}) {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = type;
osc.frequency.setValueAtTime(freq, ctx.currentTime);
// Attack, then exponential decay to (near) silence.
gain.gain.setValueAtTime(0.0001, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.3, ctx.currentTime + 0.01);
gain.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + duration);
osc.connect(gain).connect(ctx.destination);
osc.start();
osc.stop(ctx.currentTime + duration);
}Two details there are load-bearing.
Never ramp to or from exactly zero. exponentialRampToValueAtTime(0, …)
either throws or silently does nothing depending on the browser, because an
exponential curve can't reach zero. Use a very small number — 0.0001 is
inaudible.
Schedule against ctx.currentTime, not Date.now(). The audio clock runs
on its own thread and is sample-accurate. Anything scheduled against wall-clock
time drifts and jitters the moment the main thread is busy — which, in a game,
is exactly when a sound is being triggered.
Nodes are single-use. An OscillatorNode that has been stopped cannot be
restarted; you create a new one per sound. This feels wasteful and isn't —
they're cheap, and the browser garbage-collects them once they've finished.
Noise, for the sounds that aren't tones
A wooden knock isn't a tone. Percussive sounds are mostly filtered noise, which means generating a buffer of random samples:
function noiseBuffer(ctx, seconds = 0.2) {
const frames = Math.floor(ctx.sampleRate * seconds);
const buffer = ctx.createBuffer(1, frames, ctx.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < frames; i++) data[i] = Math.random() * 2 - 1;
return buffer;
}Run that through a bandpass filter with a fast decay and you get the die knocking against the board. Change the filter frequency and the same code is a different material — high and short is a click, low and slightly longer is a thud.
function knock(ctx, freq = 320) {
const src = ctx.createBufferSource();
const filter = ctx.createBiquadFilter();
const gain = ctx.createGain();
src.buffer = noiseBuffer(ctx, 0.2);
filter.type = "bandpass";
filter.frequency.value = freq;
filter.Q.value = 3;
gain.gain.setValueAtTime(0.4, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + 0.18);
src.connect(filter).connect(gain).connect(ctx.destination);
src.start();
}The autoplay policy will break this
Every browser blocks audio until the user has interacted with the page. An
AudioContext created on page load starts in a suspended state, and
everything you schedule into it is silently discarded.
The fix is to create the context lazily and resume it on the first real gesture:
let ctx = null;
export function getAudioContext() {
if (!ctx) ctx = new AudioContext();
// Safari in particular suspends aggressively, including on tab switches.
if (ctx.state === "suspended") ctx.resume();
return ctx;
}Two things worth knowing. Call this from inside the event handler for a genuine
user gesture — a tap, a click, a keypress — not from a setTimeout afterwards,
or the gesture no longer counts. And re-check state on every sound rather
than resuming once at startup: Safari re-suspends the context when a tab loses
focus, so a game resumed from a background tab goes silent otherwise.
The part that turned out to matter more than size
Once sounds are code, their properties become parameters.
The die in OmniPlay is thrown with momentum and skitters across the board before settling. With audio files you'd need several knock samples and would still hear the repetition. Synthesised, each bounce passes its impact velocity in, and the pitch and volume follow it — a hard first bounce is louder and lower, and the sound naturally settles as the die does. A capture pitches up slightly with each token you're ahead by. Nothing repeats exactly, because nothing is a recording.
Doing that with samples means shipping many variants and cross-fading between them. Doing it with an oscillator is an argument.
The other quiet benefit: there is no network request in the audio path at all. No decode step on first play, no "the first sound is late because the file was still loading", and nothing extra for the service worker to precache. Sound works offline for the same reason it works instantly — it was never a file.
Where this stops being a good idea
Synthesis handles tones, percussion, sweeps and blips well. It does not handle music with real instruments, voice, or any sound whose identity comes from a recording. If your game needs a soundtrack someone composed, ship the file.
The line is roughly: if you'd describe the sound with a verb, synthesise it; if you'd describe it with a noun, record it. A click, a knock, a whoosh, a ding — those are verbs, and they're a handful of lines each.