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 Sig object — 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 shared SignalContext instance that is mutated and reused on every fire(). No payload allocation occurs in the hot path.
  • The SignalContext shape: fixed-width { name, uid1, uid2, num1, num2, str1 }.
  • The SignalListener callback type and the internal ListenerEntry ({ 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 _ctx is mutated in place; do not retain references to ctx across calls.
  • Does not support wildcards, namespaces, or one-shot listeners.
  • Does not auto-unsubscribe — listeners persist until off() or clear().
  • 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 fn twice 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 from collision-resolver.ts when a bullet impacts an enemy. uid1 is the enemy eid, num1 is the bullet damage, str1 is the damage tag (primary or _wSpec.secondaryDamageTag).
  • damage_dealt — fired from damage.ts per damage application; num1 is raw damage, num2 is enemy x, str1 is the last damage tag.
  • tbone_hit — fired from collision-resolver.ts on ship-vs-enemy contact; num1/num2 are enemy position, str1 is either 'tbone' or 'ram'.
  • enemy_kill — fired from damage.ts on a non-boss enemy kill; num1/num2 are enemy position, str1 is enemy.typeId.
  • tagged_kill — fired from damage.ts immediately after enemy_kill when the killing blow had a damage tag; carries position and the tag in str1.
  • boss_kill — fired from damage.ts on boss death and from boss/encounter.ts on encounter completion (with reward XP/currency in num1/num2 and boss id in str1).
  • boss_body_kill — fired from damage.ts when a boss body part is destroyed (non-final).
  • boss_anchor_destroyed — fired from damage.ts when a boss anchor is destroyed; position in num1/num2, type id in str1.
  • boss_defeated — listened to in bridge.ts as the overall boss-defeat hook for run flow.
  • kill_streak_milestone — fired from damage.ts when a streak threshold is crossed; num1 is the kill count.
  • shield_hit — fired from damage.ts when damage is absorbed by shields; num1/num2 are ship position.
  • shield_break — fired from damage.ts when shields drop to zero; num1/num2 are ship position.
  • hull_damage — fired from damage.ts when damage reaches the hull; num1 is hull damage.
  • player_damage — fired from damage.ts for every damage application that touches the player; num1 is amount, str1 is 'shield' or 'hull'.
  • player_healed — fired from effects/actions.ts when a heal action lands; uid1 is the actual amount healed.

World, props, and progression:

  • prop_break — fired from world/props.ts when a destructible prop is broken; num1/num2 are position, str1 is the prop def id.
  • crate_break — fired from world/crates.ts when a crate is broken; num1/num2 are position.
  • event_complete — fired from bridge.ts on portal/event-completion, with position in num1/num2 and event type in str1.
  • level_up — fired from world/leveling.ts; num1 is the new level.
  • tier_advance — fired from bridge.ts on tier promotion; num1 is game._currentLevel.

Run lifecycle and player state:

  • phase_change — fired from bridge.ts whenever the run phase changes; str1 carries the phase name ('playing', 'results', 'dead').
  • run_start — referenced from effects/run-effects.ts for one-shot run-start effects.
  • weapon_fire — fired from bridge.ts on weapon shot; num1 is the weapon index, str1 is the weapon id.
  • stall_start — fired from physics/movement.ts when heat triggers a stall; num1 is ship.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 dispatches name to all registered listeners in priority order. Increments _depth, mutates _ctx in place, iterates listeners, decrements _depth. Returns void. If _depth would exceed 8, the call is dropped (depth is decremented back and fire() 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) — registers fn for name at 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 whose fn reference matches. Silent no-op if not found.
  • Sig.clear() — wipes _listeners and resets _depth to 0. Called from engine/core/loop.ts and engine/bridge.ts at run reset; consumers (e.g. engine/world/artifacts.ts, engine/effects/effect-engine.ts) must re-register their listeners after clear().
  • Sig.has(name) — returns true if at least one listener is registered for name. 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.ts registers all signal-triggered effects at this level) and parallel legacy registrations in engine/world/artifacts.ts (enemy_kill, shield_hit, crate_break, tbone_hit, event_complete). The boss gauntlet test runner also uses 50 for its boss_kill handler.
    • 0 — audio/VFX/cosmetic and any caller that omits the priority argument (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 _ctx snapshot. Every fire() overwrites the same _ctx instance rather than allocating. Listeners that need to capture data across the fire boundary must copy fields out — never retain the ctx reference itself, never read it after the listener returns, and never read it from an async continuation. The shared object is the reason fire() 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 _depth before 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.ts registers one listener per unique signal name at priority 50 and dispatches matching effects internally; raw Sig.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 the uid slots or accept that some fields are unused.