Hooks
A hook is an event-typed slot: a host-declared plug-point whose accepts is the event-handler type (application/x-xript-hook), and whose fills are handlers the host calls when the event fires. Hooks are the reverse of bindings — bindings let scripts call the host; an event slot lets the host call scripts. Together, they form a bidirectional communication channel between the application and its mods.
This document covers hook declaration, lifecycle phases, registration, invocation, and the runtime conventions that govern hook behavior. Those conventions are the semantics of the event slot type; everything below applies whether an event is declared as a slot or, for back-compat, in the deprecated standalone hooks section.
The standalone
hooksconcept is deprecated. A lifecycle event is an event-typed slot, and firing it means calling that slot’s fills. Thehooksfield remains allowed (hosts still fire hooks and runtimes still dispatch them), but new manifests should declare events as slots and let mods fill them. The declaration form below documents the back-compat surface; the conventions it describes carry over unchanged to event slots.
Declaration
Section titled “Declaration”A host declares an event as a slot whose accepts is the event-handler type. The deprecated standalone form declares it in the manifest’s hooks section instead:
{ "hooks": { "playerDamage": { "description": "Fired when the player takes damage.", "params": [ { "name": "amount", "type": "number", "description": "Damage amount." }, { "name": "source", "type": "string", "description": "What caused the damage." } ] } }}Either form requires a description. Like bindings, if a modder can’t understand what an event does, it may as well not exist. A mod fills an event slot by naming a handler export ({ "handler": "onPlayerDamage" }); the script-side registration helpers below are the runtime’s convenience over that wiring.
Lifecycle Phases
Section titled “Lifecycle Phases”Hooks can optionally declare lifecycle phases. Phases let scripts intervene at specific points in a host operation:
{ "hooks": { "save": { "description": "Fired during the save lifecycle.", "phases": ["pre", "post", "done", "error"], "params": [ { "name": "data", "type": "SaveData", "description": "The save payload." } ] } }}The four standard phases are:
| Phase | When | Typical Use |
|---|---|---|
pre | Before the operation | Validation, modification, cancellation |
post | After the operation, can modify | Result transformation, interception |
done | After all post-processing, sealed | Logging, notifications, observation |
error | When the operation fails | Error recovery, fallback behavior |
Phases are optional. A hook without phases is a simple notification — it fires, handlers run, done. The host controls which phases it declares and in what order it fires them.
Registration (Script Side)
Section titled “Registration (Script Side)”Scripts register handlers via the hooks global object injected by the runtime.
Simple hooks (no phases):
Section titled “Simple hooks (no phases):”hooks.playerDamage((amount, source) => { log(`Player took ${amount} damage from ${source}`);});Phased hooks:
Section titled “Phased hooks:”hooks.save.pre((data) => { log("About to save: " + data.filename);});
hooks.save.post((data) => { log("Save complete: " + data.filename);});Multiple scripts can register handlers for the same hook (or phase). Handlers run in registration order.
Invocation (Host Side)
Section titled “Invocation (Host Side)”The host fires hooks through the runtime’s fireHook method:
// Simple hookconst results = runtime.fireHook("playerDamage", { amount: 25, source: "trap" });
// Phased hookconst preResults = runtime.fireHook("save", { phase: "pre", data: savePayload });const postResults = runtime.fireHook("save", { phase: "post", data: savePayload });fireHook returns an array of results from all registered handlers, in registration order. If no handlers are registered, it returns an empty array.
Event Delivery
Section titled “Event Delivery”Hook dispatch and the top-level events broadcast catalog are two faces of one dispatch engine: a keyed handler registry plus a fire-from-registry pass. The hooks global, the __xript_hook_handlers registry, and fireHook already are an event bus, merely keyed off manifest.hooks. The events catalog rides that same machinery rather than introducing a parallel event subsystem — the least new vocabulary is one subscribe verb, one host method, and one optional schema field.
Subscription (script side)
Section titled “Subscription (script side)”Alongside the hooks global, the runtime injects an events global. A mod subscribes by event id:
events.on("player.damaged", (event) => { log(`took ${event.amount} damage`);});
events.subscribe("level.loaded", () => { /* alias of events.on */ });The call pushes the handler into a registry keyed by event id (__xript_event_handlers["<id>"]), preserving registration order — exactly like hooks.<name>(fn) pushes into __xript_hook_handlers. Multiple handlers per event id run in registration order.
Delivery (host side)
Section titled “Delivery (host side)”The host emits with emit, the sibling of fireHook:
const results = runtime.emit("player.damaged", { amount: 25, source: "trap" });emit resolves the event id in the events catalog, looks up __xript_event_handlers[id], and invokes each handler — an object payload spreads to positional arguments per the event’s declared payload shape, otherwise the payload is passed as a single argument. Results are collected in registration order; a handler that throws contributes undefined and does not stop the others. This is byte-for-byte the fireHook fan-out contract.
Subscription capability gate
Section titled “Subscription capability gate”An event may declare a capability that gates subscription, reusing the hook gate model:
{ "events": [ { "id": "world.changed", "description": "Broadcast when world terrain mutates.", "capability": "read:world" } ]}events.on checks the event’s declared capability against the granted set via grantedSatisfies (mode-lattice + prefix-subsumption — see Capability Model) before admitting the handler. A subscription denied for lack of capability throws a CapabilityDeniedError at registration time. An event with no capability admits any subscriber.
Capability Gating
Section titled “Capability Gating”Hooks can require capabilities, the same model as bindings:
{ "hooks": { "save": { "description": "Fired during the save lifecycle.", "phases": ["pre", "post", "done", "error"], "capability": "persistence" } }}A script without the persistence capability cannot register handlers for this hook. Attempting to register throws a CapabilityDeniedError.
Async Hooks
Section titled “Async Hooks”Hooks can be declared async, controlled by the host:
{ "hooks": { "dataSync": { "description": "Fired when data synchronization occurs.", "async": true } }}When async is true, handlers can use await and fireHook returns a Promise. The host must use the async runtime variant (initXriptAsync / async-capable createRuntime) for async hooks.
Execution Limits
Section titled “Execution Limits”Each hook handler invocation gets its own execution budget by default. Hooks can override the manifest-level limits:
{ "hooks": { "frameTick": { "description": "Fired every frame.", "limits": { "timeout_ms": 5 } } }}Per-hook limits are useful when different hooks have different performance requirements. A frame tick handler needs a tight timeout; a save handler can take longer.
Error Handling
Section titled “Error Handling”Handler Errors
Section titled “Handler Errors”When a hook handler throws an error, the runtime:
- Catches the error
- Wraps it in a
HookErrorwith the hook name and phase - Logs it via the host console
- Continues executing remaining handlers
One handler’s error does not prevent other handlers from running. The fireHook return value includes results from successful handlers; failed handlers contribute undefined.
Commands do the opposite, deliberately. Swallowing an error is right for a fire-and-forget lifecycle notification and wrong for a user-triggered action, so a failing command handler propagates its error to the caller rather than contributing undefined. See commands.md.
Error Types
Section titled “Error Types”| Error | When Thrown |
|---|---|
HookError | A handler failed during execution |
CapabilityDeniedError | Registration attempted without the required capability |
Hook Granularity
Section titled “Hook Granularity”Filtering and scoping are the host’s responsibility, not the hook system’s. Instead of one save hook with filter parameters, hosts should expose granular hooks when distinction matters:
{ "hooks": { "save": { "description": "Any save operation.", "phases": ["pre", "post", "done", "error"] }, "autosave": { "description": "Automatic background saves.", "phases": ["pre", "post", "done"] }, "manualSave": { "description": "Player-initiated saves.", "phases": ["pre", "post", "done", "error"] } }}Scripts register for what they care about. The host controls granularity by how many hooks it exposes.
Naming Conventions
Section titled “Naming Conventions”Hook names should follow the same conventions as binding names:
- camelCase (
playerDamage,onSave,frameTick) - Noun or event-oriented (
playerDamage,levelComplete,inventoryChange) - Not verbs unless describing an action (
save,loadare fine as they describe the operation the hook wraps)
Manifest-to-TypeScript Mapping
Section titled “Manifest-to-TypeScript Mapping”The typegen tool generates handler registration types from hooks:
Simple hook:
Section titled “Simple hook:”declare namespace hooks { function playerDamage(handler: (amount: number, source: string) => void): void;}Phased hook:
Section titled “Phased hook:”declare namespace hooks { namespace save { function pre(handler: (data: SaveData) => void): void; function done(handler: (data: SaveData) => void): void; function post(handler: (data: SaveData) => void): void; function error(handler: (data: SaveData) => void): void; }}Async hook:
Section titled “Async hook:”Async hooks wrap the handler return type in Promise:
declare namespace hooks { function dataSync(handler: (payload: SyncPayload) => Promise<void>): void;}