
Soundscapes for Web Games: A Pluggable Howler Implementation, Part 2
· Last updated
Part 2 of 2. Part 1 is the concept.
The first three versions of this engine I wrote were way too big. Class hierarchies, scene managers, a mixer service with opinions about your DI container. I kept thinking it had to be more than this, and it kept refusing to be.
The whole thing is one class under a hundred lines, types included, with a six-function adapter underneath it. What varies is the bus underneath the engine and the lifecycle binding above it.
Why Howler?
You could go straight to AudioContext and hand-roll everything. I’ve done it. I don’t recommend it.
For ambience work, Howler 🔗 is the right tradeoff. It handles cross-browser autoplay, and gives you per-instance volume, pan, rate, and fade. Sprite support if you want it. Around 7KB gzipped. It wraps Web Audio, so you keep the graph and stop writing the boilerplate.
Everything below assumes Howler. The bus adapter is the seam. Swap in raw Web Audio later and the engine does not change. The demos on this page do exactly that. They run the engine class below, unmodified, over a small Web Audio adapter.
That last claim is load-bearing.
The def
One bed. N emitters. Each emitter has a sample pool and randomization ranges.
export type Range = [min: number, max: number];
export interface EmitterDef {
samples: string[];
intervalMs: Range;
volume: Range;
pan: Range;
rate: Range;
}
export interface SoundscapeDef {
bed: { sample: string; volume: number; fadeInMs?: number };
emitters: EmitterDef[];
maxConcurrentEmitterInstances?: number;
}
rate is what Part 1 called the pitch knob. Howler’s API calls it rate, so the def follows the API.
Here is the café from Part 1.
export const CAFE: SoundscapeDef = {
bed: { sample: "restaurant-bed-light", volume: 0.9, fadeInMs: 800 },
emitters: [
{
samples: ["cups-clanging", "cash-register", "espresso-machine"],
intervalMs: [3000, 8000],
volume: [0.25, 0.45],
pan: [-0.5, 0.5],
rate: [0.95, 1.05],
},
{
samples: ["phone-ringing"],
intervalMs: [12000, 25000],
volume: [0.1, 0.18],
pan: [-0.4, 0.4],
rate: [0.98, 1.02],
},
],
maxConcurrentEmitterInstances: 3,
};
The counter pool has three samples and a tight interval, compressed to a demo timescale. A real café breathes slower. The phone has one sample and rings rarely, quiet and off to one side.
This is the whole tuning surface. You can iterate a scene’s feel without touching engine code, which is the property you want when a sound designer is sitting next to you.
This def is live.
Live def
The CAFE def above, running
Valid def. Changes apply ~half a second after you stop typing.
cups-clangingfired ×0cash-registerfired ×0espresso-machinefired ×0phone-ringingfired ×0
The adapter
The engine never calls Howler. It calls a bus adapter, and that is what makes it pluggable.
export interface BusAdapter {
playBed(name: string, opts: { volume: number; fadeInMs?: number }): number | null;
playEmitter(
name: string,
opts: {
volume: number;
pan: number;
rate: number;
onEnd?: () => void;
},
): number | null;
stopBed(id: number, fadeOutMs?: number): void;
stopAll(): void;
duck(): void;
unduck(): void;
}
Six functions. Anything that satisfies this shape can host the engine.
Six functions is suspiciously small for something called a mixer. I know. The first time I wrote this contract I deleted half of it twice before I trusted that it was enough.
Want a single ambience bus with a global duck multiplier? Implement it that way. Want a full master, music, ambience, and SFX mixer? Implement duck() as a category gain change. The engine does not care.
The adapter is the only part that should change when the host changes. Everything above it stays.
A minimal Howler adapter
The simplest adapter is one ambience gain, a duck multiplier, and a map of preloaded Howls keyed by name.
import { Howl } from "howler";
const AMBIENCE_BASE_VOLUME = 1.0;
const DUCK_MULTIPLIER = 0.25;
const DUCK_FADE_MS = 300;
export function createSingleBusAdapter(samples: Record<string, string>): BusAdapter {
const howls = new Map<string, Howl>();
for (const [name, src] of Object.entries(samples)) {
howls.set(name, new Howl({ src: [src], preload: true }));
}
let ambienceMultiplier = 1.0;
const liveBeds = new Set<{ howl: Howl; id: number; baseVolume: number }>();
const liveEmitters = new Set<{ howl: Howl; id: number }>();
function effectiveVolume(base: number) {
return base * AMBIENCE_BASE_VOLUME * ambienceMultiplier;
}
return {
playBed(name, { volume, fadeInMs }) {
const howl = howls.get(name);
if (!howl) return null;
const id = howl.play();
howl.loop(true, id);
const target = effectiveVolume(volume);
if (fadeInMs) {
howl.volume(0, id);
howl.fade(0, target, fadeInMs, id);
} else {
howl.volume(target, id);
}
liveBeds.add({ howl, id, baseVolume: volume });
return id;
},
playEmitter(name, { volume, pan, rate, onEnd }) {
const howl = howls.get(name);
if (!howl) return null;
const id = howl.play();
howl.volume(effectiveVolume(volume), id);
howl.stereo(pan, id);
howl.rate(rate, id);
const entry = { howl, id };
liveEmitters.add(entry);
howl.once(
"end",
() => {
liveEmitters.delete(entry);
onEnd?.();
},
id,
);
return id;
},
stopBed(id, fadeOutMs = 500) {
for (const entry of liveBeds) {
if (entry.id !== id) continue;
entry.howl.once("fade", () => entry.howl.stop(id), id);
entry.howl.fade(entry.howl.volume(id) as number, 0, fadeOutMs, id);
liveBeds.delete(entry);
return;
}
},
stopAll() {
for (const { howl, id } of liveBeds) howl.stop(id);
for (const { howl, id } of liveEmitters) howl.stop(id);
liveBeds.clear();
liveEmitters.clear();
},
duck() {
ambienceMultiplier = DUCK_MULTIPLIER;
for (const { howl, id, baseVolume } of liveBeds) {
howl.fade(howl.volume(id) as number, effectiveVolume(baseVolume), DUCK_FADE_MS, id);
}
},
unduck() {
ambienceMultiplier = 1.0;
for (const { howl, id, baseVolume } of liveBeds) {
howl.fade(howl.volume(id) as number, effectiveVolume(baseVolume), DUCK_FADE_MS, id);
}
},
};
}
This is the adapter that ducks ambience while voice-over plays. Good for most projects. The multi-bus version is the same shape with one more layer of multiplication. There’s a table after the host bindings.
One gap. duck() only fades the live beds. An emitter that is already mid-flight rides through the duck at full volume. Emitters are short, and anything that fires during the duck spawns at the ducked level, so the room still drops. If a two-second espresso clip stepping on your VO bothers you, track live emitter entries with their base volumes. The set is already there. Fade them the same way.
The Soundscape class
Given a SoundscapeDef and a BusAdapter, the class owns the bed instance, the emitter timers, and the live emitter count. It doesn’t know what Howler is, or what React is. It doesn’t know the host at all.
function pickInRange([min, max]: Range) {
return min + Math.random() * (max - min);
}
function pickSampleExcluding(samples: string[], last: string | null) {
if (samples.length === 1) return samples[0]!;
let next = samples[Math.floor(Math.random() * samples.length)]!;
while (next === last) {
next = samples[Math.floor(Math.random() * samples.length)]!;
}
return next;
}
export class Soundscape {
private bedId: number | null = null;
private timers = new Set<ReturnType<typeof setTimeout>>();
private liveEmitters = 0;
private lastPicked = new Map<EmitterDef, string>();
private disposed = false;
constructor(
private readonly def: SoundscapeDef,
private readonly bus: BusAdapter,
) {}
start() {
if (this.disposed) return;
this.bedId = this.bus.playBed(this.def.bed.sample, {
volume: this.def.bed.volume,
fadeInMs: this.def.bed.fadeInMs ?? 500,
});
for (const emitter of this.def.emitters) {
this.scheduleNext(emitter);
}
}
private scheduleNext(emitter: EmitterDef) {
if (this.disposed) return;
const delay = pickInRange(emitter.intervalMs);
const timer = setTimeout(() => {
this.timers.delete(timer);
this.fireEmitter(emitter);
}, delay);
this.timers.add(timer);
}
private fireEmitter(emitter: EmitterDef) {
if (this.disposed) return;
const cap = this.def.maxConcurrentEmitterInstances ?? 4;
if (this.liveEmitters >= cap) {
this.scheduleNext(emitter);
return;
}
const last = this.lastPicked.get(emitter) ?? null;
const sample = pickSampleExcluding(emitter.samples, last);
this.lastPicked.set(emitter, sample);
this.liveEmitters++;
this.bus.playEmitter(sample, {
volume: pickInRange(emitter.volume),
pan: pickInRange(emitter.pan),
rate: pickInRange(emitter.rate),
onEnd: () => {
this.liveEmitters = Math.max(0, this.liveEmitters - 1);
},
});
this.scheduleNext(emitter);
}
dispose() {
if (this.disposed) return;
this.disposed = true;
for (const t of this.timers) clearTimeout(t);
this.timers.clear();
// stopBed first: the adapter unregisters the bed and starts its fade.
// stopAll is then the hard kill for whatever is still live (the emitters).
if (this.bedId !== null) this.bus.stopBed(this.bedId, 500);
this.bus.stopAll();
}
}
That is the whole engine.
The scheduleNext → fireEmitter → scheduleNext recursion is the only nontrivial bit. Each emitter is a metronome that re-arms itself. Fire, pick a new random interval, set a fresh setTimeout, repeat.
The disposed flag is the kill switch. Without it, a late-firing timer from the old scene will call bus.playEmitter on a bus that has already moved on. You get a ghost bird two seconds into the next level.
Why a flag instead of just clearing the timers?
React StrictMode, and any host that tears down and rebuilds in the same tick, will dispose while a timer is mid-fire. By the time clearTimeout runs, the callback is already queued. The flag is what keeps fireEmitter from doing anything irreversible after the host has decided the instance is dead.
One ordering note in dispose. stopBed goes first so the bed gets its fade-out. stopAll then hard-stops whatever is still registered, which by that point is just emitters.
This only works because the adapter’s stopBed removes the bed from its live set immediately and lets the fade finish on its own. Ours does, in the adapter above. If you write an adapter whose stopAll flattens every sound unconditionally, the fade dies with it. Treat “stopBed unregisters synchronously” as part of the contract.
Wiring it in
Same engine. Different lifecycle.
React. DOM-hosted Phaser, or plain React UI.
import { useEffect } from "react";
import { Soundscape, type SoundscapeDef } from "./Soundscape";
import { bus } from "./bus";
export function SoundscapeRunner({ sceneId, def }: { sceneId: string; def: SoundscapeDef }) {
useEffect(() => {
const scape = new Soundscape(def, bus);
scape.start();
return () => scape.dispose();
}, [sceneId, def]);
return null;
}
Render it as a sibling to your <Canvas> or Phaser mount. The render tree does not contain audio nodes. The audio engine is a side effect keyed on sceneId.
One footgun. def is an effect dependency. Pass an inline object literal and every render disposes the engine and builds a new one. You will hear the bed restart and blame the engine. Nothing is wrong with the engine. Hoist defs to module scope, like CAFE above, or memoize them.
r3f. Exactly the same <SoundscapeRunner>. Put it outside <Canvas>.
<>
<SoundscapeRunner sceneId={current} def={SCENES[current]} />
<Canvas>{/* your scene */}</Canvas>
</>
The audio engine has no business inside the render loop. r3f does not need to know it exists.
Phaser. No React. No useEffect. Bind to scene events.
export function attachSoundscape(scene: Phaser.Scene, def: SoundscapeDef) {
const scape = new Soundscape(def, bus);
scene.events.once("create", () => scape.start());
scene.events.once("shutdown", () => scape.dispose());
return scape;
}
Call it in your scene’s init(). That part matters. By the time create() runs, the "create" event has already fired. That once never resolves, and you wait forever for a soundscape that never starts.
If you attach late, call scape.start() directly instead of listening for the event. The shutdown hook is what useEffect cleanup was doing in the React case. Same shape, different host.
Imperative three.js. Own the instance on whatever your singleton is. Start it after your asset loader’s “ready” event, not at construction. First-play has to follow a user gesture or autoplay will mute it.
class World {
private scape: Soundscape | null = null;
async load() {
await this.loader.loadAll();
// user has clicked "Start" by the time this resolves
this.scape = new Soundscape(CAFE, bus);
this.scape.start();
}
setSoundscape(def: SoundscapeDef) {
this.scape?.dispose();
this.scape = new Soundscape(def, bus);
this.scape.start();
}
destroy() {
this.scape?.dispose();
this.scape = null;
}
}
setSoundscape is the hot-swap. Dispose the old one, build the new one. The bus is shared, so the duck state survives the swap. The live-def demo above does this on every edit.
Same engine. The only thing that changes is who calls dispose.
Single-bus vs multi-bus
| Concern | Single-bus adapter | Multi-bus mixer |
|---|---|---|
| Buses | One ambience bus | master / music / ambience / sfx |
| Volume formula | base × duckMultiplier | master × bus × baseline |
| Duck implementation | Multiplier flips to 0.25 | setBusVolume("ambience", 0.25, 500) |
| Per-category mute | Not really | setBusVolume("sfx", 0, 0) and you are done |
| When it is the answer | Voice-over over ambience | Music + SFX + ambience all need their own knobs |
Start single-bus. Promote to multi-bus the day a designer asks for “lower the music but keep SFX at full.” Both satisfy the same BusAdapter contract. The engine does not change.
Same def, same engine. The only difference is the adapter underneath.
Adapter swap
Same def, same engine, two adapters
Single-bus: music and SFX have no fader of their own; the music rides the ambience bus and ducks with it.
Howls and the loading bar
This one tripped me up. For three.js you already have a LoadingManager driving the loading UI. Howl downloads happen outside it by default, so the bar finishes before audio is ready, and the first scene loads with the bed still buffering.
Teach Howl to report into the manager.
import { Howl } from "howler";
import type { LoadingManager } from "three";
export function registerAudioWithManager(src: string, manager: LoadingManager) {
manager.itemStart(src);
const howl = new Howl({
src: [src],
preload: true,
onload: () => manager.itemEnd(src),
onloaderror: (_id, err) => {
console.warn("audio load failed", src, err);
manager.itemEnd(src);
},
});
return howl;
}
Call this instead of new Howl(...) in your adapter’s preload step and the loading bar waits for audio. Phaser has the same hook via scene.load.start() and a custom file type. The pattern is identical.
What’s the catch?
The engine assumes you preload every sample by name. Lazy loading is possible. The contract gets uglier, and you owe the user a fallback while a sample streams in.
setTimeout is a lousy scheduler. Fine for ambience. The ear cannot tell ±50ms on a creaky-floorboard interval. Not fine if you try to repurpose this for musical timing. Don’t.
The BusAdapter interface is small on purpose. If you want a seventh function, you probably want a different abstraction underneath, not a bigger one on top.
What will bite you?
Every one of these has bitten me in production.
Autoplay. First sound must follow a user gesture. Gate start() behind a click. On mobile Safari, a programmatic play() before the first interaction silently fails and leaves the Howl in a half-broken state.
StrictMode in dev. useEffect runs twice. dispose() must be reentrant. The disposed flag above is what keeps the second teardown a no-op.
Timer leaks on scene change. Every setTimeout id has to be tracked and cleared. The check at the top of scheduleNext and fireEmitter is what makes a late-firing timer harmless if it slips past dispose.
Fade then stop. howl.fade() fires a "fade" event when it finishes. Chain stop() in that callback, as stopBed does, or Howler keeps a zero-volume instance alive forever.
Missing-sample warnings should not throw. Lazy registration, MDX-driven content, hot-reload. Any of these will ship a name without a sample. Log it, return null, keep going.
The engine does not change
I’d start with the single-bus adapter and host it from useEffect. Promote the mixer the day a designer asks to move music without touching SFX.
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