<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/"><channel><title>Game audio | Zubin Khavarian</title><description>Every part of Zubin Khavarian&apos;s series &quot;Game audio&quot;, as it publishes.</description><link>https://zubin.dev/</link><language>en-us</language><atom:link href="https://zubin.dev/rss/game-audio.xml" rel="self" type="application/rss+xml"/><lastBuildDate>Mon, 19 Oct 2026 23:00:00 GMT</lastBuildDate><image><url>https://zubin.dev/avatar.png</url><title>Game audio | Zubin Khavarian</title><link>https://zubin.dev/</link></image><item><title>Soundscapes for Web Games: A Pluggable Howler Implementation, Part 2</title><link>https://zubin.dev/blog/web-game-soundscapes-2-howler/</link><guid isPermaLink="true">https://zubin.dev/blog/web-game-soundscapes-2-howler/</guid><description>The soundscape engine is one class under a hundred lines. A six-function adapter is the seam.</description><pubDate>Mon, 11 May 2026 17:00:00 GMT</pubDate><content:encoded><![CDATA[<p>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.</p>
<p>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.</p>
<h2 id="why-howler">Why Howler?</h2>
<p>You could go straight to <code>AudioContext</code> and hand-roll everything. I’ve done it, and I don’t recommend it.</p>
<p>For ambience work, <a href="https://howlerjs.com/" rel="noopener noreferrer" target="_blank">Howler<span class="ext-link"><span class="ext-link-sr"> (opens in a new tab)</span></span></a> 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.</p>
<p>Everything below assumes Howler, but the <strong>bus adapter</strong> 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.</p>
<h2 id="the-def">The def</h2>
<p>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.</p>
<pre class="astro-code astro-code-themes zubin-light zubin-dark has-copy-btn" tabindex="0" data-language="ts"><code><span class="line"><span>export</span><span> type</span><span> Range</span><span> =</span><span> [</span><span>min</span><span>:</span><span> number</span><span>,</span><span> max</span><span>:</span><span> number</span><span>];</span></span>
<span class="line"></span>
<span class="line"><span>export</span><span> interface</span><span> EmitterDef</span><span> {</span></span>
<span class="line"><span>  samples</span><span>:</span><span> string</span><span>[];</span></span>
<span class="line"><span>  intervalMs</span><span>:</span><span> Range</span><span>;</span></span>
<span class="line"><span>  volume</span><span>:</span><span> Range</span><span>;</span></span>
<span class="line"><span>  pan</span><span>:</span><span> Range</span><span>;</span></span>
<span class="line"><span>  rate</span><span>:</span><span> Range</span><span>;</span></span>
<span class="line"><span>}</span></span>
<span class="line"></span>
<span class="line"><span>export</span><span> interface</span><span> SoundscapeDef</span><span> {</span></span>
<span class="line"><span>  bed</span><span>:</span><span> {</span><span> sample</span><span>:</span><span> string</span><span>;</span><span> volume</span><span>:</span><span> number</span><span>;</span><span> fadeInMs</span><span>?:</span><span> number</span><span> };</span></span>
<span class="line"><span>  emitters</span><span>:</span><span> EmitterDef</span><span>[];</span></span>
<span class="line"><span>  maxConcurrentEmitterInstances</span><span>?:</span><span> number</span><span>;</span></span>
<span class="line"><span>}</span></span></code></pre>
<p><code>rate</code> is what <a href="https://zubin.dev/blog/web-game-soundscapes-1-concepts/">Part 1</a> called the pitch knob. Howler’s API calls it rate, so the def follows the API.</p>
<p>Here’s the café from Part 1.</p>
<pre class="astro-code astro-code-themes zubin-light zubin-dark has-copy-btn" tabindex="0" data-language="ts"><code><span class="line"><span>export</span><span> const</span><span> CAFE</span><span>:</span><span> SoundscapeDef</span><span> =</span><span> {</span></span>
<span class="line"><span>  bed</span><span>:</span><span> {</span><span> sample</span><span>:</span><span> &quot;restaurant-bed-light&quot;</span><span>,</span><span> volume</span><span>:</span><span> 0</span><span>.</span><span>9</span><span>,</span><span> fadeInMs</span><span>:</span><span> 800</span><span> },</span></span>
<span class="line"><span>  emitters</span><span>:</span><span> [</span></span>
<span class="line"><span>    {</span></span>
<span class="line"><span>      samples</span><span>:</span><span> [</span><span>&quot;cups-clanging&quot;</span><span>,</span><span> &quot;cash-register&quot;</span><span>,</span><span> &quot;espresso-machine&quot;</span><span>],</span></span>
<span class="line"><span>      intervalMs</span><span>:</span><span> [</span><span>3000</span><span>,</span><span> 8000</span><span>],</span></span>
<span class="line"><span>      volume</span><span>:</span><span> [</span><span>0</span><span>.</span><span>25</span><span>,</span><span> 0</span><span>.</span><span>45</span><span>],</span></span>
<span class="line"><span>      pan</span><span>:</span><span> [-</span><span>0</span><span>.</span><span>5</span><span>,</span><span> 0</span><span>.</span><span>5</span><span>],</span></span>
<span class="line"><span>      rate</span><span>:</span><span> [</span><span>0</span><span>.</span><span>95</span><span>,</span><span> 1</span><span>.</span><span>05</span><span>],</span></span>
<span class="line"><span>    },</span></span>
<span class="line"><span>    {</span></span>
<span class="line"><span>      samples</span><span>:</span><span> [</span><span>&quot;phone-ringing&quot;</span><span>],</span></span>
<span class="line"><span>      intervalMs</span><span>:</span><span> [</span><span>12000</span><span>,</span><span> 25000</span><span>],</span></span>
<span class="line"><span>      volume</span><span>:</span><span> [</span><span>0</span><span>.</span><span>1</span><span>,</span><span> 0</span><span>.</span><span>18</span><span>],</span></span>
<span class="line"><span>      pan</span><span>:</span><span> [-</span><span>0</span><span>.</span><span>4</span><span>,</span><span> 0</span><span>.</span><span>4</span><span>],</span></span>
<span class="line"><span>      rate</span><span>:</span><span> [</span><span>0</span><span>.</span><span>98</span><span>,</span><span> 1</span><span>.</span><span>02</span><span>],</span></span>
<span class="line"><span>    },</span></span>
<span class="line"><span>  ],</span></span>
<span class="line"><span>  maxConcurrentEmitterInstances</span><span>:</span><span> 3</span><span>,</span></span>
<span class="line"><span>};</span></span></code></pre>
<p>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.</p>
<p>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.</p>
<p>This def is live, so play with it:</p>
<p><em>An interactive demo goes here. <a href="https://zubin.dev/blog/web-game-soundscapes-2-howler/">Open the post</a> to try it.</em></p>
<h2 id="the-adapter">The adapter</h2>
<p>The engine never calls Howler. It calls a bus adapter, and that’s what makes it pluggable.</p>
<pre class="astro-code astro-code-themes zubin-light zubin-dark has-copy-btn" tabindex="0" data-language="ts"><code><span class="line"><span>export</span><span> interface</span><span> BusAdapter</span><span> {</span></span>
<span class="line"><span>  playBed</span><span>(</span><span>name</span><span>:</span><span> string</span><span>,</span><span> opts</span><span>:</span><span> {</span><span> volume</span><span>:</span><span> number</span><span>;</span><span> fadeInMs</span><span>?:</span><span> number</span><span> }):</span><span> number</span><span> |</span><span> null</span><span>;</span></span>
<span class="line"></span>
<span class="line"><span>  playEmitter</span><span>(</span></span>
<span class="line"><span>    name</span><span>:</span><span> string</span><span>,</span></span>
<span class="line"><span>    opts</span><span>:</span><span> {</span></span>
<span class="line"><span>      volume</span><span>:</span><span> number</span><span>;</span></span>
<span class="line"><span>      pan</span><span>:</span><span> number</span><span>;</span></span>
<span class="line"><span>      rate</span><span>:</span><span> number</span><span>;</span></span>
<span class="line"><span>      onEnd</span><span>?:</span><span> ()</span><span> =&gt;</span><span> void</span><span>;</span></span>
<span class="line"><span>    },</span></span>
<span class="line"><span>  ):</span><span> number</span><span> |</span><span> null</span><span>;</span></span>
<span class="line"></span>
<span class="line"><span>  stopBed</span><span>(</span><span>id</span><span>:</span><span> number</span><span>,</span><span> fadeOutMs</span><span>?:</span><span> number</span><span>):</span><span> void</span><span>;</span></span>
<span class="line"><span>  stopAll</span><span>():</span><span> void</span><span>;</span></span>
<span class="line"></span>
<span class="line"><span>  duck</span><span>():</span><span> void</span><span>;</span></span>
<span class="line"><span>  unduck</span><span>():</span><span> void</span><span>;</span></span>
<span class="line"><span>}</span></span></code></pre>
<p>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.</p>
<p>Want a single ambience bus with a global duck multiplier? Implement it that way. Want a full master, music, ambience and SFX mixer? Implement <code>duck()</code> as a category gain change. The engine doesn’t care either way.</p>
<figure class="sketch not-prose"><div class="sketch-frame"><p><em>Figure: Layers. At the top, four hosts (React, r3f, Phaser, three.js) each point down at the Soundscape engine, which also takes a def of a bed and emitters. A dashed line below the engine marks the BusAdapter, six functions. Below it sit three adapters the engine can run on unchanged: a single-bus Howler adapter with one duck, a Web Audio adapter used by this page's demos, and a multi-bus mixer with music, ambience and sfx buses. <a href="https://zubin.dev/blog/web-game-soundscapes-2-howler/">See the drawing in the post.</a></em></p></div><figcaption>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.</figcaption></figure>
<p>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.</p>
<h2 id="a-minimal-howler-adapter">A minimal Howler adapter</h2>
<p>The simplest adapter is one ambience gain, a duck multiplier, and a map of preloaded Howls keyed by name.</p>
<pre class="astro-code astro-code-themes zubin-light zubin-dark has-copy-btn" tabindex="0" data-language="ts"><code><span class="line"><span>import</span><span> {</span><span> Howl </span><span>}</span><span> from</span><span> &quot;howler&quot;</span><span>;</span></span>
<span class="line"></span>
<span class="line"><span>const</span><span> AMBIENCE_BASE_VOLUME</span><span> =</span><span> 1</span><span>.</span><span>0</span><span>;</span></span>
<span class="line"><span>const</span><span> DUCK_MULTIPLIER</span><span> =</span><span> 0</span><span>.</span><span>25</span><span>;</span></span>
<span class="line"><span>const</span><span> DUCK_FADE_MS</span><span> =</span><span> 300</span><span>;</span></span>
<span class="line"></span>
<span class="line"><span>export</span><span> function</span><span> createSingleBusAdapter</span><span>(</span><span>samples</span><span>:</span><span> Record</span><span>&lt;</span><span>string</span><span>,</span><span> string</span><span>&gt;):</span><span> BusAdapter</span><span> {</span></span>
<span class="line"><span>  const</span><span> howls</span><span> =</span><span> new</span><span> Map</span><span>&lt;</span><span>string</span><span>,</span><span> Howl</span><span>&gt;();</span></span>
<span class="line"><span>  for</span><span> (</span><span>const</span><span> [</span><span>name</span><span>,</span><span> src</span><span>]</span><span> of</span><span> Object</span><span>.</span><span>entries</span><span>(</span><span>samples</span><span>))</span><span> {</span></span>
<span class="line"><span>    howls</span><span>.</span><span>set</span><span>(</span><span>name</span><span>,</span><span> new</span><span> Howl</span><span>({</span><span> src</span><span>:</span><span> [</span><span>src</span><span>],</span><span> preload</span><span>:</span><span> true</span><span> }));</span></span>
<span class="line"><span>  }</span></span>
<span class="line"></span>
<span class="line"><span>  let</span><span> ambienceMultiplier </span><span>=</span><span> 1</span><span>.</span><span>0</span><span>;</span></span>
<span class="line"><span>  const</span><span> liveBeds</span><span> =</span><span> new</span><span> Set</span><span>&lt;{</span><span> howl</span><span>:</span><span> Howl</span><span>;</span><span> id</span><span>:</span><span> number</span><span>;</span><span> baseVolume</span><span>:</span><span> number</span><span> }&gt;();</span></span>
<span class="line"><span>  const</span><span> liveEmitters</span><span> =</span><span> new</span><span> Set</span><span>&lt;{</span><span> howl</span><span>:</span><span> Howl</span><span>;</span><span> id</span><span>:</span><span> number</span><span> }&gt;();</span></span>
<span class="line"></span>
<span class="line"><span>  function</span><span> effectiveVolume</span><span>(</span><span>base</span><span>:</span><span> number</span><span>)</span><span> {</span></span>
<span class="line"><span>    return</span><span> base </span><span>*</span><span> AMBIENCE_BASE_VOLUME</span><span> *</span><span> ambienceMultiplier</span><span>;</span></span>
<span class="line"><span>  }</span></span>
<span class="line"></span>
<span class="line"><span>  return</span><span> {</span></span>
<span class="line"><span>    playBed</span><span>(</span><span>name</span><span>,</span><span> {</span><span> volume</span><span>,</span><span> fadeInMs</span><span> })</span><span> {</span></span>
<span class="line"><span>      const</span><span> howl</span><span> =</span><span> howls</span><span>.</span><span>get</span><span>(</span><span>name</span><span>);</span></span>
<span class="line"><span>      if</span><span> (</span><span>!</span><span>howl</span><span>)</span><span> return</span><span> null</span><span>;</span></span>
<span class="line"><span>      const</span><span> id</span><span> =</span><span> howl</span><span>.</span><span>play</span><span>();</span></span>
<span class="line"><span>      howl</span><span>.</span><span>loop</span><span>(</span><span>true</span><span>,</span><span> id</span><span>);</span></span>
<span class="line"><span>      const</span><span> target</span><span> =</span><span> effectiveVolume</span><span>(</span><span>volume</span><span>);</span></span>
<span class="line"><span>      if</span><span> (</span><span>fadeInMs</span><span>)</span><span> {</span></span>
<span class="line"><span>        howl</span><span>.</span><span>volume</span><span>(</span><span>0</span><span>,</span><span> id</span><span>);</span></span>
<span class="line"><span>        howl</span><span>.</span><span>fade</span><span>(</span><span>0</span><span>,</span><span> target</span><span>,</span><span> fadeInMs</span><span>,</span><span> id</span><span>);</span></span>
<span class="line"><span>      }</span><span> else</span><span> {</span></span>
<span class="line"><span>        howl</span><span>.</span><span>volume</span><span>(</span><span>target</span><span>,</span><span> id</span><span>);</span></span>
<span class="line"><span>      }</span></span>
<span class="line"><span>      liveBeds</span><span>.</span><span>add</span><span>({</span><span> howl</span><span>,</span><span> id</span><span>,</span><span> baseVolume</span><span>:</span><span> volume </span><span>});</span></span>
<span class="line"><span>      return</span><span> id</span><span>;</span></span>
<span class="line"><span>    },</span></span>
<span class="line"></span>
<span class="line"><span>    playEmitter</span><span>(</span><span>name</span><span>,</span><span> {</span><span> volume</span><span>,</span><span> pan</span><span>,</span><span> rate</span><span>,</span><span> onEnd</span><span> })</span><span> {</span></span>
<span class="line"><span>      const</span><span> howl</span><span> =</span><span> howls</span><span>.</span><span>get</span><span>(</span><span>name</span><span>);</span></span>
<span class="line"><span>      if</span><span> (</span><span>!</span><span>howl</span><span>)</span><span> return</span><span> null</span><span>;</span></span>
<span class="line"><span>      const</span><span> id</span><span> =</span><span> howl</span><span>.</span><span>play</span><span>();</span></span>
<span class="line"><span>      howl</span><span>.</span><span>volume</span><span>(</span><span>effectiveVolume</span><span>(</span><span>volume</span><span>),</span><span> id</span><span>);</span></span>
<span class="line"><span>      howl</span><span>.</span><span>stereo</span><span>(</span><span>pan</span><span>,</span><span> id</span><span>);</span></span>
<span class="line"><span>      howl</span><span>.</span><span>rate</span><span>(</span><span>rate</span><span>,</span><span> id</span><span>);</span></span>
<span class="line"><span>      const</span><span> entry</span><span> =</span><span> {</span><span> howl</span><span>,</span><span> id </span><span>};</span></span>
<span class="line"><span>      liveEmitters</span><span>.</span><span>add</span><span>(</span><span>entry</span><span>);</span></span>
<span class="line"><span>      howl</span><span>.</span><span>once</span><span>(</span></span>
<span class="line"><span>        &quot;end&quot;</span><span>,</span></span>
<span class="line"><span>        ()</span><span> =&gt;</span><span> {</span></span>
<span class="line"><span>          liveEmitters</span><span>.</span><span>delete</span><span>(</span><span>entry</span><span>);</span></span>
<span class="line"><span>          onEnd</span><span>?.();</span></span>
<span class="line"><span>        },</span></span>
<span class="line"><span>        id</span><span>,</span></span>
<span class="line"><span>      );</span></span>
<span class="line"><span>      return</span><span> id</span><span>;</span></span>
<span class="line"><span>    },</span></span>
<span class="line"></span>
<span class="line"><span>    stopBed</span><span>(</span><span>id</span><span>,</span><span> fadeOutMs</span><span> =</span><span> 500</span><span>)</span><span> {</span></span>
<span class="line"><span>      for</span><span> (</span><span>const</span><span> entry</span><span> of</span><span> liveBeds</span><span>)</span><span> {</span></span>
<span class="line"><span>        if</span><span> (</span><span>entry</span><span>.</span><span>id </span><span>!==</span><span> id</span><span>)</span><span> continue</span><span>;</span></span>
<span class="line"><span>        entry</span><span>.</span><span>howl</span><span>.</span><span>once</span><span>(</span><span>&quot;fade&quot;</span><span>,</span><span> ()</span><span> =&gt;</span><span> entry</span><span>.</span><span>howl</span><span>.</span><span>stop</span><span>(</span><span>id</span><span>),</span><span> id</span><span>);</span></span>
<span class="line"><span>        entry</span><span>.</span><span>howl</span><span>.</span><span>fade</span><span>(</span><span>entry</span><span>.</span><span>howl</span><span>.</span><span>volume</span><span>(</span><span>id</span><span>)</span><span> as</span><span> number</span><span>,</span><span> 0</span><span>,</span><span> fadeOutMs</span><span>,</span><span> id</span><span>);</span></span>
<span class="line"><span>        liveBeds</span><span>.</span><span>delete</span><span>(</span><span>entry</span><span>);</span></span>
<span class="line"><span>        return</span><span>;</span></span>
<span class="line"><span>      }</span></span>
<span class="line"><span>    },</span></span>
<span class="line"></span>
<span class="line"><span>    stopAll</span><span>()</span><span> {</span></span>
<span class="line"><span>      for</span><span> (</span><span>const</span><span> {</span><span> howl</span><span>,</span><span> id</span><span> }</span><span> of</span><span> liveBeds</span><span>)</span><span> howl</span><span>.</span><span>stop</span><span>(</span><span>id</span><span>);</span></span>
<span class="line"><span>      for</span><span> (</span><span>const</span><span> {</span><span> howl</span><span>,</span><span> id</span><span> }</span><span> of</span><span> liveEmitters</span><span>)</span><span> howl</span><span>.</span><span>stop</span><span>(</span><span>id</span><span>);</span></span>
<span class="line"><span>      liveBeds</span><span>.</span><span>clear</span><span>();</span></span>
<span class="line"><span>      liveEmitters</span><span>.</span><span>clear</span><span>();</span></span>
<span class="line"><span>    },</span></span>
<span class="line"></span>
<span class="line"><span>    duck</span><span>()</span><span> {</span></span>
<span class="line"><span>      ambienceMultiplier </span><span>=</span><span> DUCK_MULTIPLIER</span><span>;</span></span>
<span class="line"><span>      for</span><span> (</span><span>const</span><span> {</span><span> howl</span><span>,</span><span> id</span><span>,</span><span> baseVolume</span><span> }</span><span> of</span><span> liveBeds</span><span>)</span><span> {</span></span>
<span class="line"><span>        howl</span><span>.</span><span>fade</span><span>(</span><span>howl</span><span>.</span><span>volume</span><span>(</span><span>id</span><span>)</span><span> as</span><span> number</span><span>,</span><span> effectiveVolume</span><span>(</span><span>baseVolume</span><span>),</span><span> DUCK_FADE_MS</span><span>,</span><span> id</span><span>);</span></span>
<span class="line"><span>      }</span></span>
<span class="line"><span>    },</span></span>
<span class="line"></span>
<span class="line"><span>    unduck</span><span>()</span><span> {</span></span>
<span class="line"><span>      ambienceMultiplier </span><span>=</span><span> 1</span><span>.</span><span>0</span><span>;</span></span>
<span class="line"><span>      for</span><span> (</span><span>const</span><span> {</span><span> howl</span><span>,</span><span> id</span><span>,</span><span> baseVolume</span><span> }</span><span> of</span><span> liveBeds</span><span>)</span><span> {</span></span>
<span class="line"><span>        howl</span><span>.</span><span>fade</span><span>(</span><span>howl</span><span>.</span><span>volume</span><span>(</span><span>id</span><span>)</span><span> as</span><span> number</span><span>,</span><span> effectiveVolume</span><span>(</span><span>baseVolume</span><span>),</span><span> DUCK_FADE_MS</span><span>,</span><span> id</span><span>);</span></span>
<span class="line"><span>      }</span></span>
<span class="line"><span>    },</span></span>
<span class="line"><span>  };</span></span>
<span class="line"><span>}</span></span></code></pre>
<p>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.</p>
<p>There’s one gap. <code>duck()</code> 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.</p>
<h2 id="the-soundscape-class">The Soundscape class</h2>
<p>Given a <code>SoundscapeDef</code> and a <code>BusAdapter</code>, 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.</p>
<pre class="astro-code astro-code-themes zubin-light zubin-dark has-copy-btn" tabindex="0" data-language="ts"><code><span class="line"><span>function</span><span> pickInRange</span><span>([</span><span>min</span><span>,</span><span> max</span><span>]:</span><span> Range</span><span>)</span><span> {</span></span>
<span class="line"><span>  return</span><span> min </span><span>+</span><span> Math</span><span>.</span><span>random</span><span>()</span><span> *</span><span> (</span><span>max </span><span>-</span><span> min</span><span>);</span></span>
<span class="line"><span>}</span></span>
<span class="line"></span>
<span class="line"><span>function</span><span> pickSampleExcluding</span><span>(</span><span>samples</span><span>:</span><span> string</span><span>[],</span><span> last</span><span>:</span><span> string</span><span> |</span><span> null</span><span>)</span><span> {</span></span>
<span class="line"><span>  if</span><span> (</span><span>samples</span><span>.</span><span>length </span><span>===</span><span> 1</span><span>)</span><span> return</span><span> samples</span><span>[</span><span>0</span><span>]</span><span>!</span><span>;</span></span>
<span class="line"><span>  let</span><span> next </span><span>=</span><span> samples</span><span>[</span><span>Math</span><span>.</span><span>floor</span><span>(</span><span>Math</span><span>.</span><span>random</span><span>()</span><span> *</span><span> samples</span><span>.</span><span>length</span><span>)]</span><span>!</span><span>;</span></span>
<span class="line"><span>  while</span><span> (</span><span>next </span><span>===</span><span> last</span><span>)</span><span> {</span></span>
<span class="line"><span>    next </span><span>=</span><span> samples</span><span>[</span><span>Math</span><span>.</span><span>floor</span><span>(</span><span>Math</span><span>.</span><span>random</span><span>()</span><span> *</span><span> samples</span><span>.</span><span>length</span><span>)]</span><span>!</span><span>;</span></span>
<span class="line"><span>  }</span></span>
<span class="line"><span>  return</span><span> next</span><span>;</span></span>
<span class="line"><span>}</span></span>
<span class="line"></span>
<span class="line"><span>export</span><span> class</span><span> Soundscape</span><span> {</span></span>
<span class="line"><span>  private</span><span> bedId</span><span>:</span><span> number</span><span> |</span><span> null</span><span> =</span><span> null</span><span>;</span></span>
<span class="line"><span>  private</span><span> timers</span><span> =</span><span> new</span><span> Set</span><span>&lt;</span><span>ReturnType</span><span>&lt;</span><span>typeof</span><span> setTimeout</span><span>&gt;&gt;();</span></span>
<span class="line"><span>  private</span><span> liveEmitters</span><span> =</span><span> 0</span><span>;</span></span>
<span class="line"><span>  private</span><span> lastPicked</span><span> =</span><span> new</span><span> Map</span><span>&lt;</span><span>EmitterDef</span><span>,</span><span> string</span><span>&gt;();</span></span>
<span class="line"><span>  private</span><span> disposed</span><span> =</span><span> false</span><span>;</span></span>
<span class="line"></span>
<span class="line"><span>  constructor</span><span>(</span></span>
<span class="line"><span>    private</span><span> readonly</span><span> def</span><span>:</span><span> SoundscapeDef</span><span>,</span></span>
<span class="line"><span>    private</span><span> readonly</span><span> bus</span><span>:</span><span> BusAdapter</span><span>,</span></span>
<span class="line"><span>  )</span><span> {}</span></span>
<span class="line"></span>
<span class="line"><span>  start</span><span>()</span><span> {</span></span>
<span class="line"><span>    if</span><span> (</span><span>this</span><span>.</span><span>disposed</span><span>)</span><span> return</span><span>;</span></span>
<span class="line"><span>    this</span><span>.</span><span>bedId </span><span>=</span><span> this</span><span>.</span><span>bus</span><span>.</span><span>playBed</span><span>(</span><span>this</span><span>.</span><span>def</span><span>.</span><span>bed</span><span>.</span><span>sample</span><span>,</span><span> {</span></span>
<span class="line"><span>      volume</span><span>:</span><span> this</span><span>.</span><span>def</span><span>.</span><span>bed</span><span>.</span><span>volume</span><span>,</span></span>
<span class="line"><span>      fadeInMs</span><span>:</span><span> this</span><span>.</span><span>def</span><span>.</span><span>bed</span><span>.</span><span>fadeInMs </span><span>??</span><span> 500</span><span>,</span></span>
<span class="line"><span>    });</span></span>
<span class="line"><span>    for</span><span> (</span><span>const</span><span> emitter</span><span> of</span><span> this</span><span>.</span><span>def</span><span>.</span><span>emitters</span><span>)</span><span> {</span></span>
<span class="line"><span>      this</span><span>.</span><span>scheduleNext</span><span>(</span><span>emitter</span><span>);</span></span>
<span class="line"><span>    }</span></span>
<span class="line"><span>  }</span></span>
<span class="line"></span>
<span class="line"><span>  private</span><span> scheduleNext</span><span>(</span><span>emitter</span><span>:</span><span> EmitterDef</span><span>)</span><span> {</span></span>
<span class="line"><span>    if</span><span> (</span><span>this</span><span>.</span><span>disposed</span><span>)</span><span> return</span><span>;</span></span>
<span class="line"><span>    const</span><span> delay</span><span> =</span><span> pickInRange</span><span>(</span><span>emitter</span><span>.</span><span>intervalMs</span><span>);</span></span>
<span class="line"><span>    const</span><span> timer</span><span> =</span><span> setTimeout</span><span>(()</span><span> =&gt;</span><span> {</span></span>
<span class="line"><span>      this</span><span>.</span><span>timers</span><span>.</span><span>delete</span><span>(</span><span>timer</span><span>);</span></span>
<span class="line"><span>      this</span><span>.</span><span>fireEmitter</span><span>(</span><span>emitter</span><span>);</span></span>
<span class="line"><span>    },</span><span> delay</span><span>);</span></span>
<span class="line"><span>    this</span><span>.</span><span>timers</span><span>.</span><span>add</span><span>(</span><span>timer</span><span>);</span></span>
<span class="line"><span>  }</span></span>
<span class="line"></span>
<span class="line"><span>  private</span><span> fireEmitter</span><span>(</span><span>emitter</span><span>:</span><span> EmitterDef</span><span>)</span><span> {</span></span>
<span class="line"><span>    if</span><span> (</span><span>this</span><span>.</span><span>disposed</span><span>)</span><span> return</span><span>;</span></span>
<span class="line"><span>    const</span><span> cap</span><span> =</span><span> this</span><span>.</span><span>def</span><span>.</span><span>maxConcurrentEmitterInstances </span><span>??</span><span> 4</span><span>;</span></span>
<span class="line"><span>    if</span><span> (</span><span>this</span><span>.</span><span>liveEmitters </span><span>&gt;=</span><span> cap</span><span>)</span><span> {</span></span>
<span class="line"><span>      this</span><span>.</span><span>scheduleNext</span><span>(</span><span>emitter</span><span>);</span></span>
<span class="line"><span>      return</span><span>;</span></span>
<span class="line"><span>    }</span></span>
<span class="line"><span>    const</span><span> last</span><span> =</span><span> this</span><span>.</span><span>lastPicked</span><span>.</span><span>get</span><span>(</span><span>emitter</span><span>)</span><span> ??</span><span> null</span><span>;</span></span>
<span class="line"><span>    const</span><span> sample</span><span> =</span><span> pickSampleExcluding</span><span>(</span><span>emitter</span><span>.</span><span>samples</span><span>,</span><span> last</span><span>);</span></span>
<span class="line"><span>    this</span><span>.</span><span>lastPicked</span><span>.</span><span>set</span><span>(</span><span>emitter</span><span>,</span><span> sample</span><span>);</span></span>
<span class="line"></span>
<span class="line"><span>    this</span><span>.</span><span>liveEmitters</span><span>++;</span></span>
<span class="line"><span>    this</span><span>.</span><span>bus</span><span>.</span><span>playEmitter</span><span>(</span><span>sample</span><span>,</span><span> {</span></span>
<span class="line"><span>      volume</span><span>:</span><span> pickInRange</span><span>(</span><span>emitter</span><span>.</span><span>volume</span><span>),</span></span>
<span class="line"><span>      pan</span><span>:</span><span> pickInRange</span><span>(</span><span>emitter</span><span>.</span><span>pan</span><span>),</span></span>
<span class="line"><span>      rate</span><span>:</span><span> pickInRange</span><span>(</span><span>emitter</span><span>.</span><span>rate</span><span>),</span></span>
<span class="line"><span>      onEnd</span><span>:</span><span> ()</span><span> =&gt;</span><span> {</span></span>
<span class="line"><span>        this</span><span>.</span><span>liveEmitters </span><span>=</span><span> Math</span><span>.</span><span>max</span><span>(</span><span>0</span><span>,</span><span> this</span><span>.</span><span>liveEmitters </span><span>-</span><span> 1</span><span>);</span></span>
<span class="line"><span>      },</span></span>
<span class="line"><span>    });</span></span>
<span class="line"><span>    this</span><span>.</span><span>scheduleNext</span><span>(</span><span>emitter</span><span>);</span></span>
<span class="line"><span>  }</span></span>
<span class="line"></span>
<span class="line"><span>  dispose</span><span>()</span><span> {</span></span>
<span class="line"><span>    if</span><span> (</span><span>this</span><span>.</span><span>disposed</span><span>)</span><span> return</span><span>;</span></span>
<span class="line"><span>    this</span><span>.</span><span>disposed </span><span>=</span><span> true</span><span>;</span></span>
<span class="line"><span>    for</span><span> (</span><span>const</span><span> t</span><span> of</span><span> this</span><span>.</span><span>timers</span><span>)</span><span> clearTimeout</span><span>(</span><span>t</span><span>);</span></span>
<span class="line"><span>    this</span><span>.</span><span>timers</span><span>.</span><span>clear</span><span>();</span></span>
<span class="line"><span>    // stopBed first: the adapter unregisters the bed and starts its fade.</span></span>
<span class="line"><span>    // stopAll is then the hard kill for whatever is still live (the emitters).</span></span>
<span class="line"><span>    if</span><span> (</span><span>this</span><span>.</span><span>bedId </span><span>!==</span><span> null</span><span>)</span><span> this</span><span>.</span><span>bus</span><span>.</span><span>stopBed</span><span>(</span><span>this</span><span>.</span><span>bedId</span><span>,</span><span> 500</span><span>);</span></span>
<span class="line"><span>    this</span><span>.</span><span>bus</span><span>.</span><span>stopAll</span><span>();</span></span>
<span class="line"><span>  }</span></span>
<span class="line"><span>}</span></span></code></pre>
<p>That’s the whole engine.</p>
<p>The only nontrivial bit is the <code>scheduleNext → fireEmitter → scheduleNext</code> recursion. Each emitter is a metronome that re-arms itself: fire, pick a new random interval, set a fresh <code>setTimeout</code>, and go again.</p>
<figure class="sketch not-prose"><div class="sketch-frame"><p><em>Figure: A loop. scheduleNext sets a setTimeout for a random interval; when it fires, fireEmitter runs. Under the concurrency cap it calls playEmitter, then re-arms by calling scheduleNext again. At the cap it skips this beat and re-arms straight away. Once the engine is disposed, fireEmitter returns without doing anything. <a href="https://zubin.dev/blog/web-game-soundscapes-2-howler/">See the drawing in the post.</a></em></p></div><figcaption>Each emitter re-arms itself. The disposed check is what turns a late timer from the old scene into nothing.</figcaption></figure>
<p>The <code>disposed</code> flag is the kill switch. Without it, a late timer from the old scene calls <code>bus.playEmitter</code> on a bus that’s already moved on, and you get a ghost bird two seconds into the next level.</p>
<h2 id="why-a-flag-instead-of-just-clearing-the-timers">Why a flag instead of just clearing the timers?</h2>
<p>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 <code>clearTimeout</code> runs, the callback is already queued. The flag is what keeps <code>fireEmitter</code> from doing anything irreversible after the host has decided the instance is dead.</p>
<p>One ordering note in <code>dispose</code>: <code>stopBed</code> goes first so the bed gets its fade-out, and <code>stopAll</code> then hard-stops whatever is still registered, which by that point is just emitters.</p>
<p>That only works because the adapter’s <code>stopBed</code> 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 <code>stopAll</code> flattens every sound unconditionally, the fade dies with it, so treat “stopBed unregisters synchronously” as part of the contract.</p>
<h2 id="wiring-it-in">Wiring it in</h2>
<p>The engine is the same in every host; only the lifecycle around it changes.</p>
<p><strong>React.</strong> For DOM-hosted Phaser, or plain React UI:</p>
<pre class="astro-code astro-code-themes zubin-light zubin-dark has-copy-btn" tabindex="0" data-language="tsx"><code><span class="line"><span>import</span><span> {</span><span> useEffect </span><span>}</span><span> from</span><span> &quot;react&quot;</span><span>;</span></span>
<span class="line"><span>import</span><span> {</span><span> Soundscape</span><span>,</span><span> type</span><span> SoundscapeDef </span><span>}</span><span> from</span><span> &quot;./Soundscape&quot;</span><span>;</span></span>
<span class="line"><span>import</span><span> {</span><span> bus </span><span>}</span><span> from</span><span> &quot;./bus&quot;</span><span>;</span></span>
<span class="line"></span>
<span class="line"><span>export</span><span> function</span><span> SoundscapeRunner</span><span>({</span><span> sceneId</span><span>,</span><span> def</span><span> }:</span><span> {</span><span> sceneId</span><span>:</span><span> string</span><span>;</span><span> def</span><span>:</span><span> SoundscapeDef</span><span> })</span><span> {</span></span>
<span class="line"><span>  useEffect</span><span>(()</span><span> =&gt;</span><span> {</span></span>
<span class="line"><span>    const</span><span> scape</span><span> =</span><span> new</span><span> Soundscape</span><span>(</span><span>def</span><span>,</span><span> bus</span><span>);</span></span>
<span class="line"><span>    scape</span><span>.</span><span>start</span><span>();</span></span>
<span class="line"><span>    return</span><span> ()</span><span> =&gt;</span><span> scape</span><span>.</span><span>dispose</span><span>();</span></span>
<span class="line"><span>  },</span><span> [</span><span>sceneId</span><span>,</span><span> def</span><span>]);</span></span>
<span class="line"></span>
<span class="line"><span>  return</span><span> null</span><span>;</span></span>
<span class="line"><span>}</span></span></code></pre>
<p>Render it as a sibling to your <code>&lt;Canvas&gt;</code> or Phaser mount. The render tree doesn’t contain audio nodes; the audio engine is a side effect keyed on <code>sceneId</code>.</p>
<p>There’s one footgun: <code>def</code> 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 <code>CAFE</code> above, or memoize them.</p>
<p><strong>r3f.</strong> It’s exactly the same <code>&lt;SoundscapeRunner&gt;</code>; just put it outside <code>&lt;Canvas&gt;</code>.</p>
<pre class="astro-code astro-code-themes zubin-light zubin-dark has-copy-btn" tabindex="0" data-language="tsx"><code><span class="line"><span>&lt;&gt;</span></span>
<span class="line"><span>  &lt;</span><span>SoundscapeRunner</span><span> sceneId</span><span>={</span><span>current</span><span>}</span><span> def</span><span>={</span><span>SCENES</span><span>[</span><span>current</span><span>]}</span><span> /&gt;</span></span>
<span class="line"><span>  &lt;</span><span>Canvas</span><span>&gt;</span><span>{</span><span>/* your scene */</span><span>}</span><span>&lt;/</span><span>Canvas</span><span>&gt;</span></span>
<span class="line"><span>&lt;/&gt;</span></span></code></pre>
<p>The audio engine has no business inside the render loop, and r3f doesn’t need to know it exists.</p>
<p><strong>Phaser.</strong> There’s no React and no <code>useEffect</code> here, so bind to scene events instead.</p>
<pre class="astro-code astro-code-themes zubin-light zubin-dark has-copy-btn" tabindex="0" data-language="ts"><code><span class="line"><span>export</span><span> function</span><span> attachSoundscape</span><span>(</span><span>scene</span><span>:</span><span> Phaser</span><span>.</span><span>Scene</span><span>,</span><span> def</span><span>:</span><span> SoundscapeDef</span><span>)</span><span> {</span></span>
<span class="line"><span>  const</span><span> scape</span><span> =</span><span> new</span><span> Soundscape</span><span>(</span><span>def</span><span>,</span><span> bus</span><span>);</span></span>
<span class="line"><span>  scene</span><span>.</span><span>events</span><span>.</span><span>once</span><span>(</span><span>&quot;create&quot;</span><span>,</span><span> ()</span><span> =&gt;</span><span> scape</span><span>.</span><span>start</span><span>());</span></span>
<span class="line"><span>  scene</span><span>.</span><span>events</span><span>.</span><span>once</span><span>(</span><span>&quot;shutdown&quot;</span><span>,</span><span> ()</span><span> =&gt;</span><span> scape</span><span>.</span><span>dispose</span><span>());</span></span>
<span class="line"><span>  return</span><span> scape</span><span>;</span></span>
<span class="line"><span>}</span></span></code></pre>
<p>Call it in your scene’s <code>init()</code>, and that part matters. By the time <code>create()</code> runs, the <code>&quot;create&quot;</code> event has already fired, so that <code>once</code> never resolves and you wait forever for a soundscape that never starts.</p>
<p>If you attach late, call <code>scape.start()</code> directly instead of listening for the event. The shutdown hook does the job that <code>useEffect</code>’s cleanup did in the React case: same shape, different host.</p>
<p><strong>Imperative three.js.</strong> 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.</p>
<pre class="astro-code astro-code-themes zubin-light zubin-dark has-copy-btn" tabindex="0" data-language="ts"><code><span class="line"><span>class</span><span> World</span><span> {</span></span>
<span class="line"><span>  private</span><span> scape</span><span>:</span><span> Soundscape</span><span> |</span><span> null</span><span> =</span><span> null</span><span>;</span></span>
<span class="line"></span>
<span class="line"><span>  async</span><span> load</span><span>()</span><span> {</span></span>
<span class="line"><span>    await</span><span> this</span><span>.</span><span>loader</span><span>.</span><span>loadAll</span><span>();</span></span>
<span class="line"><span>    // user has clicked &quot;Start&quot; by the time this resolves</span></span>
<span class="line"><span>    this</span><span>.</span><span>scape </span><span>=</span><span> new</span><span> Soundscape</span><span>(</span><span>CAFE</span><span>,</span><span> bus</span><span>);</span></span>
<span class="line"><span>    this</span><span>.</span><span>scape</span><span>.</span><span>start</span><span>();</span></span>
<span class="line"><span>  }</span></span>
<span class="line"></span>
<span class="line"><span>  setSoundscape</span><span>(</span><span>def</span><span>:</span><span> SoundscapeDef</span><span>)</span><span> {</span></span>
<span class="line"><span>    this</span><span>.</span><span>scape</span><span>?.</span><span>dispose</span><span>();</span></span>
<span class="line"><span>    this</span><span>.</span><span>scape </span><span>=</span><span> new</span><span> Soundscape</span><span>(</span><span>def</span><span>,</span><span> bus</span><span>);</span></span>
<span class="line"><span>    this</span><span>.</span><span>scape</span><span>.</span><span>start</span><span>();</span></span>
<span class="line"><span>  }</span></span>
<span class="line"></span>
<span class="line"><span>  destroy</span><span>()</span><span> {</span></span>
<span class="line"><span>    this</span><span>.</span><span>scape</span><span>?.</span><span>dispose</span><span>();</span></span>
<span class="line"><span>    this</span><span>.</span><span>scape </span><span>=</span><span> null</span><span>;</span></span>
<span class="line"><span>  }</span></span>
<span class="line"><span>}</span></span></code></pre>
<p><code>setSoundscape</code> 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.</p>
<p>Across all four hosts, the only thing that changes is who calls <code>dispose</code>.</p>
<h2 id="single-bus-vs-multi-bus">Single-bus vs multi-bus</h2>



































