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)
- Trigger — what activates the effect (a signal, a timer, an aura tick, the run starting, or a counter threshold).
- Condition — zero or more pure read-only checks; all must pass (AND logic). Evaluated in array order, cheapest first, bails on first failure.
- Action — one or more executors that run in order when trigger + conditions pass.
- Bookkeeping — increment
chargesUsed, writecooldownRemaining, 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:
| Type | Activation | Key fields |
|---|---|---|
signal | A named Sig event fires | signal |
run_start | Once, after init() | — |
aura | Every auraInterval seconds (default 0.5s) | auraInterval |
timer | After timerDuration seconds, optionally repeating | timerDuration, timerRepeat, timerResetSignal |
counter | After N occurrences of a signal | counterSignal, 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:
| Field | Purpose |
|---|---|
def | The immutable EffectDef |
owner | Tagged identifier: artifact:<id>, passive:<id>, mod:<id> |
values | Dict for $var param resolution (tier values, etc.) |
cooldownRemaining | Seconds until next allowed fire; decayed each tick |
chargesUsed | Lifetime fire count; gates against def.charges |
counterAccum | Running count for counter triggers |
timerAccum | Accumulated seconds for timer and aura triggers |
auraActive | Whether aura modifiers are currently applied |
enabled | Kill 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 typeaura._timers— instances with trigger typetimer._counters— signal name to counter instances._tickHandlers— instances with anycustom_tickaction (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:
- The listener captures
Sig._ctxinto aSignalSnapshotonce via_snapshotCtx()— name, uid1/uid2, num1/num2, str1. - 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):
inst.enabled— kill switch.cooldownRemaining > 0— bail.chargesUsed >= maxCharges(whendef.charges >= 0) — bail.evaluateAllConditions()— all conditions ininst.def.conditionsmust return true.executeAllActions()— run every action in order.- Increment
chargesUsed, setcooldownRemaining(resolves$varcooldowns), 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.
Banner throttle
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:
- Cooldown decay — every
_instancesentry decrementscooldownRemainingtoward zero. - Aura tick — for each
_aurasentry, accumulatedt; whentimerAccum >= auraInterval, evaluate conditions:- Pass + not active → execute actions (applies modifiers), mark
auraActive. - Fail + active → remove modifiers tagged with
ownerfromModifiers._modsByTarget, clearauraActive. - Pass + active → re-execute (lets
modify_stat_scaledrecompute values that depend on heat/speed/HP).
- Pass + not active → execute actions (applies modifiers), mark
- Timer tick — each
_timersentry accumulatesdt; ontimerAccum >= timerDuration, fires via_tryExecute(). IftimerRepeatis false the instance is disabled (one-shot done). - Custom tick — each
_tickHandlersentry runs everycustom_tickaction’s named handler fromCustomTickHandlers, with all numeric params resolved againstinst.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 throughModifiers.add()withsource = ownerso 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+_empowerShotson 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 inCustomHandlers),custom_tick(no-op in dispatch — ticked separately byEffectEngine.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:
EffectEngine.clear()wipes all instances and unsubscribes everySig.on()listener.- For each
ArtifactInstanceinactiveArtifacts,EffectEngine.register(def.effects, 'artifact:<id>', tierValues)builds the instances and indexes them. - A zero-dt
EffectEngine.tick()caches the game/ship/world refs so signal listeners and run-start fires have state to read. EffectEngine.init()registers oneSig.on()per signal name, plus counter listeners and timer-reset listeners, then fires everyrun_startinstance 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.