Skip to main content
Zubin KhavarianZubin Khavarian
Editorial illustration of a quiet room: a grandfather clock, a cat asleep on a round rug, a kettle on a wood stove, and a bird outside the window, each giving off faint sound ripples.

Soundscapes for Web Games: The Layer Beneath Your SFX, Part 1

· Last updated

Part 1 of 2. Part 2 is the Howler implementation.

I shipped web games for years that went dead the second you stood still. The samples were fine. The music was fine.

You’ve heard this on a video call. The other person is talking and the line sounds normal. They mute, and their room disappears. No HVAC, no chair, no car through the window. The line isn’t quiet. It’s empty.

Most web games mute the room on purpose. They play a footstep, a click, a music loop, and they leave the space between those events silent.

The fix is to keep the room on. I call that layer a soundscape.

Layered comparison

Music / Bed / Emitters / SFX

Toggle any layer to start
  • Music

    Composed loop, market square cafe

  • Bed

    Restaurant room tone, looping under everything

  • Emitters

    Cash register, espresso, cups, phone, on randomised timers

  • SFX

    Click the strip below to fire a UI sound

Level
What you should hear: music + SFX alone reads as a stage set. Add the bed, then emitters, and the café starts existing around you.

What is a soundscape?

Diagram of music, SFX, and soundscape feeding into a three-bus mixer. Music is a looping continuous layer, SFX are one-shot events, and soundscape is continuous ambience with random emitters.

A soundscape is not music, and it is not SFX. Mix the three on one bus and the game sounds like a YouTube video with the ads left in.

LayerWhat it isTriggered byWants
MusicA composed, looping trackScene or beatIts own bus, fade in/out, ducks rarely
SFXOne-shot reactions to gameplayPlayer or gameTight latency, no fades
SoundscapeAmbient bed plus randomized non-musical emittersA timer, not youA bus that ducks under dialog

The bed is a looping ambience. Room tone, wind, distant traffic.

The emitters are short non-musical samples on a timer, with randomized pan, volume, and pitch. A floorboard creaks, a bird calls once, a door closes somewhere down the hall.

Together they fill the silence between SFX events, so there is sound in the room before the player does anything.

Why is this worse on the web?

This isn’t a new idea. Native engines have been shipping it for years. Unity gives you AudioMixerGroup. Unreal gives you Submixes and Sound Cues. You wire a spatial mixer, an audio graph, bus routing, and a priority system in an editor, then you forget about it.

The web hands you AudioContext 🔗 and a GainNode 🔗. That’s the entire mixer.

Phaser ships WebAudioSound 🔗. three.js ships PositionalAudio 🔗. drei wraps that as <PositionalAudio> 🔗 for r3f. None of them give you scheduled non-musical ambience, or a bus that can duck a category of sounds while a voice-over plays.

Most web games never get past music plus SFX. The work is not hard. Nothing in the engine nudges you toward doing it, so most of us never do.

Is ambience just a quiet music loop?

I used to think so. Drop a 30-second room-tone MP3 into the music slot, set the volume to 0.2, and call it ambience.

A static loop is easy for the ear to clock. Your brain finds the seam within about three repeats, and once it has the seam it cannot un-hear it. A single unchanging loop has the same mood as a fridge hum. The room is on. Nobody is home.

But isn’t a quiet loop better than nothing?

Yes. A bed by itself beats silence. The bed is the baseline, and emitters are what you put on top of it.

A floorboard creak at second 14, a distant car at second 22, a bird panned off to the right at second 31. Each fire is a one-shot. Never the same sample twice in a row, and never quite the same volume.

Timeline of randomized emitters, creak, cup clink, bird, car, door, espresso machine, scheduled at irregular intervals on top of a quiet bed. Each fire has its own pan, volume, and pitch.

A short crossfade at the loop point hides the restart. Emitters on top give the ear something better to do than hunt for the seam. Both are in the demo below. The red flash marks the exact moment the loop wraps.

Seam audition

Hear the loop seam

Press play to start
0s6.00s loop
Loops0
What you should hear: with both toggles off, a hard restart at the red seam line every few seconds. The crossfade hides the restart itself; emitters give your ear something better to track than the loop.

Stand still in a real room for a minute and listen. The ambience you hear is not a single texture. A refrigerator compressor cycles on, a neighbor’s door closes, the floor settles and creaks, a gust hits a window.

None of those things are looping. They are events on top of a near-silent bed. A soundscape system is that, on a timer.

The knobs

Skip any of these and the whole layer collapses back into a tape loop.

