custom-handlers.ts

PURPOSE

The named-handler escape hatch for the effect engine. When an effect action has type: 'custom', the engine looks up params.handler in the CustomHandlers registry (from ./custom-registry) and invokes the matching function. This module is where those functions are defined and registered at module load time.

Also hosts two shared subsystems that custom handlers (and other code paths) need: persistent flame zones (lingering fire patches that tick damage + render flame VFX over time) and delayed AoE bursts (single-shot AoE damage scheduled to detonate after a delay). Both are kept here so artifacts, weapons, and the bridge can spawn them without each owner having its own zone list.

Each registerCustomAction(name, fn) call follows the signature (snap, params, game, ship, world) => void.

OWNS

  • _flameZones: FlameZone[] — active lingering-fire patches for the current run. Each zone has x, y, radius, dps, timeLeft, tickTimer, vfxTimer.
  • _delayedAoEs: DelayedAoE[] — scheduled AoE bursts pending detonation. Each entry has x, y, radius, damage, delay, c1, c2.
  • Tuning constants: FLAME_DPS_TICK = 0.25 (damage tick interval), FLAME_VFX_TICK = 0.06 (VFX emit interval), FLAME_MAX_ZONES = 16 (cap, oldest dropped), DELAYED_AOE_MAX = 64 (cap, oldest dropped).
  • HORIZONTAL_IDS — the modifier-id pool used by the grant_random_upgrade handler (health, shield, speed, heat, damage_all, damage_bullet, damage_energy, damage_fire, damage_bomb).
  • Exported functions: spawnFlameZone, tickFlameZones, clearFlameZones, drawFlameZones, spawnDelayedAoE, tickDelayedAoEs, clearDelayedAoEs.
  • Registered custom action handlers (all named via registerCustomAction):
    • battering_ram_restore — on tbone_hit, retain pre-collision speed with optional bonus.
    • tbone_shockwave_beam — on tbone_hit, spawn a forward piercing beam projectile.
    • crate_buster_pulse — on crate_break, AoE with linear close-to-far falloff scaled to enemy max HP.
    • soul_leech_ghosts — on enemy_kill, spawn N homing ghost projectiles at the kill location.
    • killstreak_rain — on kill_streak_milestone, spawn N random explosion strikes around the ship.
    • lingering_flames_zone — on damage_dealt (explosion), spawn a persistent flame zone via spawnFlameZone.
    • grant_random_upgrade — queue N random ship-upgrade levelups onto game.rewardQueue.
    • versatility_tracker — placeholder that applies a refreshable flat percent damage modifier.

READS FROM

  • SignalSnapshot (./types) — snap.num1 / snap.num2 are read as the event position (kill x/y, crate x/y, explosion x/y) for handlers that need a world point distinct from the ship.
  • ShipStateship.x, ship.y, ship.vx, ship.vy, ship.angle, plus (ship as any).radius and (ship as any).eid for ring sizing and modifier ownership.
  • WorldStateworld.enemies for AoE scans and (world as any).playerBullets for projectile spawning.
  • GameState(game as any).rewardQueue for the grant_random_upgrade enqueue.
  • enemyGrid (../core/spatial-grid) — broad-phase queries used by tickFlameZones and tickDelayedAoEs.
  • _moduleGame, _moduleShip, _moduleWorld, camera, W, H (../core/state) — module-level state and screen dimensions. The flame-zone and delayed-AoE tickers reach into module state directly because they’re called from the artifact tick loop without receiving game/ship/world.
  • Camera.toS (../rendering/camera) and camera.zoom — world-to-screen projection for drawFlameZones, including the off-screen culling check.
  • Clock.now() (../core/clock) — drives the flicker phase in drawFlameZones.
  • params (per handler) — handler-specific tuning fields with defaults (e.g. speedRetain, bonusPct, damage, speed, width, lifetime, range, pctClose, pctFar, radius, count, dps, duration, dmgPerType).

PUSHES TO

  • _flameZones / _delayedAoEs — own internal arrays, trimmed to their caps.
  • (world as any).playerBulletstbone_shockwave_beam and soul_leech_ghosts push bullet objects with weaponId: '_artifact' and an arch of 'tesla_line' or 'projectile'.
  • damageEnemy (../combat/damage) — direct damage calls from tickFlameZones, tickDelayedAoEs, crate_buster_pulse, and killstreak_rain. The two crate/streak handlers also use a try/catch require(...) fallback that decrements (e as any).hp directly if the import fails.
  • Modifiers.add (../core/modifiers) — versatility_tracker applies a 5-second damage percent modifier with stackType: 'refresh', maxStacks: 1, source 'artifact:versatility_tracker'.
  • (game as any).rewardQueuegrant_random_upgrade pushes { type: 'event_reward_upgrade', upgradeIds: picks }. The dequeue path is what actually applies and animates the upgrades.
  • VFX modules — Particles.add / .burstHex / .muzzleFlash / .starBurst, SonarRings.spawn / .shockwave, ExplosionFX.big, AoeExplosion.spawn, PostFx.spawn('impact_ring', ...).