<div class="table-wrap table-stack"><table><thead><tr><th>Concern</th><th>Single-bus adapter</th><th>Multi-bus mixer</th></tr></thead><tbody><tr><td data-label="Concern">Buses</td><td data-label="Single-bus adapter">One ambience bus</td><td data-label="Multi-bus mixer"><code>master / music / ambience / sfx</code></td></tr><tr><td data-label="Concern">Volume formula</td><td data-label="Single-bus adapter"><code>base × duckMultiplier</code></td><td data-label="Multi-bus mixer"><code>master × bus × baseline</code></td></tr><tr><td data-label="Concern">Duck implementation</td><td data-label="Single-bus adapter">Multiplier flips to <code>0.25</code></td><td data-label="Multi-bus mixer"><code>setBusVolume(&quot;ambience&quot;, 0.25, 500)</code></td></tr><tr><td data-label="Concern">Per-category mute</td><td data-label="Single-bus adapter">Not really</td><td data-label="Multi-bus mixer"><code>setBusVolume(&quot;sfx&quot;, 0, 0)</code> and you are done</td></tr><tr><td data-label="Concern">When it is the answer</td><td data-label="Single-bus adapter">Voice-over over ambience</td><td data-label="Multi-bus mixer">Music + SFX + ambience all need their own knobs</td></tr></tbody></table></div>
<p>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 <code>BusAdapter</code> contract, so the engine doesn’t change. Here’s the same def on the same engine, with only the adapter underneath swapped:</p>
<p><em>An interactive demo goes here. <a href="https://zubin.dev/blog/web-game-soundscapes-2-howler/">Open the post</a> to try it.</em></p>
<h2 id="howls-and-the-loading-bar">Howls and the loading bar</h2>
<p>This one tripped me up. In three.js you already have a <code>LoadingManager</code> 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:</p>
<pre class="astro-code astro-code-themes zubin-light zubin-dark has-copy-btn" tabindex="0" data-language="ts"><code><span class="line"><span>import</span><span> {</span><span> Howl </span><span>}</span><span> from</span><span> &quot;howler&quot;</span><span>;</span></span>
<span class="line"><span>import</span><span> type</span><span> {</span><span> LoadingManager </span><span>}</span><span> from</span><span> &quot;three&quot;</span><span>;</span></span>
<span class="line"></span>
<span class="line"><span>export</span><span> function</span><span> registerAudioWithManager</span><span>(</span><span>src</span><span>:</span><span> string</span><span>,</span><span> manager</span><span>:</span><span> LoadingManager</span><span>)</span><span> {</span></span>
<span class="line"><span>  manager</span><span>.</span><span>itemStart</span><span>(</span><span>src</span><span>);</span></span>
<span class="line"><span>  const</span><span> howl</span><span> =</span><span> new</span><span> Howl</span><span>({</span></span>
<span class="line"><span>    src</span><span>:</span><span> [</span><span>src</span><span>],</span></span>
<span class="line"><span>    preload</span><span>:</span><span> true</span><span>,</span></span>
<span class="line"><span>    onload</span><span>:</span><span> ()</span><span> =&gt;</span><span> manager</span><span>.</span><span>itemEnd</span><span>(</span><span>src</span><span>),</span></span>
<span class="line"><span>    onloaderror</span><span>:</span><span> (</span><span>_id</span><span>,</span><span> err</span><span>)</span><span> =&gt;</span><span> {</span></span>
<span class="line"><span>      console</span><span>.</span><span>warn</span><span>(</span><span>&quot;audio load failed&quot;</span><span>,</span><span> src</span><span>,</span><span> err</span><span>);</span></span>
<span class="line"><span>      manager</span><span>.</span><span>itemEnd</span><span>(</span><span>src</span><span>);</span></span>
<span class="line"><span>    },</span></span>
<span class="line"><span>  });</span></span>
<span class="line"><span>  return</span><span> howl</span><span>;</span></span>
<span class="line"><span>}</span></span></code></pre>
<p>Call this instead of <code>new Howl(...)</code> in your adapter’s preload step and the loading bar waits for audio. Phaser has the same hook through <code>scene.load.start()</code> and a custom file type, and the pattern is identical.</p>
<h2 id="whats-the-catch">What’s the catch?</h2>
<p>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.</p>
<p><code>setTimeout</code> 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.</p>
<p>The <code>BusAdapter</code> interface is small on purpose. If you want a seventh function, you probably want a different abstraction underneath, not a bigger one on top.</p>
<h2 id="what-will-bite-you">What will bite you?</h2>
<p>Every one of these has bitten me in production.</p>
<p><strong>Autoplay.</strong> The first sound must follow a user gesture, so gate <code>start()</code> behind a click. On mobile Safari, a programmatic <code>play()</code> before the first interaction fails silently and leaves the Howl half-broken.</p>
<p><strong>StrictMode in dev.</strong> <code>useEffect</code> runs twice, so <code>dispose()</code> must be reentrant; the <code>disposed</code> flag above is what makes the second teardown a no-op.</p>
<p><strong>Timer leaks on scene change.</strong> Every <code>setTimeout</code> id has to be tracked and cleared, and the check at the top of <code>scheduleNext</code> and <code>fireEmitter</code> is what makes a late timer harmless if one slips past <code>dispose</code>.</p>
<p><strong>Fade, then stop.</strong> <code>howl.fade()</code> fires a <code>&quot;fade&quot;</code> event when it finishes. Chain <code>stop()</code> in that callback, as <code>stopBed</code> does, or Howler keeps a zero-volume instance alive forever.</p>
<p><strong>Missing samples shouldn’t throw.</strong> Lazy registration, MDX-driven content and hot reload will all ship a name without a sample sooner or later. Log it, return <code>null</code> and keep going.</p>
<h2 id="the-engine-does-not-change">The engine does not change</h2>
<p>I’d start with the single-bus adapter hosted from <code>useEffect</code>, and bring in the mixer the day a designer asks to move the music without touching the SFX.</p>]]></content:encoded><dc:creator>Zubin Khavarian</dc:creator><atom:updated>2026-10-19T23:00:00.000Z</atom:updated><category>audio</category><category>game-dev</category></item><item><title>Soundscapes for Web Games: The Layer Beneath Your SFX, Part 1</title><link>https://zubin.dev/blog/web-game-soundscapes-1-concepts/</link><guid isPermaLink="true">https://zubin.dev/blog/web-game-soundscapes-1-concepts/</guid><description>Most web games go silent the moment the player stops moving. A soundscape is the quiet layer underneath your SFX that keeps the room alive.</description><pubDate>Mon, 20 Apr 2026 17:00:00 GMT</pubDate><content:encoded><![CDATA[<p>For years I shipped web games that went dead the moment you stood still. The samples were fine and the music was fine. Something was still missing, and it took me an embarrassingly long time to hear what it was.</p>
<p>You’ve heard it on a video call. The other person is talking and the line sounds normal. Then they mute, and their whole room disappears with them: no air conditioning, no chair creak, no car going past the window. The line isn’t quiet. It’s empty.</p>
<p>Most web games mute the room on purpose. They play a footstep, a click and a music loop, and they leave the space between those events silent. The fix is to leave the room on, and I call that layer a <strong>soundscape</strong>. Flip between the two versions below and you’ll hear the difference straight away.</p>
<p><em>An interactive demo goes here. <a href="https://zubin.dev/blog/web-game-soundscapes-1-concepts/">Open the post</a> to try it.</em></p>
<h2 id="what-is-a-soundscape">What is a soundscape?</h2>
<p>It’s the third kind of game audio, and it isn’t music or SFX. If you mix all three on one bus, the game ends up sounding like a YouTube video with the ads left in.</p>
<figure class="sketch not-prose"><div class="sketch-frame"><p><em>Figure: Three lanes feeding a three-fader mixer. Music is a smooth composed loop. SFX is silence with short bursts when something happens. The soundscape is a quiet bed that never stops, with emitters firing on a timer. Each lane has its own fader, and the mixer feeds master. <a href="https://zubin.dev/blog/web-game-soundscapes-1-concepts/">See the drawing in the post.</a></em></p></div><figcaption>Three layers, three buses. The soundscape is the only one that is never silent.</figcaption></figure>





























