Skip to content

Audio

The engine owns sound. A game declares a cue under a name while it initializes and plays it by that name from its update; the audio graph, the mute state, and the first-gesture unlock a browser requires sit below the name and belong to the engine.

A cue is either synthesized from a description or backed by a produced audio file. Both are declared during initialization and both are played by name, so the simulation reads the same either way.

A synthesized cue is one oscillator through one gain node. The waveform is chosen per cue, the frequency sweeps linearly from a starting value to an ending value across the cue’s duration, the gain decays from a peak to silence over that same span, and both nodes stop when the duration ends.

That is the entire synthesis model. Five numbers describe a bleep, a rising chime, or a falling thud, which is the vocabulary a 2D game needs. A produced game gets its sound without shipping a file and without spending any of its build effort on oscillators, gain envelopes, or the autoplay policy.

A cue may instead name an audio file, resolved under the asset root and decoded once during initialization. This is how a game plays sound the asset-generation tools produced: a case that generates its own effects or music binds each produced file to a cue name, and the simulation plays that name exactly as it plays a synthesized one.

Both kinds share one namespace, so replacing a placeholder bleep with a produced file changes the declaration and leaves every play site alone. Declaring a name that already holds a cue replaces it, which is what lets a sound be retuned mid-run.

A cue played once ends on its own. A cue looped sounds until the game stops it, which is what an engine hum, a held thruster, or a music bed needs. A file-backed cue loops its decoded buffer seamlessly, and a synthesized cue holds its waveform at its starting frequency and its gain, so the same five numbers that describe a bleep also describe a drone.

A cue is either looping or not. Starting a loop that is already running does nothing, and stopping one that is not running does nothing, so a game drives a loop from its state on every frame without counting starts against stops. Each transition is announced once, as cue:looped when the loop starts and cue:stopped when it ends.

A loop belongs to the cue name. Redeclaring a looping name stops the loop, mute silences and restores every running loop in place, and a loop requested before the unlock is looping from that moment and begins to sound when the context opens. Destroying the engine stops every loop.

The bus carries two bits, because a silent game is silent for one of two very different reasons.

Muted is a decision the game or the player made. Unlocked records that a user gesture reached the engine, which is what a browser demands before it will start an audio context at all. The engine listens for the first pointer or key event on its surface and opens the context there, once, so a build supplies no gesture handler and no enable-sound screen of its own.

Keeping the two bits apart is what lets a silent build be diagnosed. A muted bus is the build behaving as asked. A locked bus is an environment nothing has clicked yet, which says nothing about the build. A single bit covering both would read the same for a working game and a broken one, so the unlock is also announced as the audio:unlocked event and a caller watching a build come up sees the moment the context opened.

A browser that refuses a context, or one whose context dies partway through a run, degrades the bus to one that accepts cues, announces them, and sounds nothing. The frame proceeds unchanged.

Playing, looping, or stopping a cue that was never declared raises an error. Silence is the expected outcome of a muted or still-locked bus, so a mistyped name would otherwise be indistinguishable from a cue that played inaudibly, and the mistake would survive to the end of the run unnoticed.

Each play emits a cue:played event carrying the name, the time it played at, and the gain it played at. The payload is semantic: it states that a named cue happened, so it reads identically under a muted engine, a still-locked bus, and a browser with no audio at all. A muted play is announced at a gain of zero, which keeps the mute visible and keeps a game that reacted to an event while muted distinguishable from one that never reacted.

Delivery is synchronous, inside the play. A subscriber therefore runs while the frame that played the cue is still running, and can attribute the cue to that frame’s counter, respond to it, or count it against a frame it holds.

A subscription is what audio observation needs, in place of a record the engine accumulates. An engine-owned log would have to keep every cue of every run against the chance that something reads it, which grows for as long as the game plays. A subscription keeps the engine’s memory flat and puts the shape of the record with the code that wants one, so a check that needs the last few cues keeps a fixed-capacity buffer of its own and a check that needs a count keeps an integer.

A cue’s timestamp is the loop’s accumulated simulated time, so it lines up with the frame counter and with the simulated time everything else is expressed in.

That is what keeps the timestamp meaningful under a scripted clock. Frames stepped through engine.advance run back to back and no real time passes, so wall-clock stamps would put every cue of a several-hundred-frame advance at the same instant. Read against the frame clock, a cue’s time says which frame of the simulation it belongs to, and an ordering assertion over cues becomes an assertion about the game’s behavior over its own timeline.