DOES NOT

  • Does not gate by signal type or owner — the effect engine has already routed the call by the time a handler runs. Handlers trust params and snap were prepared correctly upstream.
  • Does not validate handler-name collisions itself — registerCustomAction in custom-registry throws on duplicate names at registration time.
  • Does not own the per-frame tick driver. tickFlameZones, tickDelayedAoEs, clearFlameZones, and clearDelayedAoEs are called from tickArtifacts() in engine/world/artifacts.ts; drawFlameZones is called from the engine/bridge.ts draw block beneath particles and sprites, above terrain.
  • Does not unify enemy-iteration style. tickFlameZones, tickDelayedAoEs, and crate_buster_pulse use the spatial grid (enemyGrid.query), while killstreak_rain and crate_buster_pulse enemy loop iterate world.enemies directly.
  • Does not deduplicate the registered tick handlers (the file uses only registerCustomAction, not registerCustomTick).
  • Does not cap or schedule the level-up animation in grant_random_upgrade — that lives on the reward-queue dequeue path.

Signals

Handler-to-signal mapping (as documented in the section banners and matched to the effect engine’s signal vocabulary):

HandlerTriggering signal
battering_ram_restoretbone_hit
tbone_shockwave_beamtbone_hit
crate_buster_pulsecrate_break
soul_leech_ghostsenemy_kill
killstreak_rainkill_streak_milestone
lingering_flames_zonedamage_dealt (explosion variant)
grant_random_upgradeinvoked from event-reward flows, no specific signal
versatility_trackerinvoked on the relevant tracking signal; applies the modifier on each call

snap.num1 / snap.num2 are read as the event position (kill x/y, crate x/y, explosion x/y) when the snap is present; handlers fall back to ship.x / ship.y otherwise.

Entry points

  • tickFlameZones(dt) and tickDelayedAoEs(dt) — called from tickArtifacts() in engine/world/artifacts.ts each frame. Iterate zones/AoEs backwards, decrement timers, apply damage on tick boundaries, splice on expiration/detonation.
  • clearFlameZones() and clearDelayedAoEs() — called from engine/world/artifacts.ts on run reset.
  • drawFlameZones(ctx) — called from the draw block in engine/bridge.ts (beneath particles + sprites, above terrain). Uses globalCompositeOperation = 'lighter' to additively stack three radial gradients (outer haze, mid orange ring, hot yellow core) per zone, with two flicker phases and a fade-in/hold/fade-out envelope.
  • spawnFlameZone(x, y, radius, dps, duration) — exported. Called from lingering_flames_zone handler, engine/combat/collision-resolver.ts (collision embers, radius 30, duration 0.4), engine/weapons/weapons.ts (fire-trail weapon), and engine/weapons/bullets.ts (landed-shot fire pools). data/weapons/_types.ts calls out the shared flame-zone system in comments.
  • spawnDelayedAoE(x, y, radius, damage, delay, c1?, c2?) — exported. Called from engine/weapons/bullets.ts for sparkle echoes, scar pulses, and landed-shot afterbursts (color pairs vary per caller).
  • registerCustomAction(name, fn) — module-load side effect from ./custom-registry that populates the CustomHandlers map. The effect engine reads the map by handler name at signal-dispatch time.

Pattern notes

  • Named-handler registry, not a switch. Each handler is independently exported via registerCustomAction rather than being branched in a giant switch. New custom logic adds another registerCustomAction(...) block at the bottom of the file; the registry rejects duplicate names. This keeps the effect engine’s data-driven action loop unchanged while letting one-off logic land here.
  • Params with defaults. Every handler destructures params.field ?? default so a partially-specified effect entry still runs. count for grant_random_upgrade is additionally Math.max(1, Math.floor(...))-guarded.
  • Snap-or-ship origin. Handlers that need a world position read snap?.num1 / .num2 and fall back to ship.x / .y. The fallback covers handlers fired without a positional snapshot.
  • Bounded queues, oldest-first eviction. _flameZones and _delayedAoEs use plain arrays capped by constants. When the cap is hit, Array.shift() drops the oldest entry before pushing the new one. No pooling.
  • VFX is part of the handler contract. Every handler emits its own particle bursts, sonar rings, and shockwaves alongside its gameplay effect — handlers are not pure-gameplay functions that delegate FX elsewhere.
  • Two shared subsystems hosted in one module. Flame zones and delayed AoEs are kept here (not split into their own files) because the artifact tick loop calls them together and several callers (artifact handler, weapons, collision resolver) need the same primitives. spawnFlameZone exists so neither the lingering_flames artifact nor the fire-trail weapon has to reach into the private _flameZones array.
  • try/catch require for damage. crate_buster_pulse and killstreak_rain require('../combat/damage') inside a try/catch and fall back to raw HP decrement on failure — this differs from the top-level import { damageEnemy } used by the flame-zone and delayed-AoE tickers.
  • Reward-queue handoff for upgrades. grant_random_upgrade doesn’t apply modifiers directly — it pushes { type: 'event_reward_upgrade', upgradeIds } onto game.rewardQueue so the standard dequeue path applies the upgrade and runs the shooting-star animation, serializing with other level-ups.
  • Flame-zone render envelope. drawFlameZones does its own off-screen cull (sc.x + reach < 0 etc.) before any path work, computes per-zone phase from world coords so adjacent patches don’t flicker in sync, and uses two flicker frequencies (6 slow, 14 fast) plus a fade-in (0.15s) / fade-out (0.4s) envelope derived from timeLeft.
  • versatility_tracker is a placeholder. The comment marks it as a flat damage boost stand-in rather than a real “track damage types used” implementation.