Effect Engine

The effect engine is the unified runtime for every triggered gameplay effect — artifacts, ship passives, and ship mod abilities all flow through the same registry, dispatcher, and tick loop. Content authors describe what happens with data; the engine turns that data into signal subscriptions, per-frame condition checks, and queued actions.

Source: engine/effects/effect-engine.ts, types.ts, conditions.ts, actions.ts, run-effects.ts.

Pipeline

Every effect is the same four-stage shape:

Trigger → Condition → Action → (cooldown/charges/banner)
  1. Trigger — what activates the effect (a signal, a timer, an aura tick, the run starting, or a counter threshold).
  2. Condition — zero or more pure read-only checks; all must pass (AND logic). Evaluated in array order, cheapest first, bails on first failure.
  3. Action — one or more executors that run in order when trigger + conditions pass.
  4. Bookkeeping — increment chargesUsed, write cooldownRemaining, optionally push a banner, log a telemetry line.

EffectDef (types.ts) is the immutable content shape: id, trigger, conditions[], actions[], optional cooldown, charges (default unlimited), priority (default 50, the effects band), showBanner.

Trigger types

TriggerDef.type is one of five values:

TypeActivationKey fields
signalA named Sig event firessignal
run_startOnce, after init()
auraEvery auraInterval seconds (default 0.5s)auraInterval
timerAfter timerDuration seconds, optionally repeatingtimerDuration, timerRepeat, timerResetSignal
counterAfter N occurrences of a signalcounterSignal, counterThreshold, counterResets

timerResetSignal lets a timer be re-armed by a separate event (and tears down any aura-style modifiers it had applied). counterResets defaults to true — counters zero on fire.

EffectInstance state

Each registered effect produces one EffectInstance holding mutable runtime state:

FieldPurpose
defThe immutable EffectDef
ownerTagged identifier: artifact:<id>, passive:<id>, mod:<id>
valuesDict for $var param resolution (tier values, etc.)
cooldownRemainingSeconds until next allowed fire; decayed each tick
chargesUsedLifetime fire count; gates against def.charges
counterAccumRunning count for counter triggers
timerAccumAccumulated seconds for timer and aura triggers
auraActiveWhether aura modifiers are currently applied
enabledKill switch — skips all evaluation when false

Registry indexes

effect-engine.ts keeps instances in five parallel collections so dispatch never iterates the full list:

  • _instances — all instances (used for cooldown decay, run-start scan, unregister).
  • _bySignal — signal name to instances watching that signal.
  • _auras — instances with trigger type aura.
  • _timers — instances with trigger type timer.
  • _counters — signal name to counter instances.
  • _tickHandlers — instances with any custom_tick action (per-frame work).

_listenerRefs tracks the Sig.on() callbacks the engine registered so clear() can unsubscribe cleanly.

Signal dispatch

One Sig.on() listener is registered per unique signal name, at priority 50 (the effects band). When the signal fires:

  1. The listener captures Sig._ctx into a SignalSnapshot once via _snapshotCtx() — name, uid1/uid2, num1/num2, str1.
  2. It iterates _bySignal.get(signalName) and calls _tryExecute() for each watcher.

The snapshot step is load-bearing: actions may emit nested signals, which would overwrite the shared Sig._ctx mid-iteration. Snapshotting first means every effect on this firing sees the same context, even after actions re-enter the signal bus.

Counter signals get a separate listener (__counter__<signal>) that increments counterAccum and only fires _tryExecute() when the threshold is reached. Timer-reset signals get a third listener variant (__timer_reset__<signal>) that zeroes timerAccum and tears down any active aura modifiers.

_tryExecute gate

Every fire path (signal listener, counter listener, timer expiry, aura activation, run start) funnels through _tryExecute(inst, snap, game, ship, world):

  1. inst.enabled — kill switch.
  2. cooldownRemaining > 0 — bail.
  3. chargesUsed >= maxCharges (when def.charges >= 0) — bail.
  4. evaluateAllConditions() — all conditions in inst.def.conditions must return true.
  5. executeAllActions() — run every action in order.
  6. Increment chargesUsed, set cooldownRemaining (resolves $var cooldowns), optionally push an artifact banner.

A telemetry line is logged on every fire showing owner, fire count, resolved action params, and trigger source. A global window.__effectTriggerCounts map increments per owner for gauntlet metrics.

