I have been building a browser board game (Samudra Manthana, an asymmetric strategy game), and when it came time to add sound, the usual route was to go shopping: find effects, check each licence, download a pile of .wav files, and watch the bundle get heavier.
I did not do that. Every sound effect in the game is synthesised at runtime with the Web Audio API. No files, no licences, almost no bytes. Here is the whole approach.
Two primitives
It turns out you can build a surprisingly expressive palette from just two functions.
A tone: one note with a soft attack and an exponential decay.
function tone(freq, dur, type = 'sine', gain = 0.18, delay = 0) {
const t = ctx.currentTime + delay;
const osc = ctx.createOscillator();
const g = ctx.createGain();
osc.type = type; // sine | triangle | square | sawtooth
osc.frequency.setValueAtTime(freq, t);
g.gain.setValueAtTime(0.0001, t);
g.gain.linearRampToValueAtTime(gain, t + 0.012); // soft attack
g.gain.exponentialRampToValueAtTime(0.0001, t + dur); // decay
osc.connect(g).connect(master);
osc.start(t);
osc.stop(t + dur + 0.03);
}
And a thud: a short burst of filtered noise, for impacts.
function thud(dur, gain, lowpass) {
const buf = ctx.createBuffer(1, ctx.sampleRate * dur, ctx.sampleRate);
const data = buf.getChannelData(0);
for (let i = 0; i < data.length; i++)
data[i] = (Math.random() * 2 - 1) * (1 - i / data.length); // white noise, fading out
const src = ctx.createBufferSource(); src.buffer = buf;
const filt = ctx.createBiquadFilter(); filt.type = 'lowpass'; filt.frequency.value = lowpass;
const g = ctx.createGain();
g.gain.setValueAtTime(gain, ctx.currentTime);
g.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + dur);
src.connect(filt).connect(g).connect(master);
src.start();
}
That is the entire kit. Layer a couple of tones, add a thud, pick the pitch and length, and you have a sound.
A voice per event
The game narrates itself through a log ("chronicle"), so the sound layer just maps each kind of event to a little synthesised voice, tuned to be quiet rather than arcade-loud:
const SFX = {
move: () => tone(520, 0.06, 'triangle', 0.09),
combat: () => { thud(0.14, 0.16, 900); tone(120, 0.14, 'sawtooth', 0.09); },
tribute: () => { tone(784, 0.10); tone(1047, 0.14, 'sine', 0.09, 0.05); },
glory: () => { tone(659, 0.12, 'sine', 0.14); tone(880, 0.14, 'sine', 0.12, 0.08);
tone(1175, 0.18, 'sine', 0.1, 0.16); }, // a rising chime
churning: () => { tone(90, 0.5, 'sawtooth', 0.1); tone(180, 0.5, 'sine', 0.06, 0.03); }, // deep rumble
};
// win flourish: a C-E-G-C major arpeggio
const playWin = () => [523, 659, 784, 1047].forEach((f, i) => tone(f, 0.35, 'sine', 0.16, i * 0.12));
Because it is driven off the log, I get sound for free in every mode. A tiny effect watches the log and plays one cue per new entry - hotseat or online, no extra wiring:
useEffect(() => {
for (let i = seen.current; i < game.log.length; i++) playSfx(game.log[i].kind);
seen.current = game.log.length;
}, [game.log]);
The HUD makes sounds too, deliberately different from the game (lighter and drier): a committing action gets a firm two-note cue, a small toggle gets a quiet tick. Your fingers learn the difference.
The one real recording, and the autoplay gotcha
There is exactly one audio file in the whole thing: the background music (dropped low, looped, and started from a random point each load so no two sessions open on the same bar). Everything is gated behind the first user gesture, because browsers will not let you make noise until the user has interacted:
window.addEventListener('pointerdown', unlockAudio, { once: true });
Why bother
The entire sound design adds almost nothing to the download, carries zero licensing strings, and is trivial to tweak - a sound is just numbers, so making the churn deeper or a move softer is a one-line change.
If you want to hear it, the game is playable in the browser: https://mighty840.itch.io/samudra-manthan. Turn sound on and have a churn.
Top comments (0)