Custom Action Handler

Escape hatch for effects whose behavior cannot be expressed via the data-driven core action types (modify_stat, damage_aoe, spawn_projectile, etc.). When an EffectDef.actions entry has type: 'custom', the effect engine looks up a named CustomActionFn in the CustomHandlers registry and invokes it instead of running a built-in action.

Where it lives

  • Type: CustomActionFn in engine/effects/types.ts
  • Registry: CustomHandlers: Record<string, CustomActionFn> in engine/effects/custom-registry.ts
  • Registration helper: registerCustomAction(name, fn) — throws on duplicate name
  • Implementations: engine/effects/custom-handlers.ts (one registerCustomAction(...) call per handler at module load)

A parallel CustomTickHandlers registry exists for per-frame type: 'custom_tick' actions (same pattern, different signature).

Signature

type CustomActionFn = (
  snap: SignalSnapshot | null,
  params: Record<string, number>,
  game: GameState,
  ship: ShipState,
  world: WorldState,
) => void;
  • snap — captured signal context (uid1, uid2, num1, num2, str1) at dispatch time, or null for non-signal triggers. num1/num2 are typically the event position; handlers fall back to ship.x/ship.y when the snapshot is absent.
  • params — the ActionDef.params block, with $var references already resolved against EffectInstance.values.
  • game, ship, world — full mutable game-state references. Handlers may mutate any of them (spawn bullets into world.playerBullets, queue rewards on game.rewardQueue, apply modifiers via Modifiers.add, etc.).

Return value is ignored. Side effects only.

Dispatch flow

  1. Effect engine fires the trigger and runs conditions.
  2. For each action with type: 'custom', it reads params.handler (the registered name).
  3. It looks up the function in CustomHandlers[name] and calls it with the snapshot + resolved params + game/ship/world.
  4. Handler is responsible for its own damage, VFX, spawning, and cleanup.

When to use it

Use a custom handler only when no core action type fits. Examples currently in custom-handlers.ts:

  • battering_ram_restore — read/rewrite ship.vx/ship.vy after collision (no built-in for velocity preservation).
  • tbone_shockwave_beam — push a bespoke tesla_line bullet onto world.playerBullets with custom pierce/range fields.
  • crate_buster_pulse — AoE damage scaled by enemy hpMax with distance falloff (the built-in damage_aoe is flat).
  • soul_leech_ghosts — radial volley of homing ghost bullets at a kill position.
  • killstreak_rain — N random off-ship explosion locations within a spread.
  • lingering_flames_zone — registers a persistent flame zone via spawnFlameZone() plus ignition VFX. The zone itself is ticked separately by tickFlameZones(dt) from the artifact tick loop, with damage and rendering owned by this module.
  • grant_random_upgrade — pushes an event_reward_upgrade onto game.rewardQueue so the standard level-up animation runs.
  • versatility_tracker — placeholder that adds a stacking damage modifier via Modifiers.add.

If the behavior is just “deal damage”, “apply a modifier”, or “spawn a projectile with standard fields”, prefer the built-in action — keep custom handlers for the genuinely bespoke.

Authoring rules

  • Register at module load with registerCustomAction(name, fn). Duplicate names throw.
  • Read every tunable from params with a default fallback (params.damage ?? 30). No magic numbers in the body.
  • Use snap for event-positioned effects (kill location, crate position, impact point); fall back to ship position when snap is null.
  • Drive your own VFX — the engine does not auto-emit anything for custom actions. Standard primitives: Particles, SonarRings, ExplosionFX, AoeExplosion, PostFx.
  • For long-lived state (zones, delayed AoEs), keep the array module-local with a MAX cap and an oldest-first eviction, expose tick*() and clear*() from the module, and have the artifact tick loop call them. See _flameZones and _delayedAoEs for the pattern.
  • On run reset, call the module’s clear*() to drop lingering state.

Used by

Artifacts with bespoke trigger behavior (battering ram, soul leech, crate buster, killstreak rain, lingering flames, versatility tracker, ram-shockwave variants, the random-upgrade event reward). New artifacts should reach for a custom handler only after confirming no core action type fits.