PURPOSE
Backing store for the unified effect system’s named code-callbacks. Effects whose behavior cannot be expressed as data-driven trigger / condition / action params declare an action.type of custom or custom_tick and reference a handler by name string; this registry is the lookup table that resolves those names to functions. Splitting the maps and the registration functions out of custom-handlers.ts lets actions.ts and effect-engine.ts import the lookup tables without pulling in every handler implementation as a dependency.
OWNS
CustomHandlers: Record<string, CustomActionFn>— module-level map from handler name to signal-triggered action function. Mutable, populated at import time. Starts empty.CustomTickHandlers: Record<string, CustomTickFn>— module-level map from handler name to per-frame tick function. Mutable, populated at import time. Starts empty.registerCustomAction(name, fn)— adds aCustomActionFnundername. ThrowsDuplicate custom action handler: <name>ifnameis already present.registerCustomTick(name, fn)— adds aCustomTickFnundername. ThrowsDuplicate custom tick handler: <name>ifnameis already present.
READS FROM
./types—CustomActionFnandCustomTickFnfunction-signature types (for the map value types and the register-function parameter types).
This file performs no runtime reads — its only inputs are the values passed to the two register functions.
PUSHES TO
The two exported maps are read by the rest of the effect system:
effects/actions.ts—actCustomlooks upCustomHandlers[handlerName]to execute acustomaction. ThrowsUnknown custom action handler: <name>on miss.effects/effect-engine.ts— the per-frametickloop readsCustomTickHandlers[handlerName]for everycustom_tickaction on registered effects. Silently skips on miss.effects/index.ts— re-exports both maps and both register functions as the public surface of the effects barrel.
DOES NOT
- Does not define any handlers itself. All handler bodies live in
effects/custom-handlers.ts. - Does not resolve
$varparams, thehandleraction param, or snapshot capture — those happen at the call sites inactions.tsandeffect-engine.ts. - Does not gate by owner, charges, cooldown, or
enabledflag — those are the effect engine’s responsibility. - Does not provide an unregister, override, or clear API. Once a name is registered for the lifetime of the module, it is fixed; the duplicate-name throw enforces this.
- Does not validate the function signature beyond TypeScript’s compile-time check, and does not assert the name is non-empty or matches any naming convention.
- Does not initialize itself with a built-in handler set; the maps are empty until
custom-handlers.tsis imported (which happens via the effects barrel).
Signals
This file does not subscribe to or emit any Sig signals. Signals reach registered custom-action handlers via the call site in actions.ts, which forwards a SignalSnapshot argument captured by the effect engine before action dispatch. Custom tick handlers receive dt instead of a snapshot.
Entry points
registerCustomAction(name: string, fn: CustomActionFn): void— called once per signal-triggered handler at module-load time fromcustom-handlers.ts.registerCustomTick(name: string, fn: CustomTickFn): void— called once per per-frame handler at module-load time fromcustom-handlers.ts.CustomHandlers[name]— read byactCustominactions.tsduring action execution.CustomTickHandlers[name]— read by the engine tick loop ineffect-engine.tsonce per frame per registeredcustom_tickaction.
The CustomActionFn signature is (snap: SignalSnapshot | null, params: Record<string, number>, game: GameState, ship: ShipState, world: WorldState) => void. The CustomTickFn signature is (dt: number, params: Record<string, number>, game: GameState, ship: ShipState, world: WorldState) => void. Both signatures are owned by types.ts.
Pattern notes
- Two categories, two maps. The split exists because the call paths differ:
customflows through the action-executor pipeline (snapshot, conditions, cooldown, charges all already applied), whilecustom_tickis pulled by the engine’s tick loop every frame on a separate_tickHandlerslist. Mixing them in one map would lose that dispatch-time distinction. - Registration happens at module-import time as a side effect of importing
custom-handlers.ts. There is no explicitinit()step. The effects barrelindex.tsis responsible for ordering imports so the handlers are present before the first effect runs. - Duplicate-name throws are intentional and load-bearing. They turn a typo or copy-paste mistake into a hard startup error rather than a silently overridden handler. There is no production case where overwriting a registered handler is correct, so no override API is provided.
- Handlers cannot be removed. The maps live for the lifetime of the process; per-run lifecycle is managed by the effect engine’s instance list, not by mutating these maps.
- The
handleraction param is the lookup key. Action authors pick a string in the effect data file (data/artifacts/<id>.ts, etc.) andactions.ts/effect-engine.tsstrip it from the resolved param dict before calling the handler. Handler code therefore never sees its own name inparams. - Param resolution is the caller’s job. Both call sites walk
action.params, run them throughresolveNumberagainstinst.values(the$vartable), coerce booleans to0/1, and pass the resultingRecord<string, number>to the handler. Non-numeric params are dropped silently — handlers receive only numeric params. - Miss behavior diverges by category:
actCustomthrows on an unknown name (treats it as a content bug), while the tick loop silently skips. The asymmetry is deliberate — a missing action handler is a defect, but a missing tick handler is treated as a no-op so a partially-loaded build still ticks. - This is the only place in the effect system that holds executable code keyed by string. Conditions and built-in actions are dispatched by
switchinconditions.tsandactions.ts; only thecustom/custom_tickescape hatch routes through a name lookup.