PURPOSE
Procedurally-synthesized sub-10 dB “ear-candy” SFX for every gameplay juice event. Owns a small library of short-lived Web Audio primitives (click / pop / crackle / thud / sizzle / sparkle / balloon-pop / laser), a recipe table that maps each event name to a sequence of primitive-plus-gain-plus-delay steps, the per-RAF SFX budget counter shared with weapon chimes, and a small handful of bespoke entries (XP tinkle, reward-card burst, medical-UI click namespace). No audio files — every sound is built from oscillators, biquads, and a shared one-second white-noise buffer.
OWNS
- The shared
_noiseBuffer— a one-second mono white-noiseAudioBufferlazily created on first crackle / sizzle / balloon-pop / xp-tinkle use and reused thereafter. _lastXpTinkleTime— module-level timestamp used by the 100 ms XP-tinkle rate limiter._sfxThisFramecounter,_queueoverflow array, andMAX_QUEUEcap (CFG.MAX_SFX_PER_RAF * 2). ExportsbumpSfxFrame()soweapon-chimes.tscan share the same per-RAF budget.UI_EVENTS— hardcodedSetof event names that should route through the UI SFX bus instead of the game SFX bus (menu clicks, stat stamps, level-up, every*_doneevent-completion cue, chase / warp / comet pickups, revive, credit error, pulse / vortex / cascade / forager / horde / comet-shower payoffs).dB(db)linear-gain converter and thehFreq/hGainhumanization helpers (±5% pitch, ±20% gain).- The eight sound primitives
_click,_pop,_crackle,_thud(with optionalheavyflag),_sizzle,_sparkle,_balloonPop,_laser— each builds its own oscillator(s) / noise source / filter / envelope graph, connects to the supplied bus, and self-terminates. SoundSteptuple type,SoundRecipearray alias, and theRECIPESevent-to-recipe table (≈120 entries spanning weapon fire, per-archetype death cues, per-prop break cues, per-artifact proc cues, shields, crates, props, collisions, XP / level-up, every*_doneevent payoff, hazards, wave-incoming telegraph, player death, warp-puddle transitions, enemy spawn, menu / stat feedback, medical-UI panel motions, credit error).MicroSfxpublic export —enabledflag (seeded fromss_sound_mutedinlocalStorage),resetFrameCount,play,_playXpTinkle,playCardBurst.MedClickpublic export —click(flavor)namespaced helper that delegates toMicroSfx.play(${flavor}_click).
READS FROM
./audio-context—AudioBus.getCtx,AudioBus.getGameSfxBus,AudioBus.getUiSfxBus,AudioBus.resume.../core/config—CFG.MAX_SFX_PER_RAFfor the per-frame budget and the overflow queue cap.localStorage— readsss_sound_mutedonce at module load to seedMicroSfx.enabled.- The browser’s
performance.now()for XP-tinkle rate limiting and the Web Audio APIs (OscillatorNode,BufferSource,BiquadFilterNode,GainNode,StereoPannerNode) for graph construction.
PUSHES TO
- Whichever bus
AudioBus.getGameSfxBus()orAudioBus.getUiSfxBus()returns — every primitive’s final envelopeGainNodeconnects to the suppliedmasterargument, which the dispatcher picks via theUI_EVENTSset. - The shared
_sfxThisFramecounter (incremented bybumpSfxFrame, zeroed byresetFrameCount). - The
_queueoverflow array — events that exceed the per-frame cap are pushed, then re-dispatched in the nextresetFrameCountdrain pass. _lastXpTinkleTimeis rewritten on each successful XP tinkle.- No
localStoragewrites, no telemetry, no signals.
DOES NOT
- Does not load or decode any audio files. Every sound is synthesized live.
- Does not decide which juice event fired —
engine/effects/juicecallsMicroSfx.play(event)and this module looks up the recipe. - Does not own the
AudioContext, the master chain, the SFX / UI / music buses, or the mute state itself — those live inaudio-context.ts(this module only reads the bus accessors and the cached mute flag). - Does not schedule its own per-RAF reset —
bridge.tscallsresetFrameCountonce per frame. - Does not drop over-budget events — they are queued (capped at
MAX_QUEUE) and replayed next frame. - Does not spatialize sounds in world coordinates. The only stereo pan is the ±0.2 random panner in
_playXpTinkle; every recipe is mono into a bus. - Does not pool or reuse oscillators / filter nodes. Each primitive allocates fresh nodes per play and lets
osc.stop()plus GC clean up after the envelope finishes. - Does not toggle its own
enabledflag at runtime —enabledis set once at module load fromlocalStorage. - Does not branch by recipe shape in the dispatcher beyond the special-case for
xp_pickup(which forks to_playXpTinkle). - Does not write the per-bus low-pass / reverb routing — those are baked into the bus chains in
audio-context.ts; this module just connects to the bus input.
Signals
None. No BridgeSignals are emitted or subscribed to. All communication is direct function calls — Juice.fire calls MicroSfx.play, the bridge calls MicroSfx.resetFrameCount, the reward-card screen calls MicroSfx.playCardBurst, and medical-UI components call MedClick.click. weapon-chimes.ts reaches in for the shared bumpSfxFrame export rather than going through a signal.
Entry points
MicroSfx.play(event)— public dispatcher. Returns silently ifenabledis false or the event has no recipe. Forks to_playXpTinkle()forxp_pickup. CallsbumpSfxFrame(); on overflow, queues the event (if_queue.length < MAX_QUEUE) and returns. Resolves the bus viaUI_EVENTS.has(event), callsAudioBus.resume(), then walks each[fn, gainDb, delay, extra?]step callingfn(ctx, bus, dB(gainDb), delay, extra).MicroSfx.resetFrameCount()— zeros_sfxThisFrame, snapshots_queue.length, splices that many events out, and replays each viathis.play(...)(which counts against the new frame’s budget). Called once per RAF frame bybridge.ts.MicroSfx._playXpTinkle()— special-cased XP pickup recipe. Rate-limited to once per 100 ms via_lastXpTinkleTime, still respectsbumpSfxFrame(). Builds a C6 + C7 sine pair through a shared envelope, an allpass biquad with randomized frequency (400–1600 Hz) and Q (2–8), then aStereoPannerNodeat ±0.2 random pan, falling back to a direct bus connection on older browsers.MicroSfx.playCardBurst(isNew)— reward-card explosion. Routes through the UI bus (no LPF / no reverb).isNew=trueplays a four-sparkle chord stagger (0 / 20 / 40 / 60 ms) plus a coin-land click at 80 ms and a metal sizzle at 90 ms;isNew=falseplays a rising three-sparkle arpeggio (0 / 60 / 120 ms) plus a dust sizzle at 50 ms.MicroSfx.enabled— public boolean, read at module load fromlocalStorage['ss_sound_muted'].MedClick.click(flavor='drawer')— namespaced helper. CallsMicroSfx.play(${flavor}_click), so it inherits the frame limiter, the mute flag, and the recipe-lookup miss-is-silent behavior.bumpSfxFrame()— exported only soweapon-chimes.tscan share the per-RAF budget; not part of theMicroSfxobject.
Pattern notes
- Recipe table is the entire API surface. Adding a new event is a single object-literal entry in
RECIPES. Removing one is a deletion. No registration function, no branching code inplay()— the dispatcher walks whatever tuples it finds. - Per-frame budget is shared, not per-module. Both this file and
weapon-chimes.tscall the samebumpSfxFrame(); the single counter_sfxThisFrameand the singleresetFrameCountdrain pass cover both. The bridge owns the once-per-RAF reset. - Overflow is queued, not dropped. Excess events go into
_queue(capped at2 * CFG.MAX_SFX_PER_RAF); the nextresetFrameCountsnapshots the length and replays exactly that batch, so events that overflow again during the drain end up at the new tail rather than being re-attempted in a tight loop. - UI vs game routing by hardcoded set.
UI_EVENTSis a literalSet<string>. The same recipe can sound different depending on whether its event name is in the set: game-bus events pass through the 3 kHz LPF + reverb inaudio-context.ts; UI-bus events skip both for a dryer, brighter payoff. - dB-coded recipes. Every recipe gain is expressed in dB ranging roughly from −8 (loudest payoff cues) to −28 (XP pickup fallback). The dispatcher converts via
dB(db) = 10^(db/20)so designers tune in familiar units. - Humanization is centralized. Every primitive runs its base frequency through
hFreqand its peak gain throughhGainto add ±5% pitch and ±20% volume jitter for organic variation. There is no “deterministic” path through a primitive. - Shared noise buffer. Crackle, sizzle, balloon-pop, and the XP tinkle’s allpass-coloured path all reuse a single one-second mono white-noise
AudioBuffer; each primitive picks a randomloopStart/loopEndwindow so the underlying samples look different even though the buffer is shared. - Per-archetype / per-prop / per-artifact override maps. The recipe table includes dispatch-site override entries (
enemy_kill_charger,prop_break_volatile_crystal,artifact_devourer, …). The caller —damage.ts,props.ts,actions.ts:actFlashArtifact— looks up the override key first and falls back to the generic recipe (enemy_kill,prop_break, etc.) when missing. xp_pickupis the only event the dispatcher special-cases. Its recipe entry is a fallback placeholder;play()checks for the event name before walking steps and forks to_playXpTinkle(), which has its own rate limiter, allpass colouring, and stereo pan.- Self-terminating envelopes. Every primitive sets
osc.start(now)+osc.stop(now + dur + tail)and connects through a per-call envelopeGainNode. Nodes disconnect themselves when the source ends and are garbage-collected — no manual pool, no cleanup queue. - Mute is read-only at module load.
MicroSfx.enabledis initialized once fromlocalStorage['ss_sound_muted']and not re-checked; runtime mute toggling has to flipenableddirectly (audio-context owns the user-visible volume sliders). MedClickis an indirection, not a separate engine. It exists so future medical-UI variants (e.g.panel_click,tab_click) can be added without changing call sites — every variant still goes throughMicroSfx.playand inherits the frame limiter + mute behavior.