Audio in Fieldrunners HTML5: A Porting Retrospective
Fieldrunners, the tower-defense title that debuted on iPhone in 2008, arrived in the Chrome browser in October 2011. Porting the game to HTML5 meant facing a familiar but formidable challenge: reproducing its audio layer. The game requires 88 sound effects, a large number of which can be in flight simultaneously. These are short, immediate cues that must sync tightly with on-screen action to preserve the game's feel.
The initial soundtrack playback relied on the <audio> element, but the team soon moved to the Web Audio API to meet the demanding concurrency and latency requirements. This switch solved the core problem of overlapping effects but introduced a new set of nuanced technical obstacles worth detailing for other developers.
The One-Shot Lifecycle of AudioBufferSourceNodes
The fundamental building block for playback in Web Audio is the AudioBufferSourceNode. A critical constraint: these nodes are single-use. You create one, assign an AudioBuffer, connect it to the graph, and trigger it with noteOn or noteGrainOn. Playback can be halted with noteOff, but that node cannot be reused for another play call. A fresh AudioBufferSourceNode is required each time.
The underlying AudioBuffer, however, is reusable. You can have multiple active AudioBufferSourceNode instances pointing to the same AudioBuffer object. This distinction is key to building an efficient sound-queueing system.
An Audio Graph with Three Node Types
Fieldrunners' audio model supports independent volume control for effects and music, a global mute, pausing and resuming in-game sounds, and silencing on tab loss. Achieving this with Web Audio required only three node types: AudioBufferSourceNode, GainNode, and the AudioContext's destination node.
A simple graph suffices. A master GainNode connects to the destination. From the master, two dedicated gain nodes branch out: one for the music channel, and one to which all sound effects attach. Fieldrunners initially used six permanent gain nodes; three are functionally sufficient for these volume and routing controls. The extra nodes were a workaround for a misunderstood bug (explained below) and are no longer recommended.
Volume control is straightforward. Each GainNode exposes a gain attribute on its AudioParam, accepting decimal values between 0 and 1. Adjusting the music or effects gain nodes scales those channels independently. Setting the master gain to 0 serves as a global mute switch.
This graph structure has a distinct advantage over manual volume management. In a system of many simultaneous effects, you don't have to track and update the gain on each individual AudioBufferSourceNode. A single adjustment on the relevant group node handles all child sounds.
When Pausing is More Complex Than It Appears
A game pause shouldn't wipe out UI feedback sounds, but it should halt looping or long-duration gameplay effects. The initial approach was to use extra GainNodes as breakers, physically disconnecting groups of sound effects from the audio graph to stop their progress.
While this "worked" for shipping, it was later discovered that disconnecting a node does not actually pause the underlying AudioBufferSourceNodes. The team had accidentally leveraged a bug in Web Audio implementations where nodes disconnected from the destination would stop processing. This behavior is subject to change, so relying on it is hazardous for future-proofing.
A more robust solution involves actively tracking active AudioBufferSourceNodes and explicitly suspending them when the game pauses, then resuming them on continuation. This requires maintaining a record of the playback state—a responsibility Web Audio's core does not currently manage for you.
Keeping the Silence When Focus is Lost
Before the Page Visibility API, detecting tab switches was a convoluted effort. The game's update loop relies on requestAnimationFrame, implying rendering freezes in background tabs, but the Web Audio context keeps running. Looped effects and the music track would continue unabated.
The Page Visibility API offers a clean solution. A small handler on a document's visibilitychange event can mute the master gain node, effectively silencing the entire game. As with game pausing, the initial pass relied on disconnecting the master node; the same bug-and-fix caveat applies here, making a code-based pause or mute on the gain node pattern the wiser choice.
Playback Mechanics
The game has an intentional audio design decision: for a given game event, like a character dying, a new sound instance is not created if one for that event is already playing. Resources are only restarted after completion to avoid audible stuttering. The one-shot nature of AudioBufferSourceNode suits this design pattern well.
Standard playback for Fieldrunners follows a predictable sequence: instantiate a fresh AudioBufferSourceNode, assign its buffer property, set the loop boolean if necessary, connect it into the existing gain node graph, and start it with noteOn or noteGrainOn. At that point, noteOff is available to stop scheduled playback early.
Streaming Setback
Early on, background music was a clear exception, playing through the <audio> element. This was intended to allow for streaming. Following the launch, server logs revealed a disproportionate number of requests for music files. The culprit was caching: the Chrome 15 browser was downloading the audio file in chunks and not retaining them, forcing a fresh request whenever the track neared its finish. Newer browser builds handle streaming cache correctly, but some still falter.
Given these issues, the team shifted background music playback into the Web Audio graph entirely. This meant loading the full music track like any other asset—via an XMLHttpRequest with an arraybuffer response type. This eliminates the streaming problem at the cost of a larger initial transfer, a tradeoff considered acceptable for the stability it provided.
Web Audio’s One-Shot Constraint
Porting Fieldrunners to HTML5 meant confronting an architectural reality of the Web Audio API: AudioBufferSourceNode objects are single-use. You create one, attach a buffer, connect it to the audio graph, and start it with noteOn() or noteGrainOn(). To play that same sound again, you must build a fresh node.
That design makes sense for garbage collection and memory efficiency, but it forces a different pattern than what most game audio engines assume. In a C++ engine, you typically have persistent playback objects you can re-trigger. With Web Audio, you need a management layer that creates, plays, and discards nodes constantly. A naïve approach—just new nodes every time a sound fires—will work, but it quickly churns memory and risks audio glitches under load.
Pooling Nodes Without Stalling Playback
The practical solution is a pool of pre-created AudioBufferSourceNode instances. Before gameplay starts, you instantiate a handful of nodes per sound effect, attach the relevant AudioBuffer, and keep them idle. When a sound is requested, you pull an idle node from the pool, call Play, and mark it busy. Once the node finishes, you reclaim it for reuse.
The catch is knowing when a node is done. The onended event fires when playback stops naturally, but relying solely on it adds latency to node recovery. If a sound effect fires repeatedly in quick succession, you can exhaust the pool waiting for onended callbacks. You can poll for a node’s current time against its duration to age out finished nodes early, but that requires a timer tick that itself adds complexity.
In practice, a hybrid worked best: for short, frequent sounds—like gunshots or footsteps—one-shot nodes were created on demand and discarded, with GC handling cleanup. For longer or looped sounds—like ambient noise or music—the pool was used, since the cost of recreating nodes is higher. Tuning the pool size to the maximum expected simultaneous sounds for a given level avoided both stutters and excessive memory.
Latency, Scheduling, and the Audio Clock
Web Audio runs on its own high-precision clock, decoupled from the main JavaScript thread. That means you should never trigger sounds directly from a game loop or an event callback if you want tight timing. The noteOn() argument takes an absolute time in seconds on the audio clock, so you can schedule a sound precisely by adding a small lookahead delay to the current context.currentTime. This decoupling absorbs jitter from garbage collection pauses, layout work, or enemy AI bursts.
Fieldrunners needed exactly this for its tower-firing sounds. Instead of firing audio the instant a projectile launched from a JS callback, the code scheduled the buffer to start a few milliseconds ahead, using a “lookahead” pattern. That eliminated the audio dropouts that come from main-thread stalls.
Memory and Buffer Management
Decoded audio lives in memory as an AudioBuffer with its own sample count and channel count. Loading all sounds at page load is tempting but wasteful, and decoding new audio on the fly blocks the audio thread. The middle path: pre-decode critical UI and environment sounds during a load screen, and lazy-load longer music tracks as they become needed. When a portion of a level ends or a sound is no longer in scope, releasing references and letting the garbage collector reclaim the buffer memory is straightforward—but avoid creating buffers repeatedly for effects used many times per second.
Volume control must remain a per-node parameter at the GainNode level, not at the global master volume. That way, pausing the game or muting SFX doesn't require reconnecting the entire graph.
Fitting Into a JavaScript Codebase
Integrating the audio manager into the ported JS version of Fieldrunners meant encapsulating all Web Audio calls inside a single wrapper module. Game code calls a simple playSound(id) function; the wrapper decides whether to fetch a node from the pool, creates a new one, or schedules a one-shot. All time calculations live in that module. This separation kept the core game logic clean and allowed the audio layer to evolve without touching the rest of the port.
The final lesson: AudioBufferSourceNode’s one-time nature is not a limitation but a design prompt. Embraced early, it leads to a pooling system that is predictable and testable, and it forces the team to think explicitly about when and how much audio memory a level needs.



