Skip to main content
Zubin KhavarianZubin Khavarian

Soundscapes for Web Games: A Pluggable Howler Implementation, Part 2

· Last updated · 13 min read · #audio #game-dev

Editorial illustration of a chunky cream mixing console with four channel faders in rust, blue, mint and yellow, each under its own knob, the mint one pulled down low, sound waves leaving its side, every edge drawn in wobbly ink.

The first three versions of this engine I wrote were far 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 version that stuck is one class under a hundred lines, types included, sitting on a six-function adapter. What changes from game to game is the bus underneath the engine and the lifecycle that starts and stops it, so those two seams are what this post is about.

Why Howler?

You could go straight to AudioContext and hand-roll everything. I’ve done it, and I don’t recommend it.

For ambience work, Howler (opens in a new tab) is the right tradeoff. It handles cross-browser autoplay and gives you per-instance volume, pan, rate and fade, with sprites if you want them, in about 7 KB gzipped. It wraps Web Audio, so you keep the graph and stop writing the boilerplate.

Everything below assumes Howler, but the bus adapter is the seam: swap in raw Web Audio later and the engine doesn’t change. The demos on this page prove it, because they run the engine class below, unmodified, over a small Web Audio adapter.

The def

A scene is one bed and any number of emitters, and each emitter has a pool of samples and a random range for every knob.

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’s 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, squeezed to a demo timescale (a real café breathes slower). The phone has one sample and rings rarely, quiet and off to one side.

That’s the whole tuning surface. You can change how a scene feels without touching engine code, which is exactly what you want when a sound designer is sitting next to you.

This def is live, so play with it:

Live def

The CAFE def above, running

Press play to start

Valid def. Changes apply ~half a second after you stop typing.

Live emitters0/3
  • cups-clangingfired ×0
  • cash-registerfired ×0
  • espresso-machinefired ×0
  • phone-ringingfired ×0
This is the actual Soundscape class from this post (same café samples as Part 1) over a raw Web Audio adapter. Tighten an interval, widen the pan, drop the cap to 1; the engine hot-swaps on every valid edit.

The adapter

The engine never calls Howler. It calls a bus adapter, and that’s 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;
}

Anything that satisfies those six functions can host the engine. I know that’s suspiciously small for something called a mixer; 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 doesn’t care either way.

Reactr3fPhaserthree.jsdefbed + emittersSoundscapestart() · dispose()one class, < 100 linesBusAdapter: six functionssingle busHowler, one duckWeb Audiothis page's demosmulti-bus mixermusic · ambience · sfx
Two seams: hosts only call start and dispose, and the engine only talks to the six-function adapter. Either side can change without touching the engine.

The adapter is the only part that should change when the audio setup changes, and the host binding is the only part that changes when the framework does. The engine in the middle stays put.

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 adapter ducks ambience while voice-over plays, which is all most projects need. The multi-bus version has the same shape with one more layer of multiplication; there’s a comparison table after the host bindings.

There’s one gap. duck() only fades the live beds, so an emitter that’s 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 the live emitters with their base volumes (the set is already there) and 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 React, or anything else about its host.

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’s the whole engine.

The only nontrivial bit is the scheduleNext → fireEmitter → scheduleNext recursion. Each emitter is a metronome that re-arms itself: fire, pick a new random interval, set a fresh setTimeout, and go again.

scheduleNextsetTimeoutfireEmitterplayEmitternothinga random intervalat the cap: skip this beatunder the capre-armdisposed
Each emitter re-arms itself. The disposed check is what turns a late timer from the old scene into nothing.

The disposed flag is the kill switch. Without it, a late timer from the old scene calls bus.playEmitter on a bus that’s already moved on, and 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, and stopAll then hard-stops whatever is still registered, which by that point is just emitters.

That only works because the adapter’s stopBed removes the bed from its live set right away and lets the fade finish on its own, as ours does above. If you write an adapter whose stopAll flattens every sound unconditionally, the fade dies with it, so treat “stopBed unregisters synchronously” as part of the contract.

Wiring it in

The engine is the same in every host; only the lifecycle around it changes.

React. For 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 doesn’t contain audio nodes; the audio engine is a side effect keyed on sceneId.

