signals.ts
PURPOSE
Synchronous, in-process event bus used as the spine of cross-system communication in the engine. Decouples emitters (combat, world, physics, bridge) from listeners (effects, artifacts, audio/VFX, telemetry, tests) while keeping the dispatch path allocation-free and trivially debuggable. Ported from the legacy 06-systems.js (Sig) module.
OWNS
- The singleton
Sigobject — the only signal bus in the engine. _listeners:Map<string, ListenerEntry[]>of registered handlers keyed by signal name, each entry sorted descending by priority._depth: integer recursion counter used to cap nested signal storms._ctx: a single sharedSignalContextinstance that is mutated and reused on everyfire(). No payload allocation occurs in the hot path.- The
SignalContextshape: fixed-width{ name, uid1, uid2, num1, num2, str1 }. - The
SignalListenercallback type and the internalListenerEntry({ fn, priority }).
READS FROM
Nothing. Pure in-memory module with no imports. It does not read game state, time, RNG, config, or DOM.
PUSHES TO
Nothing directly. It dispatches synchronously to whatever callbacks are registered via on(). All side effects belong to listeners, not the bus.
DOES NOT
- Does not queue, batch, or defer — every
fire()invokes all listeners synchronously before returning. - Does not allocate per-fire — the shared
_ctxis mutated in place; do not retain references toctxacross calls. - Does not support wildcards, namespaces, or one-shot listeners.
- Does not auto-unsubscribe — listeners persist until
off()orclear(). - Does not catch listener exceptions — a throw inside a handler unwinds through
fire()and aborts later handlers for that fire. - Does not pass typed payloads — every signal shares the same six-field context; meaning of each slot is signal-specific and documented at the call site.
- Does not enforce listener uniqueness — registering the same
fntwice runs it twice. - Does not persist listeners across
clear(); consumers must re-register after a run reset.
Signals
All signals share the SignalContext shape { name, uid1, uid2, num1, num2, str1 }; the meaning of each numeric/string slot is per-signal.
Combat and damage (engine/combat/):
bullet_hit— fired fromcollision-resolver.tswhen a bullet impacts an enemy.uid1is the enemy eid,num1is the bullet damage,str1is the damage tag (primary or_wSpec.secondaryDamageTag).damage_dealt— fired fromdamage.tsper damage application;num1is raw damage,num2is enemyx,str1is the last damage tag.tbone_hit— fired fromcollision-resolver.tson ship-vs-enemy contact;num1/num2are enemy position,str1is either'tbone'or'ram'.enemy_kill— fired fromdamage.tson a non-boss enemy kill;num1/num2are enemy position,str1isenemy.typeId.tagged_kill— fired fromdamage.tsimmediately afterenemy_killwhen the killing blow had a damage tag; carries position and the tag instr1.boss_kill— fired fromdamage.tson boss death and fromboss/encounter.tson encounter completion (with reward XP/currency innum1/num2and boss id instr1).boss_body_kill— fired fromdamage.tswhen a boss body part is destroyed (non-final).boss_anchor_destroyed— fired fromdamage.tswhen a boss anchor is destroyed; position innum1/num2, type id instr1.boss_defeated— listened to inbridge.tsas the overall boss-defeat hook for run flow.kill_streak_milestone— fired fromdamage.tswhen a streak threshold is crossed;num1is the kill count.shield_hit— fired fromdamage.tswhen damage is absorbed by shields;num1/num2are ship position.shield_break— fired fromdamage.tswhen shields drop to zero;num1/num2are ship position.hull_damage— fired fromdamage.tswhen damage reaches the hull;num1is hull damage.player_damage— fired fromdamage.tsfor every damage application that touches the player;num1is amount,str1is'shield'or'hull'.player_healed— fired fromeffects/actions.tswhen a heal action lands;uid1is the actual amount healed.
World, props, and progression:
prop_break— fired fromworld/props.tswhen a destructible prop is broken;num1/num2are position,str1is the prop def id.crate_break— fired fromworld/crates.tswhen a crate is broken;num1/num2are position.event_complete— fired frombridge.tson portal/event-completion, with position innum1/num2and event type instr1.level_up— fired fromworld/leveling.ts;num1is the new level.tier_advance— fired frombridge.tson tier promotion;num1isgame._currentLevel.
Run lifecycle and player state:
phase_change— fired frombridge.tswhenever the run phase changes;str1carries the phase name ('playing','results','dead').run_start— referenced fromeffects/run-effects.tsfor one-shot run-start effects.weapon_fire— fired frombridge.tson weapon shot;num1is the weapon index,str1is the weapon id.stall_start— fired fromphysics/movement.tswhen heat triggers a stall;num1isship.heat.
The list above is the set of standard signals observed in production code paths. Additional signal names may be wired dynamically through the effects pipeline (see Entry points).
Entry points
Sig.fire(name, uid1?, uid2?, num1?, num2?, str1?)— synchronously dispatchesnameto all registered listeners in priority order. Increments_depth, mutates_ctxin place, iterates listeners, decrements_depth. Returnsvoid. If_depthwould exceed 8, the call is dropped (depth is decremented back andfire()returns early without dispatching) — protects against infinite signal storms when a listener fires another signal that fires the original signal, and so on.Sig.on(name, fn, priority = 0)— registersfnfornameat the given priority. Inserts into the per-name list and re-sorts descending by priority so higher priorities run first.Sig.off(name, fn)— removes the first listener whosefnreference matches. Silent no-op if not found.Sig.clear()— wipes_listenersand resets_depthto 0. Called fromengine/core/loop.tsandengine/bridge.tsat run reset; consumers (e.g.engine/world/artifacts.ts,engine/effects/effect-engine.ts) must re-register their listeners afterclear().Sig.has(name)— returns true if at least one listener is registered forname. Used as a cheap guard before assembling a fire payload.
Pattern notes
- Priority bands. Three conventional bands are used across the engine:
100— core/gameplay logic that must run before downstream reactions.50— effects pipeline (engine/effects/effect-engine.tsregisters all signal-triggered effects at this level) and parallel legacy registrations inengine/world/artifacts.ts(enemy_kill,shield_hit,crate_break,tbone_hit,event_complete). The boss gauntlet test runner also uses 50 for itsboss_killhandler.0— audio/VFX/cosmetic and any caller that omits thepriorityargument (default). Higher numbers fire first. Same-priority listeners run in registration order within the sort (stable sort behavior is not guaranteed but in practice the array order is preserved at equal priority).
- Shared
_ctxsnapshot. Everyfire()overwrites the same_ctxinstance rather than allocating. Listeners that need to capture data across the fire boundary must copy fields out — never retain thectxreference itself, never read it after the listener returns, and never read it from an async continuation. The shared object is the reasonfire()is allocation-free in steady state. - Synchronous dispatch. Listeners run on the caller’s stack; emitters can rely on all listeners having completed before the next line. There is no microtask hop and no defer.
- Recursion cap. The depth guard is the only protection against listener cascades. The check is
++this._depth > 8, so fires at nesting levels 1–8 dispatch and the 9th nested fire is dropped silently. A dropped fire still decrements_depthbefore returning, keeping the counter balanced. - Re-registration after reset. Because
clear()wipes everything, any system that owns listeners (artifacts, effect engine, bridge boss hooks, gauntlet test runner) must re-register on run start. Forgetting to re-register manifests as silent dead signals. - Effect engine is the primary
on()registrar.engine/effects/effect-engine.tsregisters one listener per unique signal name at priority 50 and dispatches matching effects internally; rawSig.on()outside the effect engine is reserved for systems that need to bypass the effect pipeline (artifacts legacy path, bridge phase wiring, test runners). - Fixed-width payload is intentional.
{ uid1, uid2, num1, num2, str1 }keeps the bus monomorphic and allocation-free. Signals that need richer payloads either look up state from world arrays via theuidslots or accept that some fields are unused.