Sig Dispatch Flow (Kill Chain)

The Sig bus is a synchronous, fixed-payload event dispatcher (see sig-bus). All gameplay reactions to combat — artifacts, effects, achievements, telemetry, juice, VFX — hang off named signals fired from the damage and death sites. The kill chain is the canonical example: one bullet landing on an enemy walks through up to five named signals in a fixed order, each delivering a context object listeners can react to.

Typical chain on enemy kill

When a player bullet hits an enemy with enough damage to kill it, the following signals fire in this order within a single damageEnemy call:

  1. bullet_hit — fired from collision-resolver.ts immediately after the bullet’s damage is committed. Payload: uid1 = enemy.eid, num1 = b.dmg, str1 = enemy._lastDmgTag (the weapon’s damage tag). Legendary weapons with a secondaryDamageTag fire a second bullet_hit with the secondary tag so listeners keyed on either parent tag react to the same hit.
  2. damage_dealt — fired inside damageEnemy after enemy.hp -= rawDmg is applied. Payload: num1 = rawDmg, num2 = enemy.x, str1 = enemy._lastDmgTag. This is the generic “amount + position + damage tag” signal that effects use for type-conditional reactions regardless of whether the hit kills.
  3. enemy_kill — fired when enemy.hp <= 0 and the enemy is not a boss anchor or shared-health body. Payload: num1 = enemy.x, num2 = enemy.y, str1 = enemy.typeId. Artifacts like Soul Leech and ghost spawners listen here.
  4. tagged_kill — fired immediately after enemy_kill, on the same regular-kill branch. Payload: num1 = enemy.x, num2 = enemy.y, str1 = enemy._lastDmgTag. Carries the damage tag for type-conditional kill effects (e.g. “on fire kill”, “on bullet kill”) so listeners can short-circuit when the tag doesn’t match.
  5. boss_kill — fired only if enemy.isBoss and the dead body was the final sharer in a shared-health encounter. Payload: num1 = enemy.x, num2 = enemy.y, str1 = enemy.typeId. Bosses also receive a per-body boss_body_kill signal on every shared-health body death.

Streak milestones additionally emit kill_streak_milestone from _checkStreakMilestone, and boss anchors emit boss_anchor_destroyed instead of the regular enemy_kill / tagged_kill pair (anchors don’t grant XP, streak, or elite bonuses by design).

Other dispatch sites that follow the same pattern

  • level_up — fired from LevelingSystem.levelUp in world/leveling.ts with num1 = game.level. Drives effect-engine listeners (level-gated triggers), the level-up orchestrator, and reward queue side effects.
  • Player damage chaindamagePlayer fires shield_hit + player_damage (with str1 = 'shield') on shield absorption, hull_damage + player_damage (str1 = 'hull') on hull damage, and shield_break when the shield drops to zero.

Priority bands

Listeners register with a numeric priority; higher fires first. The bus sorts the listener list at registration time, so dispatch is a simple in-order walk. The three conventional bands (documented in sig-bus and priority-bands):

  • 100 — Core logic. State mutations that other listeners may read (e.g. effect-engine bookkeeping, status accumulators).
  • 50 — Effects. Artifact reactions, on-hit / on-kill effects that read state set by core logic.
  • 0 — Audio / VFX / juice. Cosmetic reactions; never mutate gameplay state.

A depth cap of 8 nested Sig.fire calls prevents infinite signal loops; once breached the call short-circuits and returns without dispatching.

Snapshot ctx pattern

The bus reuses a single shared _ctx object across every fire call (see signals.ts). On dispatch, fire mutates the six fields (name, uid1, uid2, num1, num2, str1) and passes the same object reference to every listener in sequence. Listeners must read the values they need synchronously and not retain the ctx reference past their own callback — by the time the next signal fires, the same object has been overwritten with the next signal’s payload.

Practical consequences:

  • Payloads are five primitive slots, no objects or arrays. Anything richer has to be looked up by uid1 / str1 (e.g. “find the enemy with this eid”, “look up the weapon by id”).
  • All dispatch is synchronous within a single frame. By the time Sig.fire('enemy_kill', ...) returns, every listener has run to completion. Re-entrant fires inside a listener execute immediately, bounded by the depth cap.
  • Zero allocation per fire (after listener-list lookup). The reused ctx is the reason Sig.fire is cheap enough to call from the hot damage path on every bullet hit.

Where to read the source

  • src/starship-survivors/engine/core/signals.ts — the bus, the ctx, the depth cap.
  • src/starship-survivors/engine/combat/damage.tsdamage_dealt / enemy_kill / tagged_kill / boss_kill / boss_body_kill / boss_anchor_destroyed dispatch sites (and the player-damage signals).
  • src/starship-survivors/engine/combat/collision-resolver.tsbullet_hit dispatch (including the legendary secondary-tag re-fire).
  • src/starship-survivors/engine/world/leveling.tslevel_up and kill_streak_milestone dispatch.