When def.showBanner is true and the owner is tagged artifact:<id>, the engine calls pushArtifactBanner(<id>, game.time) on each fire. The banner module throttles to one banner per artifact per 0.5s, so high-frequency procs naturally collapse to a single visible cue. Dev-curated — leave showBanner off for per-hit procs, auras, and run_start.

Per-frame tick

EffectEngine.tick(dt, game, ship, world) runs four passes each frame from bridge.ts:

  1. Cooldown decay — every _instances entry decrements cooldownRemaining toward zero.
  2. Aura tick — for each _auras entry, accumulate dt; when timerAccum >= auraInterval, evaluate conditions:
    • Pass + not active → execute actions (applies modifiers), mark auraActive.
    • Fail + active → remove modifiers tagged with owner from Modifiers._modsByTarget, clear auraActive.
    • Pass + active → re-execute (lets modify_stat_scaled recompute values that depend on heat/speed/HP).
  3. Timer tick — each _timers entry accumulates dt; on timerAccum >= timerDuration, fires via _tryExecute(). If timerRepeat is false the instance is disabled (one-shot done).
  4. Custom tick — each _tickHandlers entry runs every custom_tick action’s named handler from CustomTickHandlers, with all numeric params resolved against inst.values.

tick() also writes the _cachedGame / _cachedShip / _cachedWorld refs that signal listeners read. The cache is the same-frame state; signal handlers never look at stale data because tick runs before the frame’s signals fire.

Conditions

conditions.ts registers a flat CONDITION_MAP keyed by string type. Built-ins covered today:

  • Signal context: random, signal_str1_eq, signal_str1_neq, signal_num1_gte, damage_tag_eq.
  • Ship state: hp_below, hp_above, shield_active, shield_broken, shield_empty, has_buff, heat_above, speed_above, speed_below.
  • Game state: has_artifact, has_upgrade, kill_streak_above, elapsed_above, elapsed_below, tier_at_least, boss_active, boss_phase_gte.

evaluateAllConditions() short-circuits on the first failure — cheapest checks (random rolls, str1 compares) should appear before expensive ones (modifier-bucket scans, enemy-list traversal).

Actions

actions.ts registers a flat ACTION_MAP keyed by string type. Categories:

  • Stats: modify_stat, modify_stat_scaled, modify_upgrade_count, remove_modifiers. All route through Modifiers.add() with source = owner so aura teardown can find them.
  • Healing / defense: heal (flat / percentMax / percentMissing), heal_shield, grant_invuln, set_heat.
  • Damage: damage_aoe (linear falloff, ship- or signal-centered), apply_knockback.
  • Spawn: spawn_projectile (count, spread, pierce, homing, blast).
  • Status: apply_enemy_status (signal-targeted), apply_status_aoe (radius).
  • Weapons: empower_weapon (random, slot_0, or all — sets _empowerMult + _empowerShots on the weapon).
  • Signal bus: emit_signal (fans out a new event with resolved params).
  • VFX / feedback: flash_artifact (artifact-coloured ring + particles + glow + optional audio cue), vfx (sonar_ring, particles, starburst, dual_ring, player_glow, burst_and_ring).
  • Escape hatches: custom (named entry in CustomHandlers), custom_tick (no-op in dispatch — ticked separately by EffectEngine.tick()).

All action params support $var substitution via resolve-params.ts, which reads inst.values so artifacts can scale by tier without separate code paths.

Lifecycle

run-effects.ts orchestrates per-run wiring:

  1. EffectEngine.clear() wipes all instances and unsubscribes every Sig.on() listener.
  2. For each ArtifactInstance in activeArtifacts, EffectEngine.register(def.effects, 'artifact:<id>', tierValues) builds the instances and indexes them.
  3. A zero-dt EffectEngine.tick() caches the game/ship/world refs so signal listeners and run-start fires have state to read.
  4. EffectEngine.init() registers one Sig.on() per signal name, plus counter listeners and timer-reset listeners, then fires every run_start instance through _tryExecute().

Mid-run registration (e.g., picking up a new artifact) calls register() after _initialized = true, which auto-subscribes the relevant listeners and immediately fires run_start effects from the new owner.

unregister(owner) removes all instances tagged with owner from _instances, tears down any active aura modifiers, and rebuilds the five indexes from the survivors. Simpler than surgical removal for the small instance counts the game runs with.

teardownRunEffects() is just EffectEngine.clear() — called from bridge.ts on mission end.