BLIT386 1.3.0 - the engine learns to make noise
The audio release - buses, a voice pool, procedural synthesis, crossfading music - plus BT.renderAlpha and overlay level meters.
For three releases BLIT386 could draw a sprite, cycle a palette, warp the whole screen through a CRT shader, and then sit there in complete silence, like a very pretty aquarium. Every demo I built ended the same way: I got the pixels right, moved on to the sound, remembered there was no sound, and shipped it anyway. That gap is closed now. 1.3.0 is the audio release.
Three buses, sixteen voices
The shape is deliberately boring, because sound is the one subsystem where clever design bites you at 2am. There are
three buses - sfx and music, both feeding main - and each one has volume, mute, and fades:
import { } from 'blit386';
.('music', 0.6);
.('sfx', 0.9, { : 200 });
.('main', true);Sound effects go through a fixed-size voice pool (16 by default, HardwareSettings.audioVoices if you want more or
fewer). When the pool runs out, the lowest-priority voice gets stolen, which is the honest thing to do - the alternative
is either dropping the sound the player is actually listening for, or letting a hundred explosions turn the mix into
mud. BT.soundPlay hands you back a generational SoundRef, so you can re-pitch, re-pan, or stop a voice later, and
the handle quietly no-ops once that voice has been recycled instead of retuning some other poor sound that inherited the
slot.
Clips load the way you would expect - AudioClip.load, loadAll, per-URL caching, in-flight deduplication, and an
ordered fallback list for browsers that refuse your first codec. The full method-by-method reference is in
API: Audio; the subsystem map, the bus graph, and the web platform constraints behind all of it live
in the Audio Guide.
Sounds with no sound files
The part I enjoyed building most: AudioClip.synth renders a clip on the CPU from a small parameter object - waveforms,
an ADSR envelope, a pitch sweep, vibrato, a noise mix - with no source file anywhere. It is deterministic and seeded, so
the same params give you the same bytes every time. And there are six presets, because nobody wants to hand-tune an
envelope just to make a coin go bloop:
import { , } from 'blit386';
const = await .(..());
.(, { : 0.8 });jump, pickup, explosion, laser, hit, blip. Each takes an optional seed, so the tenth coin in a row does not
sound exactly like the first. It costs you zero kilobytes of assets, which for a game jam is not nothing.
Music, and the asymmetry nobody warns you about
Music is its own crossfading player - BT.musicPlay, BT.musicStop, configurable fade duration, crossfade overlap, and
loop points, so an intro can play once and then hand off to a bridge that repeats forever.
Then there is the browser autoplay rule, which is where the design gets slightly weird on purpose. Browsers will not let
you make a sound until the player has touched something, so the engine installs one-shot gesture listeners at
BT.init() and flips BT.isAudioUnlocked on the first pointer, key, or touch. Before that moment, a BT.soundPlay is
dropped on the floor - it was a reaction to an input that, by definition, has not happened yet. A BT.musicPlay is
remembered instead, and starts the instant the context unlocks. Asymmetric, yes, and it looks like a bug in the API
until you think about what each call actually means: an effect that missed its moment is worthless, a theme song that
was queued a second early is not.
BT.renderAlpha
Fixed update, variable render. If your display runs at 144 Hz and your simulation ticks at 60, you are rendering
somewhere between two update steps most of the time, and the engine now tells you where: BT.renderAlpha is the
fractional progress in [0, 1) since the last tick. Lerp your positions with it and the motion stops looking slightly
chunky. The worked pattern, with Vector2i.lerp, is in the new Game Loop Guide; the timing
API itself moved into its own page at API: Game Loop.
The overlay grew an ear
Turn on isOverlayAudioMetersEnabled and the overlay gains a per-bus level band plus a voices used/total, steal, and
drop readout - enough to see at a glance whether your explosions are eating the whole pool. The AnalyserNode is
created lazily, so if you never set the flag you pay nothing for the feature.
While I was in there, two small irritations got fixed. The overlay's separators are now real 1px dividers instead of |
glyphs, and the numeric fields are padded to a fixed width, so the dividers no longer twitch sideways every time your
FPS crosses from 99 to 100. It bothered me more than it should have.
Fixes
Three of them worth naming. The camera offset now survives a render frame that ran zero update steps, so scrolling no
longer snaps back to the previous origin on high-refresh displays. WebGPU canvas configuration asks for COPY_SRC
usage, which means BT.captureFrame() stops handing you a beautifully transparent PNG of nothing. And
BT.systemPrintMeasure() returns a fresh size object per call, instead of a shared one that let two measurements
silently overwrite each other.
The docs took the release seriously
api-core.md had quietly grown into one of those pages you scroll for a minute before finding anything, so it is now
six: Overlay, Game Loop, Camera,
Easing, Core Types, and Browser Support. Plus
the two new audio pages - API: Audio and the Audio Guide - and the
Game Loop Guide.
Underneath that, every published page now carries per-symbol version history generated straight from @since /
@changed / @deprecated tags in the source, so "when did this method actually ship" is a question the docs answer
themselves rather than one you take to the git log. Every TypeScript block is Twoslash-checked, which means it compiles
or the build fails - no more copy-pasting an example that stopped being true two releases ago. And the API pages now
embed the live demos next to the prose, so you can read what soundPlay does and then hear it in the same scroll.
The full list, in the usual Keep a Changelog shape, is at the changelog.
npm create blit386@latest my-gameGo make something loud.