Skip to content
PN Scripts

Games

Procedural game sound effects with the Web Audio API: one mix bus, layered recipes and measured levels

  • PN Scripts
  • 8 min read

Every sound in our 3D browser games Faultwarden and Billowpost is made by code while you play: the thud of a strike, the crack that runs along a fault, the groan of a stone slab about to fall, the chime when you pick up a shard, and the music underneath. There is not a single recorded sample in either game. That keeps the download small, lets every sound vary a little each time, and lets effects follow the music's key. It also makes it very easy to produce a harsh, clipping mess. This guide shows the mix bus we use, how one sound is built from layers, and how an automated test keeps the level in a sensible range.

#Start the audio on a gesture

Browsers block sound until the person interacts with the page. Create the AudioContext on the first pointer press or key press, not on page load, and build the whole graph at that moment. When the tab is hidden, suspend the context; when it comes back, resume it. A suspended context costs nothing and cannot surprise anyone with sound from a background tab.

const unlock = () => audio.unlock(); // creates the context once
addEventListener('pointerdown', unlock, { capture: true });
addEventListener('keydown', unlock, { capture: true });

#One bus for everything

The most common mistake with synthesised sound is connecting every oscillator straight to the speakers. Ten overlapping hits add up, the output goes past full scale and the browser clips it into a crackle. Instead, every source goes through one chain. This is the one in Faultwarden; Billowpost uses the same parts in a slightly different order:

sfx ┐
music ┼─▶ high-pass 35 Hz ─▶ compressor ─▶ low-pass ─▶ make-up gain ─▶ master ─▶ limiter ─▶ soft clipper ─▶ speakers
ambience ┘                                                          ▲
                                                        menu sounds ┘
  • High-pass at 35 Hz. Low sine sweeps carry energy nobody hears on laptop speakers, but the compressor reacts to it and pumps the whole mix. Cutting below 35 Hz keeps the bass you can hear and removes the rest.
  • Bus compressor. Threshold at minus 16 dB, ratio 4, a soft 10 dB knee, 4 ms attack and 200 ms release. It glues effects and music together and brings busy moments down so they do not drown everything else.
  • Low-pass. Normally wide open at 18 kHz. When the game pauses, it slides down so the world sounds muffled behind the menu, which is a cheap and clear cue.
  • Make-up gain. After compression the mix is quieter, so a fixed gain of 2.25 (about 7 dB) brings it back to the level we aim for.
  • Limiter and soft clipper. A fast compressor at minus 3 dB with a ratio of 20 catches peaks, and a wave shaper as the last stage is linear up to 0.8 and then bends smoothly so the output never passes 0.94. Even a pile-up of twenty hits saturates gently instead of cracking.

Menu sounds join after the low-pass, at the master, so clicks in the pause menu stay crisp while the game behind it is muffled.

function safetyCurve() {
  const n = 4096, lin = 0.8, ceil = 0.94;
  const c = new Float32Array(n);
  for (let i = 0; i < n; i++) {
    const x = (i / (n - 1)) * 2 - 1;
    const a = Math.abs(x);
    const y = a <= lin ? a : lin + (ceil - lin) * Math.tanh((a - lin) / (ceil - lin));
    c[i] = Math.sign(x) * y;
  }
  return c;
}
clipper.curve = safetyCurve();
clipper.oversample = '2x';

#Two building blocks

Almost every effect in our games is a few calls to two helpers. One plays an oscillator whose pitch glides from one frequency to another under a short envelope. The other plays filtered noise, where the filter's frequency glides instead.

tone(type, f0, f1, t, attack, decay, peak) {
  const o = ctx.createOscillator();
  o.type = type;
  o.frequency.setValueAtTime(f0, t);
  if (f1 !== f0) o.frequency.exponentialRampToValueAtTime(Math.max(20, f1), t + attack + decay);
  const g = ctx.createGain();
  g.gain.setValueAtTime(0.0001, t);
  g.gain.exponentialRampToValueAtTime(peak, t + attack);
  g.gain.exponentialRampToValueAtTime(0.0001, t + attack + decay);
  o.connect(g).connect(sfxBus);
  o.start(t);
  o.stop(t + attack + decay + 0.05);
}

Exponential ramps sound natural for both pitch and loudness, but they cannot start or end at zero, which is why the envelope runs from 0.0001. The noise helper plays a four-second buffer of white noise, filled once at start-up, from a random offset and at a random rate between 0.8 and 1.2, so two noise hits in a row never share the same grain.

#A sound is layers

A convincing impact is never one oscillator. The fault strike in Faultwarden is five layers that start within 20 milliseconds of each other:

