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:
CustomActionFninengine/effects/types.ts - Registry:
CustomHandlers: Record<string, CustomActionFn>inengine/effects/custom-registry.ts - Registration helper:
registerCustomAction(name, fn)— throws on duplicate name - Implementations:
engine/effects/custom-handlers.ts(oneregisterCustomAction(...)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, ornullfor non-signal triggers.num1/num2are typically the event position; handlers fall back toship.x/ship.ywhen the snapshot is absent.params— theActionDef.paramsblock, with$varreferences already resolved againstEffectInstance.values.game,ship,world— full mutable game-state references. Handlers may mutate any of them (spawn bullets intoworld.playerBullets, queue rewards ongame.rewardQueue, apply modifiers viaModifiers.add, etc.).
Return value is ignored. Side effects only.
Dispatch flow
- Effect engine fires the trigger and runs conditions.
- For each action with
type: 'custom', it readsparams.handler(the registered name). - It looks up the function in
CustomHandlers[name]and calls it with the snapshot + resolved params + game/ship/world. - 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/rewriteship.vx/ship.vyafter collision (no built-in for velocity preservation).tbone_shockwave_beam— push a bespoketesla_linebullet ontoworld.playerBulletswith custom pierce/range fields.crate_buster_pulse— AoE damage scaled by enemyhpMaxwith distance falloff (the built-indamage_aoeis 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 viaspawnFlameZone()plus ignition VFX. The zone itself is ticked separately bytickFlameZones(dt)from the artifact tick loop, with damage and rendering owned by this module.grant_random_upgrade— pushes anevent_reward_upgradeontogame.rewardQueueso the standard level-up animation runs.versatility_tracker— placeholder that adds a stackingdamagemodifier viaModifiers.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
paramswith a default fallback (params.damage ?? 30). No magic numbers in the body. - Use
snapfor event-positioned effects (kill location, crate position, impact point); fall back to ship position whensnapis 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
MAXcap and an oldest-first eviction, exposetick*()andclear*()from the module, and have the artifact tick loop call them. See_flameZonesand_delayedAoEsfor 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.