A sample pool per emitter, with last-picked exclusion. Each emitter holds three to six short samples. Each fire picks one at random, but never the one it just played. The ear locks onto an immediate repeat faster than it locks onto anything else, which is why this is the biggest win per byte of work.

Random pan, volume, and pitch within tight ranges. Pan ±0.4. Volume 0.7 to 1.0. Rate 0.95 to 1.05. Tight enough that nothing sounds broken, wide enough that no two fires are identical.

Pitch variance is the easiest one to overdo. Past about ±5%, samples start sounding chipmunky or sluggish. Ask me how I know.

A concurrency cap. maxConcurrentEmitterInstances stops a pile-up. Bad RNG will occasionally fire three emitters within 200ms, and without a cap that stretch craters the mix and clips the master. Cap it at three or four. The cap will fire maybe once a minute, and you won’t hear it.

Everything else is plumbing.

Three panels: a sample pool with last-picked exclusion, random variation ranges for pan, volume, and pitch, and a concurrency cap blocking extra emitters past the limit so the master does not overload.

Parameter playground

The three knobs, live

Press Fire to start

Pool

  • cups-clanging
  • cash-register
  • phone-ringing
  • espresso-machine
What you should hear: no two fires identical. Drop the cap to 1 and mash Fire; the extra fires get blocked instead of stacking into mud.

Same engine, different emphasis

The same three knobs show up in every host. What changes is which one you lean on.

2D Phaser games. Key the bed to the scene, and schedule emitters from scene update or from timers. The listener rarely moves, so pan is decorative rather than spatial. Three or four samples per emitter with last-picked exclusion is enough to keep the ear from locking onto a loop.

3D three.js or r3f games. The bed is still 2D. Room tone has no position. It is everywhere. Emitters can stay 2D-randomized, or become true positional sources via PositionalAudio 🔗 when they should pan as the camera turns.

Ducking matters more here. Dialog and narration get buried under ambience faster when music, footsteps, and UI are all competing for the same space.

Narrative scenes. Ducking is the biggest win. Without it, voice-over fights the ambience, and you crank the VO bus until the room is a whisper.

Duck the ambience bus in about 120ms when the line starts, then release it over about 450ms when the line ends. The VO sits on top. The room comes back when the line is over.

Waveforms of a voice-over bus and an ambience bus. When dialog starts, the ambience ducks down. When the line ends, it releases back to full level.

Ducking A/B

Voice-over over the ambience bed

Start the bed to begin

You finally made it to the tavern.

Depth
Bed level
0 dB
What you should hear: the room drops away the moment the line starts, then breathes back in when it ends. Toggle Duck off to hear the VO fight the bed instead.

What’s the catch?

Authoring three to six samples per emitter is more work than wiring up one. The variety budget is real, and you will feel it the first time a designer asks for “one more bird.”

A concurrency cap means an emitter you wanted to hear will occasionally get dropped. Almost always fine. Sometimes a tell, if the dropped fire was the one sound that sold the room.

Ducking adds bus complexity, and bugs hide there. If you have one bus today, you will have four tomorrow.

The whole layer is invisible when it works. Nobody on the team will notice, and that is the point. It also makes the work a tough sell in a sprint review, because the demo looks like “the room, but it sounds like a room.”

What should you not do?

I have written every one of these at least once.

Looping the bed at full volume, with no fade-in. On first load the player hears a hard cut into ambience. A 500ms fade-in costs nothing and removes that seam.

Hardcoded <audio> tags. No bus routing, no ducking, no per-instance volume. You cannot duck what you cannot address. Use Web Audio, or a wrapper like Howler, for anything that has to coexist with other sounds.

One Howl per emitter fire. A new Howl on every fire leaks memory and re-decodes the sample. Construct once at load, then trigger play() per fire.

Duck by pausing the bed. Pausing kills the loop’s phase, and you get a click on resume. Lower the bus gain instead.

Forgetting to dispose timers on scene change. A setTimeout armed in scene A will fire an emitter in scene B if you don’t clear it. Track every timer id, clear them all on dispose, flip a disposed flag, and check that flag before re-arming inside the recursion.

If you only take one of these home, take the timers one. It’s the one that finds you at 11pm.

Part 2

Part 2 is one engine-agnostic class, a six-function bus adapter, and host bindings for React, Phaser, and imperative three.js. The engine is under a hundred lines.

What varies is the bus underneath it and the lifecycle binding above it.

I’d start with last-picked exclusion and a concurrency cap. Those two knobs are what stop a bed from sounding like a tape.

Stay in touch

Don't miss out on new posts or project updates. Hit me up on X for updates, queries, or some good ol' tech talk.

Follow @zkmake
Zubin Khavarian, Principal Front-End EngineerWritten by