engine/effects
PURPOSE — Unified trigger → condition → action pipeline that drives every reactive gameplay procedure on the player ship. Artifacts, passives, and ship mods declare effects as data; this system registers them at run start, listens to engine signals, evaluates conditions, dispatches actions (modifier add/remove, AoE damage, projectile spawn, enemy status, VFX, custom handlers), maintains cooldowns and charges, and ticks auras, timers, counters, and per-frame custom handlers.
OWNS
- The central effect registry — every
EffectInstanceregistered for the current run (definition reference, owner tag, value dict for$varresolution, cooldown remaining, charges used, counter accumulator, timer accumulator, aura active flag, enabled flag). - Indexed dispatch tables — signal-name → instances, aura instance list, timer instance list, counter signal-name → instances, custom-tick instance list.
- The
Siglistener subscriptions registered on the effects priority band, plus the bookkeeping that letsclear()unsubscribe them cleanly at run end. - The signal-context snapshot taken before each action dispatch, guarding against the shared mutable context being overwritten by nested signals fired from within actions.
- Cached references to the current run’s game / ship / world state, refreshed each tick and read by signal handlers, aura ticks, counter listeners, and timer-reset listeners.
- Initialization state — whether listeners have been wired up, used to decide whether
register()should auto-subscribe and whetherrun_starteffects fire immediately. - The action dispatch map (built-in action types → executor functions) and the condition dispatch map (built-in condition types → evaluator functions).
- The two custom-handler registries — signal-triggered custom actions and per-frame custom tick handlers — populated at module load time.
- The standalone collections owned by the custom-handlers module — active flame zones (position, radius, dps, lifetime, damage tick countdown, vfx tick countdown) and active delayed AoE bursts (position, radius, damage, delay, color pair), each with their own max-size cap and oldest-first eviction.
- Enemy status state, lazy-allocated on each enemy — entries by status type with stack count, remaining timer, and per-stack magnitude, plus the legacy stun timer mirror used by enemy AI.
- Parameter resolver — the
$varNamesubstitution that swaps placeholder strings for numbers from the instance value dict. - Telemetry side channel — a per-owner trigger counter on the global window object for gauntlet metric collection.
READS FROM
engine/core/signals— the global signal bus, including its mutable shared context fields that get snapshotted on every fire.engine/core/modifiers— to add stat modifiers, to remove modifiers by source tag for aura deactivation and scaled-stat re-evaluation, and to read the per-target bucket for buff-presence condition checks.engine/core/typesforGameState,ShipState,WorldStateshapes andengine/core/statefor the module-level game/ship/world/camera/canvas-dimension singletons consumed by the custom-handlers tick paths.engine/core/spatial-grid— broad-phase enemy queries for flame-zone damage ticks and delayed AoE detonations.engine/core/clockfor the shared run clock used by flame-zone visual flicker phase.engine/combatviadamageEnemyfor AoE actions, custom-handler damage application, flame-zone DPS ticks, and delayed-AoE detonations.data/artifactsfor the per-artifact effect definitions, color palette, optional proc audio cue, and tier-value lookup used at run-start registration.- The artifact world layer for the active artifact instance list and the artifact-flash setter used by the activation flash action.
engine/vfx— particles, sonar rings, explosion FX, player glow, post-FX rings — consumed by the built-invfxandflash_artifactactions and by custom handlers.engine/audiovia Juice for the per-artifact proc audio cue on activation flash.engine/renderingcamera for screen-space projection in the flame-zone draw pass and the artifact banner pusher for activation banners.- Ship state fields — HP, shield, velocity, position, angle, radius, heat, hpMax/shieldMax bases, weapon list, eid — for condition evaluation, scaled-stat interpolation, action centers, and modifier targeting.
- Game state fields — time, kill streak, upgrade counts, artifacts list, boss arena, current level, stats, reward queue — for condition evaluation and upgrade-count / reward-queue mutation actions.
- Enemy state fields — hp, hpMax, position, velocity, alive flag, dying flag, status bag, stun timer, pack-leader flag, id — for AoE iteration, status application, knockback, and shred multiplier lookup.
PUSHES TO
engine/core/modifiers— stat modifier add formodify_stat, scaled re-application formodify_stat_scaled, direct bucket splice for aura deactivation andremove_modifiers, dirty-set marking after every mutation.engine/core/signals— re-emits via theemit_signalaction and firesplayer_healedfrom the heal action; subscribes/unsubscribes signal listeners on the effects priority band over its full lifecycle.- The world’s player-bullet array —
spawn_projectileand the projectile-spawning custom handlers push fully-formed bullet records (position, velocity, damage, radius, lifetime, color pair, pierce count, hit set, weapon id, archetype tag, homing strength, blast radius, optional range). - The world’s enemy list — direct velocity perturbation for knockback, direct HP subtraction for burning DPS and custom-handler damage fallback, lazy status-bag allocation on apply.
- Ship state — HP and shield mutation for heals, invuln flag and timer for grant-invuln, heat reset, and per-weapon empower mult/shots fields.
- Game state — upgrade-count mutation and reward-queue push for the random-upgrade grant.
engine/combatviadamageEnemyfor AoE damage, custom-handler damage, flame-zone DPS ticks, and delayed-AoE detonations.engine/vfx— particle bursts, sonar rings, shockwaves, explosion FX, player glow flash, starbursts, post-FX impact rings, and the persistent flame-zone gradient draw stack.engine/audiovia Juice for the per-artifact proc audio cue.- The artifact world layer — UI icon flash via the flash setter and the on-screen activation banner via the banner pusher.
- The global window object — per-owner trigger counters for gauntlet metric collection and a per-trigger
console.logline with resolved params for telemetry.
DOES NOT
- Define which effects exist, what their tier values are, or how they map to artifact / passive / mod content — those are data and live in
data/artifactsand (Phase 6) the mod template layer. - Decide which artifacts the player has equipped or own the active artifact instance list — only consumes whatever the artifact world layer hands the run-effects orchestrator at mission start.
- Resolve damage math, knockback math, life steal, shield absorption, affixes, boss caps, or i-frames on its damage actions — those live downstream of
damageEnemy; this system only feeds raw values in. - Implement projectile flight, collision, on-hit, on-death, or behavior callbacks —
spawn_projectileand the spawn-bullet custom handlers only push records onto the player-bullet array; the weapons / bullets path owns everything after spawn. - Render anything itself except the persistent flame-zone gradient stack — every other visual is delegated to the VFX module.
- Define signal names, signal priority bands, or the signal context shape — only consumes the existing
SigAPI and snapshots its context fields. - Drive enemy AI — only mutates status fields and the legacy stun-timer mirror; enemy behaviors read
hasStatus/getStacksto decide what to do. - Compute scaling curves, tier interpolation, or artifact rarity — only consumes the resolved tier value dict supplied at registration.
- Run any logic for installed mod abilities — the mod registration phase in the run-effects orchestrator is a documented placeholder pending the mod-system follow-up.
- Provide a generic event bus —
emit_signalre-emits through the existingSigchannel; there is no second routing layer here.
Signals fired
player_healed— emitted by the heal action when the heal delta is positive.run_start— emitted indirectly via the trigger-type dispatch path the first timeinit()runs (and immediately on mid-run re-registration if the engine is already initialized).- Any signal named by an
emit_signalaction’s params — those re-enter the same bus the engine listens on, so effects can chain.
Signals watched
- Every
signal-trigger effect’s named signal — one bus listener per unique name, multiplexed across all instances watching it. - Every
counter-trigger effect’s counted signal — separate listener that increments per-instance accumulators and dispatches at threshold. - Every
timer-trigger effect’s optional reset signal — separate listener that zeros the timer accumulator and tears down any active aura modifiers attached to that instance.
Entry points
EffectEngine.register— register a batch of effect definitions for an owner with a tier-value dict; indexes by trigger type and auto-subscribes mid-run if already initialized.EffectEngine.unregister— remove every effect for an owner, tear down any active aura modifiers it held, and rebuild the indexes.EffectEngine.init— wire up signal / counter / timer-reset listeners and fire any pendingrun_starteffects.EffectEngine.tick— per-frame cooldown decay, aura interval evaluation with state-transition modifier add/remove, timer accumulation and one-shot or repeat fire, and dispatch of everycustom_tickaction.EffectEngine.clear— unsubscribe every listener, drop every instance, drop the cached state refs, and reset the initialized flag.EffectEngine.getInstances— read-only view of every registered instance.EffectEngine.isInitialized— bridge-side flag query.registerRunEffects— run-effects orchestrator called from the bridge at mission start; clears, registers each active artifact’s effects with its resolved tier values, primes the cached state via a zero-dt tick, and runsinit().teardownRunEffects— bridge-side run-end teardown that delegates toclear().executeAction/executeAllActions— action dispatcher and per-instance loop, called from signal handlers, the aura tick, the timer tick, and the run-start path.evaluateCondition/evaluateAllConditions— condition dispatcher and AND-folded evaluator, called before any action dispatch.resolveParam/resolveAllParams/resolveNumber/resolveString—$varsubstitution against the instance value dict, used by every action and condition that reads params.CustomHandlers/CustomTickHandlers— the two name-keyed handler registries.registerCustomAction/registerCustomTick— module-load-time registration helpers used by the custom-handlers module to wire in named behaviors (battering-ram restore, t-bone shockwave beam, crate-buster pulse, soul-leech ghosts, killstreak rain, lingering-flames zone spawn, random upgrade grant, versatility tracker).applyStatus/hasStatus/getStacks/getShredMult— enemy status read/write API consumed by actions, enemy AI, and the combat damage path.tickStatuses— per-frame status timer decay and burning DPS application, called by the bridge in the enemy update loop.clearStatuses— per-enemy status teardown on death or despawn.spawnFlameZone/tickFlameZones/drawFlameZones/clearFlameZones— persistent fire-patch lifecycle, exposed both for the lingering-flames custom handler and the fire-trail weapon path.spawnDelayedAoE/tickDelayedAoEs/clearDelayedAoEs— fire-and-forget delayed AoE burst lifecycle for thematic legendary echoes.
Pattern notes
- Effects are pure data —
EffectDefcarries a trigger description, condition list, action list, optional cooldown / charges / priority / banner flag. Adding a new artifact behavior is normally a data edit plus, if needed, one new condition or action entry in the dispatch maps. - The system is closed-vocabulary for built-ins (named action and condition types via two dispatch maps) and open-vocabulary for anything that can’t fit data params (the two custom-handler registries keyed by string name).
- Signal context is snapshotted to a local object before any action dispatches, so actions that themselves fire signals can’t corrupt the outer handler’s view of the original event.
- One bus listener per unique signal name — instances multiplex through the per-signal instance list. Listener teardown walks the listener-ref map at
clear()time, with key prefixes distinguishing direct, counter, and timer-reset subscriptions on the same underlying signal name. - Auras run on an interval rather than every frame; state transitions add or remove modifiers via the instance’s owner tag as the source field, and a third “stay active” branch handles scaled-stat re-evaluation by tearing down and re-adding modifiers each interval.
unregisterrebuilds every index from scratch rather than surgically removing entries — fine for the small instance counts a single run accumulates.$varresolution is the only string-typed indirection layer — every other param is a literal number, string, or boolean. Unknown$varkeys throw rather than silently defaulting.- The activation banner is dev-curated via a flag on the definition and only fires for owners tagged
artifact:<id>, so high-frequency procs and auras don’t spam the screen. - Mid-run registration paths exist for both signal-trigger and counter-trigger instances and for
run_start(which fires immediately if the engine is already initialized) — needed for artifacts picked up after mission start. - Custom handlers receive a fully numeric param dict; non-numeric params are silently filtered for
custom_tickand coerced to numbers (boolean → 0/1) forcustom, so handlers can declare their inputs as plainRecord<string, number>. - Flame zones and delayed AoEs are run-scoped collections with hard caps that evict oldest-first; both expose explicit clear functions for run reset and their own draw and tick passes called from the bridge.
- Enemy statuses are stored lazily as a plain record on the enemy entity (cheaper on mobile than a Map). Burning stacks tick damage every frame via dt-scaled DPS; shredded stacks expose a multiplier read by the combat path; stunned mirrors a legacy timer field for the existing AI handlers.
- The
custom_tickaction type is a no-op in the normal action dispatch path — its real work happens in the engine tick loop, which walks the_tickHandlersindex separately so per-frame work doesn’t have to fake a trigger. - Telemetry is two-pronged — every fire logs a
[EFFECT]line with resolved params, and a per-owner counter on the global window object is bumped for gauntlet metric collection.