Skip to content

Hooks

Hooks are bindings run backwards: the host fires them, scripts handle them. Bindings let scripts call the host; hooks let the host call scripts. Put the two together and you have a bidirectional 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.

A host declares an event as a slot whose accepts is the event-handler type:

{
"slots": [
{
"id": "playerDamage",
"accepts": ["application/x-xript-hook"]
}
]
}

A mod fills that slot by naming a handler export in its mod manifest’s fills surface, keyed by the slot id:

{
"fills": {
"playerDamage": [{ "handler": "onPlayerDamage" }]
}
}

The deprecated standalone form declares the event in the manifest’s hooks section instead, carrying its params and other properties inline:

{
"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. Same rule as bindings: if a modder can’t tell what a hook does, it may as well not exist. The script-side registration helpers below are the runtime’s convenience over the { "handler": "..." } wiring.

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:

PhaseWhenTypical Use
preBefore the operationValidation, modification, cancellation
postAfter the operation, can modifyResult transformation, interception
doneAfter all post-processing, sealedLogging, notifications, observation
errorWhen the operation failsError recovery, fallback behavior

Phases are optional. A hook without phases is a plain notification: it fires, handlers run, done. The host controls which phases it declares and the order it fires them in.

Scripts register handlers via the hooks global object injected by the runtime.

hooks.playerDamage((amount, source) => {
log(`Player took ${amount} damage from ${source}`);
});
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.

The host fires hooks through the runtime’s fireHook method:

const results = runtime.fireHook("playerDamage", { amount: 25, source: "trap" });
const 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.

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.

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.

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.

When a hook handler throws an error, the runtime:

  1. Catches the error
  2. Wraps it in a HookError with the hook name and phase
  3. Logs it via the host console
  4. 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.

ErrorWhen Thrown
HookErrorA handler failed during execution
CapabilityDeniedErrorRegistration attempted without the required capability

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", "on", "post", "error"] },
"autosave": { "description": "Automatic background saves.", "phases": ["pre", "post", "done"] },
"manualSave": { "description": "Player-initiated saves.", "phases": ["pre", "on", "post", "error"] }
}
}

Scripts register for what they care about. The host controls granularity by how many hooks it exposes.

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, load are fine as they describe the operation the hook wraps)

The typegen tool generates handler registration types from hooks:

declare namespace hooks {
function playerDamage(handler: (amount: number, source: string) => void): void;
}
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 hooks wrap the handler return type in Promise:

declare namespace hooks {
function dataSync(handler: (payload: SyncPayload) => Promise<void>): void;
}