case 'strike':
  tone('sine', vary(95), 38, t, 0.004, 0.32, 0.5);      // body: a falling sub thump
  tone('triangle', vary(280), 120, t, 0.003, 0.14, 0.3); // knock: mid weight
  tone('triangle', vary(160), 60, t, 0.002, 0.12, 0.25);
  noiseHit(t, 'bandpass', vary(2200, 0.2), 900, 0.9, 0.004, 0.11, 0.45); // crack
  noiseHit(t + 0.02, 'lowpass', 1400, 300, 0.7, 0.01, 0.42, 0.4);        // rubble tail
  break;

The sine gives the weight you feel, the triangles give the knock you hear on a phone speaker that cannot play 95 Hz, the band-passed noise is the crack, and the low-passed noise is the tail of rubble. vary() moves each value up to 8 percent either way, so a hundred strikes never sound like one sample on repeat.

Some sounds carry information. When a fault opens a line of plates, each plate cracks 22 milliseconds after the previous one and 6 percent higher, so the player hears the direction and the length of the cut. The shard chime takes its pitch from the chord the music is playing at that moment and climbs one chord tone with each shard in a streak, which makes collecting feel musical rather than noisy.

#Gates against pile-ups

When six enemies fall in the same frame, six identical sounds starting together only add loudness. Each effect has a gate, a minimum time between two starts: 30 milliseconds for the strike, 55 for the shard chime, 300 for the groan of a holding slab. A second call inside the gate is simply dropped. Together with the bus, this is what keeps the busiest moments clear.

#Measure the level in a test

Ears get used to a mix, and laptops lie about bass. So our browser tests measure what actually leaves the game. An AnalyserNode after the clipper reads the output, and the end-to-end test plays a stretch of real gameplay and checks two numbers: the median loudness (RMS of the samples) must sit between 0.04 and 0.14 while playing, and no peak may reach 0.95. The same test presses mute and checks that the output goes silent. In Billowpost the measured flight mix sat at a median of 0.096 with peaks at 0.51, and mute took effect in under 120 milliseconds.

For exported sound files there are proper loudness standards with LUFS and true peak; our article on loudness, true peak and seamless loops covers them, and our sound pack Zaklinka is levelled to those targets for engines that play files instead of synthesising.

#What synthesis is good at, and what not

Synthesis is excellent for impacts, cracks, whooshes, chimes, interface clicks, wind and fire beds, and for music built from simple voices. It struggles with voices, real instruments and complex natural sounds such as a crowd. A practical rule: if a sound can be described as "a pitch that falls plus some filtered noise", synthesise it; if it needs a microphone to describe, record or license it. You can hear both of our synthesised soundtracks when the games arrive; they are listed as Coming soon in the PN Scripts games catalogue. For the bigger picture of game feel, see making a browser game feel good.

#Frequently asked questions

Why does my Web Audio game crackle when many sounds play?

The sources add up past full scale and the output clips. Route everything through one bus with a compressor, a limiter and a soft clipper as the last stage, and limit how often the same sound can start.

Why can't exponentialRampToValueAtTime reach zero?

An exponential curve never reaches zero, so the Web Audio specification does not allow zero as a target. Ramp to a tiny value such as 0.0001, which is far below hearing, and stop the source shortly after.

Is synthesised sound lighter than audio files?

Yes. The code for all effects and music in one of our games is a few dozen kilobytes, where a comparable set of compressed files would be megabytes. The cost moves to the CPU, which for a few dozen short voices is small.

How loud should a browser game be?

We aim for a median output RMS between 0.04 and 0.14 during play with peaks below 0.95, and we check it with an analyser in automated tests. Always give players separate volume controls for music and effects, and a mute key.

#Sources

Comments

Comments

Be the first to leave a comment.

Leave a comment

Next step

pnscripts.com/contact

Talk to the team

Ask about an article, or tell us about a project you want built. We reply within one business day.

Write to us

The PN Scripts family

Other PN Scripts sites

Hosting, games and the blog each have their own site, run by the same company.

  • pnscripts.com

    PN Scripts

    Software engineering

    Custom web, mobile and API development, plus our open-source products and plugins.

  • games.pnscripts.com

    Games

    Games and game servers

    The home for PN Scripts games: free browser games you can play right now, with game servers coming soon.

  • hosting.pnscripts.com

    Hosting

    Hosting and infrastructure

    Shared hosting, KVM VPS, dedicated servers and domains, from the same company that builds your project.

  • blog.pnscripts.com

    Blog

    Articles and field notes

    Practical articles on software development, hosting, open source and games, written by the people who build them.

    You are here