<div class="table-wrap table-stack"><table><thead><tr><th>Layer</th><th>What it is</th><th>Triggered by</th><th>Wants</th></tr></thead><tbody><tr><td data-label="Layer">Music</td><td data-label="What it is">A composed, looping track</td><td data-label="Triggered by">Scene or beat</td><td data-label="Wants">Its own bus, fade in/out, ducks rarely</td></tr><tr><td data-label="Layer">SFX</td><td data-label="What it is">One-shot reactions to gameplay</td><td data-label="Triggered by">Player or game</td><td data-label="Wants">Tight latency, no fades</td></tr><tr><td data-label="Layer">Soundscape</td><td data-label="What it is">Ambient bed plus randomized non-musical emitters</td><td data-label="Triggered by">A timer, not you</td><td data-label="Wants">A bus that ducks under dialog</td></tr></tbody></table></div>
<p>A soundscape has two parts. The <strong>bed</strong> is a looping ambience: room tone, wind, distant traffic. The <strong>emitters</strong> are short, non-musical samples that fire on a timer with a little randomness in their pan, volume and pitch. A floorboard creaks, a bird calls once, a door closes somewhere down the hall.</p>
<p>Together they fill the gaps between your SFX, so the room already sounds alive before the player has done anything.</p>
<h2 id="why-is-this-worse-on-the-web">Why is this worse on the web?</h2>
<p>None of this is new. Native engines have shipped it for years. Unity gives you <code>AudioMixerGroup</code>, Unreal gives you Submixes and Sound Cues, and you wire up bus routing and priorities in an editor once and then forget about it.</p>
<p>The web gives you an <a href="https://developer.mozilla.org/en-US/docs/Web/API/AudioContext" rel="noopener noreferrer" target="_blank"><code>AudioContext</code><span class="ext-link"><span class="ext-link-sr"> (opens in a new tab)</span></span></a> and a <a href="https://developer.mozilla.org/en-US/docs/Web/API/GainNode" rel="noopener noreferrer" target="_blank"><code>GainNode</code><span class="ext-link"><span class="ext-link-sr"> (opens in a new tab)</span></span></a>, and that’s your entire mixer.</p>
<p>The libraries don’t fill the gap either. Phaser ships <a href="https://docs.phaser.io/api-documentation/class/sound-webaudiosound" rel="noopener noreferrer" target="_blank"><code>WebAudioSound</code><span class="ext-link"><span class="ext-link-sr"> (opens in a new tab)</span></span></a>, three.js ships <a href="https://threejs.org/docs/#api/en/audio/PositionalAudio" rel="noopener noreferrer" target="_blank"><code>PositionalAudio</code><span class="ext-link"><span class="ext-link-sr"> (opens in a new tab)</span></span></a>, and drei wraps that as <a href="https://drei.docs.pmnd.rs/abstractions/positional-audio" rel="noopener noreferrer" target="_blank"><code>&lt;PositionalAudio&gt;</code><span class="ext-link"><span class="ext-link-sr"> (opens in a new tab)</span></span></a> for r3f. None of them give you scheduled ambience, or a bus that can duck a whole category of sounds while a voice-over plays.</p>
<p>So most web games never get past music plus SFX. The work isn’t hard. Nothing in the tooling nudges you toward it, so it quietly never happens.</p>
<h2 id="isnt-ambience-just-a-quiet-music-loop">Isn’t ambience just a quiet music loop?</h2>
<p>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.</p>
<p>The trouble is that the ear clocks a static loop fast. Within about three repeats your brain has found the seam, and once it has, it can’t stop hearing it. A single unchanging loop ends up with the mood of a fridge hum: the room is on, but nobody’s home.</p>
<h2 id="but-isnt-a-quiet-loop-better-than-nothing">But isn’t a quiet loop better than nothing?</h2>
<p>Yes, it is. A bed on its own beats silence. It’s the baseline, and emitters are what you put on top of it.</p>
<p>Here’s what thirty seconds of the café demo actually does. The bed runs the whole time. Every few seconds an emitter picks a sample, a volume and a pan, and fires once.</p>
<figure class="sketch not-prose"><div class="sketch-frame"><p><em>Figure: Thirty seconds of a café soundscape. A quiet bed runs the whole time. On top, emitters fire at irregular times: cups at 2 seconds, espresso at 7.5, register at 11, a quiet phone at 14, cups at 17, espresso at 23.5, cups at 27. Each stem's height is that fire's random volume. The gap between 17 and 23.5 seconds is marked as a random 3 to 8 seconds. <a href="https://zubin.dev/blog/web-game-soundscapes-1-concepts/">See the drawing in the post.</a></em></p></div><figcaption>The café demo, thirty seconds of it. Stem height is each fire&#39;s random volume; no sample plays twice in a row.</figcaption></figure>
<p>A short crossfade at the loop point hides the restart, and the emitters give the ear something more interesting to follow than the seam. The demo below has both. The red flash marks the exact moment the loop wraps, so try to hear it with the emitters off and then on.</p>
<p><em>An interactive demo goes here. <a href="https://zubin.dev/blog/web-game-soundscapes-1-concepts/">Open the post</a> to try it.</em></p>
<p>Stand still in a real room for a minute and just listen. What you hear isn’t one texture. A fridge compressor kicks in, a neighbour’s door closes, the floor settles, a gust hits the window. None of those are looping. They’re events on top of a near-silent bed, and a soundscape system is exactly that, on a timer.</p>
<h2 id="the-knobs">The knobs</h2>
<p>There are three, and skipping any one of them turns the layer back into a tape loop.</p>
<figure class="sketch not-prose"><div class="sketch-frame"><p><em>Figure: Three panels. Sample pool: cups, register and espresso, with espresso crossed out because it just played. Tight ranges: pan within plus or minus 0.4, volume 0.7 to 1.0, pitch within plus or minus 5 percent, each with one picked value. Concurrency cap: three slots are busy, so a fourth fire is blocked and dropped. <a href="https://zubin.dev/blog/web-game-soundscapes-1-concepts/">See the drawing in the post.</a></em></p></div><figcaption>The three knobs. Skip any one and the layer collapses back into a tape loop.</figcaption></figure>
<p><strong>A sample pool per emitter, with last-picked exclusion.</strong> Each emitter holds three to six short samples. Every fire picks one at random, but never the one it just played. The ear catches an immediate repeat faster than anything else, which makes this the biggest win for the least work.</p>
<p><strong>Random pan, volume and pitch, within tight ranges.</strong> Pan ±0.4, volume 0.7 to 1.0, playback rate 0.95 to 1.05. That’s tight enough that nothing sounds broken and wide enough that no two fires are identical. Pitch is the easy one to overdo: past about ±5%, samples start sounding chipmunky or sluggish. Ask me how I know.</p>
<p><strong>A concurrency cap.</strong> <code>maxConcurrentEmitterInstances</code> stops pile-ups. Every so often the dice will fire three emitters within 200ms, and without a cap that moment craters the mix and clips the master. Cap it at three or four. It’ll kick in maybe once a minute, and you won’t hear it when it does.</p>
<p>Everything else is plumbing. Play with all three in the playground below.</p>
<p><em>An interactive demo goes here. <a href="https://zubin.dev/blog/web-game-soundscapes-1-concepts/">Open the post</a> to try it.</em></p>
<h2 id="same-engine-different-emphasis">Same engine, different emphasis</h2>
<p>The same three knobs show up whatever you’re building. What changes is which one you lean on.</p>
<p><strong>2D Phaser games.</strong> Tie the bed to the scene and schedule emitters from the scene’s update loop or from timers. The listener rarely moves, so pan is decoration more than space. Three or four samples per emitter with last-picked exclusion is plenty.</p>
<p><strong>3D three.js or r3f games.</strong> The bed stays 2D, because room tone doesn’t come from anywhere; it’s everywhere. Emitters can stay 2D and randomized, or become real positional sources with <a href="https://threejs.org/docs/#api/en/audio/PositionalAudio" rel="noopener noreferrer" target="_blank"><code>PositionalAudio</code><span class="ext-link"><span class="ext-link-sr"> (opens in a new tab)</span></span></a> when they should pan as the camera turns. Ducking matters more here, because dialog gets buried quickly when music, footsteps and UI are all competing for the same space.</p>
<p><strong>Narrative scenes.</strong> This is where ducking pays off most. Without it, the voice-over fights the ambience and you end up cranking the VO bus until the room is a whisper. Instead, duck the ambience bus over about 120ms when a line starts and release it over about 450ms when the line ends. The voice sits on top, and the room comes back when it’s done.</p>
<figure class="sketch not-prose"><div class="sketch-frame"><p><em>Figure: Two tracks over time. The voice-over line starts and ends at two dashed markers. The ambience bus drops to minus 12 dB within 120 milliseconds of the line starting, holds there under the voice, then comes back to full over 450 milliseconds after the line ends. <a href="https://zubin.dev/blog/web-game-soundscapes-1-concepts/">See the drawing in the post.</a></em></p></div><figcaption>Drawn to scale: duck in 120 ms, hold at −12 dB under the line, release over 450 ms.</figcaption></figure>
<p><em>An interactive demo goes here. <a href="https://zubin.dev/blog/web-game-soundscapes-1-concepts/">Open the post</a> to try it.</em></p>
<h2 id="whats-the-catch">What’s the catch?</h2>
<p>It’s more authoring. Three to six samples per emitter is real work compared to one, and you’ll feel that budget the first time a designer asks for “one more bird.”</p>
<p>The concurrency cap means an emitter you wanted to hear will occasionally get dropped. That’s almost always fine, but once in a while the dropped fire was the one sound that sold the room.</p>
<p>Ducking adds buses, and bugs like to hide in bus routing. If you have one bus today, you’ll have four tomorrow.</p>
<p>And the whole layer is invisible when it works. Nobody on the team will notice it, which is the point, but it makes for a tough sprint review when your demo is “the room, except now it sounds like a room.”</p>
<h2 id="what-should-you-not-do">What should you not do?</h2>
<p>I’ve done every one of these at least once.</p>
<p><strong>Start the bed at full volume with no fade-in.</strong> On first load the player hears a hard cut into ambience. A 500ms fade-in costs nothing and removes it.</p>
<p><strong>Use hardcoded <code>&lt;audio&gt;</code> tags.</strong> They give you no bus routing, no ducking and no per-instance volume, and you can’t duck what you can’t address. Use Web Audio, or a wrapper like Howler, for anything that has to share the stage with other sounds.</p>
<p><strong>Create a new Howl for every emitter fire.</strong> That leaks memory and re-decodes the sample each time. Construct each Howl once at load, then call <code>play()</code> per fire.</p>
<p><strong>Duck by pausing the bed.</strong> Pausing loses the loop’s position and you get a click on resume. Lower the bus gain instead.</p>
<p><strong>Forget to clear timers on scene change.</strong> A <code>setTimeout</code> armed in scene A will happily fire an emitter in scene B. Track every timer id, clear them all on dispose, set a <code>disposed</code> flag, and check that flag before re-arming inside the recursion.</p>
<p>If you only take one of these home, take the timers one. It’s the one that finds you at 11pm.</p>
<h2 id="part-2">Part 2</h2>
<p><a href="https://zubin.dev/blog/web-game-soundscapes-2-howler/">Part 2</a> builds this for real: one engine-agnostic class, a six-function bus adapter, and bindings for React, Phaser and plain three.js. The engine itself is under a hundred lines. What changes from game to game is the bus underneath it and the lifecycle wiring above it.</p>
<p>If you only have an afternoon, start with last-picked exclusion and a concurrency cap. Those two knobs are what stop a bed from sounding like a tape.</p>]]></content:encoded><dc:creator>Zubin Khavarian</dc:creator><atom:updated>2026-09-28T17:00:00.000Z</atom:updated><category>audio</category><category>game-dev</category></item></channel></rss>