There’s one footgun: def is an effect dependency, so if you pass an inline object literal, every render disposes the engine and builds a new one. You’ll hear the bed restart and blame the engine, but nothing’s wrong with it. Hoist defs to module scope, like CAFE above, or memoize them.

r3f. It’s exactly the same <SoundscapeRunner>; just put it outside <Canvas>.

<>
  <SoundscapeRunner sceneId={current} def={SCENES[current]} />
  <Canvas>{/* your scene */}</Canvas>
</>

The audio engine has no business inside the render loop, and r3f doesn’t need to know it exists.

Phaser. There’s no React and no useEffect here, so bind to scene events instead.

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(), and that part matters. By the time create() runs, the "create" event has already fired, so 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 does the job that useEffect’s cleanup did in the React case: same shape, different host.

Imperative three.js. Own the instance on whatever your singleton is, and start it after your asset loader’s “ready” event rather than at construction, because the 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 and build the new one. The bus is shared, so the duck state survives the swap, and the live-def demo above does exactly this on every edit.

Across all four hosts, the only thing that changes is who calls dispose.

Single-bus vs multi-bus

ConcernSingle-bus adapterMulti-bus mixer
BusesOne ambience busmaster / music / ambience / sfx
Volume formulabase × duckMultipliermaster × bus × baseline
Duck implementationMultiplier flips to 0.25setBusVolume("ambience", 0.25, 500)
Per-category muteNot reallysetBusVolume("sfx", 0, 0) and you are done
When it is the answerVoice-over over ambienceMusic + SFX + ambience all need their own knobs

Start single-bus, and promote to multi-bus the day a designer asks you to lower the music but keep the SFX at full. Both satisfy the same BusAdapter contract, so the engine doesn’t change. Here’s the same def on the same engine, with only the adapter underneath swapped:

Adapter swap

Same def, same engine, two adapters

Press play to start

Single-bus: music and SFX have no fader of their own; the music rides the ambience bus and ducks with it.

What you should hear: play the VO in single-bus mode and the music dips with the room. Flip to multi-bus and the room ducks while the music keeps playing over it.

Howls and the loading bar

This one tripped me up. In three.js you already have a LoadingManager driving the loading UI, but Howl downloads happen outside it by default, so the bar finishes before the audio is ready and the first scene loads with the bed still buffering. The fix is to 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 through scene.load.start() and a custom file type, and the pattern is identical.

What’s the catch?

The engine assumes you preload every sample by name. Lazy loading is possible, but the contract gets uglier and you owe the user a fallback while a sample streams in.

setTimeout is a lousy scheduler. It’s fine for ambience, because nobody can hear ±50 ms on a creaky-floorboard interval, but it’s no good for musical timing, so don’t repurpose this for that.

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. The first sound must follow a user gesture, so gate start() behind a click. On mobile Safari, a programmatic play() before the first interaction fails silently and leaves the Howl half-broken.

StrictMode in dev. useEffect runs twice, so dispose() must be reentrant; the disposed flag above is what makes the second teardown a no-op.

Timer leaks on scene change. Every setTimeout id has to be tracked and cleared, and the check at the top of scheduleNext and fireEmitter is what makes a late timer harmless if one 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 samples shouldn’t throw. Lazy registration, MDX-driven content and hot reload will all ship a name without a sample sooner or later. Log it, return null and keep going.

The engine does not change

I’d start with the single-bus adapter hosted from useEffect, and bring in the mixer the day a designer asks to move the music without touching the SFX.

Get new posts by email

One short email when a post goes out, about once a week. Unsubscribe any time.

Or follow along:RSSGitHubX

Zubin Khavarian, Software EngineerWritten by
READ~/blog/web-game-soundscapes-2-howler~13 min0%
zubin@zubin.dev: man keys

// go to

  • ghhome
  • gwwriting
  • gaapps
  • gpprojects
  • goopen source
  • gnnow
  • guuses

// move

  • jscroll down
  • kscroll up
  • ggtop of page
  • Gbottom of page
  • }next section (posts)
  • {previous section (posts)
  • ]newer post
  • [older post

// do

  • ⌘Ksearch everything
  • /search, too
  • ttoggle light / dark
  • ?this sheet
  • escclose