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 hasx, y, radius, dps, timeLeft, tickTimer, vfxTimer._delayedAoEs: DelayedAoE[]— scheduled AoE bursts pending detonation. Each entry hasx, 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 thegrant_random_upgradehandler (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 viaspawnFlameZone.grant_random_upgrade— queue N random ship-upgrade levelups ontogame.rewardQueue.versatility_tracker— placeholder that applies a refreshable flat percent damage modifier.
READS FROM
SignalSnapshot(./types) —snap.num1/snap.num2are 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.ShipState—ship.x, ship.y, ship.vx, ship.vy, ship.angle, plus(ship as any).radiusand(ship as any).eidfor ring sizing and modifier ownership.WorldState—world.enemiesfor AoE scans and(world as any).playerBulletsfor projectile spawning.GameState—(game as any).rewardQueuefor thegrant_random_upgradeenqueue.enemyGrid(../core/spatial-grid) — broad-phase queries used bytickFlameZonesandtickDelayedAoEs._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) andcamera.zoom— world-to-screen projection fordrawFlameZones, including the off-screen culling check.Clock.now()(../core/clock) — drives the flicker phase indrawFlameZones.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).playerBullets—tbone_shockwave_beamandsoul_leech_ghostspush bullet objects withweaponId: '_artifact'and anarchof'tesla_line'or'projectile'.damageEnemy(../combat/damage) — direct damage calls fromtickFlameZones,tickDelayedAoEs,crate_buster_pulse, andkillstreak_rain. The two crate/streak handlers also use atry/catch require(...)fallback that decrements(e as any).hpdirectly if the import fails.Modifiers.add(../core/modifiers) —versatility_trackerapplies a 5-seconddamagepercent modifier withstackType: 'refresh',maxStacks: 1, source'artifact:versatility_tracker'.(game as any).rewardQueue—grant_random_upgradepushes{ 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
paramsandsnapwere prepared correctly upstream. - Does not validate handler-name collisions itself —
registerCustomActionincustom-registrythrows on duplicate names at registration time. - Does not own the per-frame tick driver.
tickFlameZones,tickDelayedAoEs,clearFlameZones, andclearDelayedAoEsare called fromtickArtifacts()inengine/world/artifacts.ts;drawFlameZonesis called from theengine/bridge.tsdraw block beneath particles and sprites, above terrain. - Does not unify enemy-iteration style.
tickFlameZones,tickDelayedAoEs, andcrate_buster_pulseuse the spatial grid (enemyGrid.query), whilekillstreak_rainandcrate_buster_pulseenemy loop iterateworld.enemiesdirectly. - Does not deduplicate the registered tick handlers (the file uses only
registerCustomAction, notregisterCustomTick). - 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):
| Handler | Triggering signal |
|---|---|
battering_ram_restore | tbone_hit |
tbone_shockwave_beam | tbone_hit |
crate_buster_pulse | crate_break |
soul_leech_ghosts | enemy_kill |
killstreak_rain | kill_streak_milestone |
lingering_flames_zone | damage_dealt (explosion variant) |
grant_random_upgrade | invoked from event-reward flows, no specific signal |
versatility_tracker | invoked 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)andtickDelayedAoEs(dt)— called fromtickArtifacts()inengine/world/artifacts.tseach frame. Iterate zones/AoEs backwards, decrement timers, apply damage on tick boundaries, splice on expiration/detonation.clearFlameZones()andclearDelayedAoEs()— called fromengine/world/artifacts.tson run reset.drawFlameZones(ctx)— called from the draw block inengine/bridge.ts(beneath particles + sprites, above terrain). UsesglobalCompositeOperation = '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 fromlingering_flames_zonehandler,engine/combat/collision-resolver.ts(collision embers, radius 30, duration 0.4),engine/weapons/weapons.ts(fire-trail weapon), andengine/weapons/bullets.ts(landed-shot fire pools).data/weapons/_types.tscalls out the shared flame-zone system in comments.spawnDelayedAoE(x, y, radius, damage, delay, c1?, c2?)— exported. Called fromengine/weapons/bullets.tsfor sparkle echoes, scar pulses, and landed-shot afterbursts (color pairs vary per caller).registerCustomAction(name, fn)— module-load side effect from./custom-registrythat populates theCustomHandlersmap. 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
registerCustomActionrather than being branched in a giant switch. New custom logic adds anotherregisterCustomAction(...)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 ?? defaultso a partially-specified effect entry still runs.countforgrant_random_upgradeis additionallyMath.max(1, Math.floor(...))-guarded. - Snap-or-ship origin. Handlers that need a world position read
snap?.num1 / .num2and fall back toship.x / .y. The fallback covers handlers fired without a positional snapshot. - Bounded queues, oldest-first eviction.
_flameZonesand_delayedAoEsuse 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.
spawnFlameZoneexists so neither thelingering_flamesartifact nor the fire-trail weapon has to reach into the private_flameZonesarray. try/catch requirefor damage.crate_buster_pulseandkillstreak_rainrequire('../combat/damage')inside a try/catch and fall back to raw HP decrement on failure — this differs from the top-levelimport { damageEnemy }used by the flame-zone and delayed-AoE tickers.- Reward-queue handoff for upgrades.
grant_random_upgradedoesn’t apply modifiers directly — it pushes{ type: 'event_reward_upgrade', upgradeIds }ontogame.rewardQueueso the standard dequeue path applies the upgrade and runs the shooting-star animation, serializing with other level-ups. - Flame-zone render envelope.
drawFlameZonesdoes its own off-screen cull (sc.x + reach < 0etc.) before any path work, computes per-zonephasefrom world coords so adjacent patches don’t flicker in sync, and uses two flicker frequencies (6slow,14fast) plus a fade-in (0.15s) / fade-out (0.4s) envelope derived fromtimeLeft. versatility_trackeris a placeholder. The comment marks it as a flat damage boost stand-in rather than a real “track damage types used” implementation.