DEV Community

Cover image for Every sound in my browser game is a few lines of Web Audio (no files)
Sharang Parnerkar
Sharang Parnerkar

Posted on

Every sound in my browser game is a few lines of Web Audio (no files)

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);
}
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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));
Enter fullscreen mode Exit fullscreen mode

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]);
Enter fullscreen mode Exit fullscreen mode

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 });
Enter fullscreen mode Exit fullscreen mode

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)