Pack Lua Hooks
Audience: Builder Status: ✅ Ready
This is the catalog a builder scans to answer “what can I hook?” It lists every Lua hook,
reaction checkpoint, entity trigger, and event kind — when each fires, what it binds, and what a
handler is allowed to do — plus honest notes on what looks hookable but isn’t. For the authoring
model (the sandbox, the handle API, self.state) read Pack Lua Scripting
first; the engine internals are in Lua Sandbox Internals.
There are four families of hook: lifecycle hooks (a named Lua field on a def), event-bus
hooks (subscribe to an engine event about an entity), the reaction lane (alter an in-flight
result at a checkpoint), and entity triggers (on("...") in a lua: block).
1. Lifecycle hooks (named Lua fields on defs)
Each is a specific field whose Lua body runs at a defined moment.
| Hook (YAML field) | Fires when | Bindings | A handler may |
|---|---|---|---|
on_resolve_lua (ability) |
the ability resolves, beside its op-list | self = caster; ctx.actor = caster, ctx.target, ctx.room, ctx.mag |
full harm/heal via handles; inherits the cast’s gate disposition + cascade budget |
on_apply_lua (affect) |
the affect attaches | self = affected; harm actor = affect source |
harm ops gated “may applier harm target”; clean root cascade |
on_expire_lua (affect) |
the affect expires | same | same |
on_dispel_lua (affect) |
the affect is dispelled | same | same |
on_roll (loot table) |
loot resolves, per looter | ctx.looter, ctx.source |
read-only decision: return {"item:ref", …} (capped 64); the caller delivers |
pvp_lua (pack manifest) |
the PvP gate is queried | actor, target |
must return true to permit; fail-closed deny |
formulas.<name> (pack manifest) |
a ruleset formula is consulted | self, target? |
return a number; fallback to the engine default |
render (display def) |
a display command runs | self = viewer (+ a list for collections) |
a pure function → string; any side-effecting op raises mid-render. Surfaces: score, inventory, equipment, room, who |
lua (custom command) |
a player types the verb | self = actor, arg = argument tail |
clean root; compose gated ops |
formulas— live vs reserved: onlyregenis actually consulted today.to_hit,soak, andxp_forare defined but not wired — a formula override for them has no effect yet.
There is no
on_tick_lua, noon_depleted_lua, and noon_resolveLua variant beyond the one above. The nameson_apply/on_expire/on_tick/on_resolve/on_depletedwithout the_luasuffix are declarative op-lists, not Lua. A damage-over-time tick or a death hook can only be an op-list.on_reactionis not a lifecycle hook — it’s the reaction lane (§3).
2. Event-bus hooks
Subscribe a Lua body to an engine event fired about an entity.
on_event_lua(on a resource or affect def) — a map of engine-event-name → handler. The event fires about the entity that has that resource/affect. Bindings:self= subject,ev.other= counterpart,ev.mag= magnitude. Runs under the firing cascade’s shared budget; harm ops still funnel the gate. The subscribable event kinds are the closedEventKindset in §5.on_world(event, fn)/on_region(event, fn)— registered inside an entity’slua:trigger block; fire on a director scope broadcast. Binding:ev= the broadcast payload table. The write side issignal_world(...)/signal_region(...)(see Pack Lua Scripting). Scope semantics: Scoped Event Bus, Orchestration & Directors.
The world-director script
The hooks above run inside a zone. Cross-zone orchestration — reacting to an event one zone raised by writing world state and telling every zone about it — runs one level up, in the world director, via a pack-level world_script (a Pack MUD Setting). It defines a single function, on_signal(event, payload), called once per signal-up event the director consumes, and it talks through a director host table rather than the zone’s mud/self surface:
director.* |
Effect |
|---|---|
get(key) |
read a world-scope-state value |
set(key, value) |
write world-scope state |
broadcast(event, payload) |
fan a down-event to the member zones (they react with on_world) |
log(msg) |
structured log line |
-- pack.yaml: world_script: | ...
function on_signal(event, payload)
if event == "boss_slain" then -- ALWAYS guard on your own event name
local boss = (type(payload) == "table" and payload.boss) or "an unknown foe"
director.set("last_boss_slain", boss) -- DERIVED write — idempotent under redelivery
director.broadcast("world_announce",
{ text = "The " .. boss .. " has fallen before the realm's heroes!" })
end
end
Four rules, all enforced (the same “untrusted content” posture as zone Lua, applied to the control plane):
on_signalis delivered every non-boss.diedsignal, including engine-internal ones (content.reload.audit,content.pull.result). Guard on your own event names; never write a catch-all/elsebranch that would fire on engine events.- Be idempotent. Delivery is at-least-once, so a redelivery re-runs
on_signal— write derived values, never a blind+1, or the event double-counts. director.setrefuses engine-reserved keys (theschedule:*prefix); use any other key freely.director.broadcastrefuses engine-reserved events (scope.state.set,content.*) — you cannot forge a state-set that bypasses the director’s single-writer path.
It runs in the shared sandbox (same caps, chokepoint, and breaker as a zone). Only the world director runs a script today; region-director scripts are a follow-up. Engine mechanics: Orchestration & Directors → world-director script.
3. The reaction lane
Reaction hooks alter an in-flight result at a named checkpoint. They receive ev and a
reaction context rx, whose capabilities are a closed allowlist per checkpoint — a call
outside the allowlist is a silent no-op. Reactions can be attached two ways: on_reaction_lua (a
map on a resource/affect def) or an on("<Checkpoint>", fn) trigger on a player/mob’s lua:
block.
| Checkpoint | Fires about | rx may |
|---|---|---|
BeforeCastCommit |
each observer when a cast begins (Counterspell) | rx:cancel() only |
ToHit |
the defender before a swing’s to-hit roll (Shield) | rx:modify("ac", …) only |
OnDamageTaken |
the damage-taker (concentration / damage-shield / redirect) | rx:modify("amount", …) (reduce-only), rx:cancel(), rx:replace_target(handle) |
The rx methods:
rx:cancel()— veto the in-flight action (only where the checkpoint permits).rx:modify(field, delta)— nudge a pending numeric result; the allowedfieldis checkpoint-specific (ToHit→ only"ac";OnDamageTaken→ only"amount", reduce-only).rx:replace_target(handle)— redirect a harmful blow; re-runs the harm gate against the new target.rx:consume_resource(ref, n?)— spend a reaction-economy pool (routes the gated funnel); returns a bool.
Semantics: Combat System, Abilities & Effects.
4. Entity on("...") triggers
Registered inside a room/mob/item lua: block via on(event, fn). In every trigger self is
bound, harm ops are attributed to self and gated, and a handler error fizzles just that fire
(per-instance circuit breaker).
| Trigger | Fires when | ev binding |
|---|---|---|
spawn |
this entity is reset-spawned + placed in its room | ev empty (self is bound) |
enter |
someone enters the room entity | ev.actor = entrant |
greet |
someone enters — on each scripted mob in the room | ev.actor = entrant |
leave |
someone departs (on the room, before detach) | ev.actor = leaver |
witness_leave |
someone departs — on each other scripted mob in the room | ev.actor = leaver, ev.dir = exit taken |
traverse |
someone is about to leave via an exit — on the room, before the move commits | ev.actor = mover, ev.exit = exit key; cancellable (below) |
death |
a scripted entity dies (before corpse/reap) | ev.actor = killer (may be nil) |
speech |
someone says text — on each scripted mob in the room | ev.actor = speaker, ev.text |
any engine event kind (OnHit, BeforeCastCommit, ToHit, …) |
that event fires about this entity | ev / rx per event |
world:<event> / region:<event> |
a director scope broadcast | ev = payload |
<pack>:<Name> |
a custom event (mud.fire) about this entity |
ev.mag, ev.data, ev.other? |
The spawn hook and wandering mobs
Entity scripts build lazily — a mob’s lua: body normally doesn’t run until a trigger first fires on
it (i.e. until a player reaches it). That is fine for reactive mobs, but a mob that should act on its own
(a wanderer arming a mud.after loop) would sit inert until someone found it. on("spawn") closes that gap:
it fires the moment a reset-spawned entity is placed, so its loop arms immediately.
on("spawn", function()
local function step()
local opts = {}
for _, e in ipairs(self:room():exits()) do
if type(e.to) ~= "string" then opts[#opts + 1] = e.dir end -- same-zone exits only (a cross-zone exit's `to` is a bare ref string)
end
if #opts > 0 then self:move(opts[math.random(1, #opts)]) end
mud.after(20, step) -- re-arm ~5s later
end
mud.after(20, step)
end)
Three things to know:
- Reset path only — but both reset paths.
spawnfires from a zone reset: the ordinary ephemeral repop and the persistent path that loads a durable object fromobject_instances. So a durable scripted mob wanders unprompted just like an ephemeral one. A mob created bymud.spawnfrom inside another script does not getspawn— arm it at the call site or in the spawned mob’s top-level body. - Fires before later
intoops, and parent-first. A spawn handler runs the instant the mob is placed — before a separatespawn_item … into: <mob>reset op arms it with loot, and before its own contents’ handlers, so a nested scripted child always spawns under a fully-spawned parent. Don’t assume reset-placed inventory is present inon("spawn"). (This is one contract across both paths: “spawn fires when placed, don’t assume inventory yet.”) - Roaming spawns need
roam: true(see below), or the repop will leak.
Chasing: witness_leave
The room’s leave trigger fires on the room and carries no direction, so a mob can’t use it to
follow a fleeing player. witness_leave fires on each other scripted mob left behind, and carries both
ev.actor (who left) and ev.dir (the exit they took) — everything a chaser needs:
on("witness_leave", function(ev)
self:move(ev.dir) -- follow the fleer
end)
Fired from both the walk path and the combat-flee path (a player escaping combat can only leave via
flee, not move), so a chaser follows a genuinely fleeing player. It fires after the mover has left,
so ev.actor:room() is their destination (where they fled to), and self:move(ev.dir) follows them
there. A chaser that leaves its spawn room should use a roam: true reset (below) so the repop doesn’t leak a
replacement while it’s away.
Gating movement: the cancellable traverse hook
A room’s traverse trigger fires before a move commits — ahead of the local / intra-shard / cross-shard branch split — so content can gate, veto, or reroute a departure. It has three verbs:
on("traverse", function(ev)
if ev.exit == "warded" and not ev.actor:has_flag("proven") then
return block("A spectral warden bars the way.") -- cancel with a message
end
-- return false -- cancel with the default "you can't go that way"
-- redirect("down") -- send the mover through a different exit instead
end)
Three properties are load-bearing:
- It fails open. No handler, an unscripted room, a quarantined script, or a runtime error all allow the move — a buggy gate can never imprison a player. So a
traversehook is a content convenience, not a hard security boundary. redirect(dir)is engine-controlled and bounded. It re-runs the move through a different exit (re-firing the hook — guard onev.exitto avoid a loop) under a redirect budget, so there is no harm-gated teleport and no unbounded recursion. A redirect can legitimately cross a zone/shard boundary. Crucially, it can name only anexitskey: an instance entrance is reachable by a player’s own typed direction alone (the depth-0 move), so a redirect naming an entrance key misses both maps and yields “you can’t go that way” — preserving that security invariant.- A post-hook re-check abandons the move if the hook relocated or killed the mover — defense-in-depth on the single move funnel.
The demo’s darkwood Warded Sanctum ships this as the canonical gate-on-condition: a warded named exit blocked by the warden unless the mover carries the proven flag.
roam: true — zone-wide population for a wanderer
A normal reset tops a spawn back up to max per spawn room. A wandering mob leaves that room, so a
room-scoped count would see the room empty every repop and spawn a replacement — an unbounded leak. Mark a
roaming spawn roam: true and the repop counts the prototype across the whole zone, so one roamer
anywhere satisfies the reset:
resets:
- {op: spawn_mob, proto: darkwood:mob:wisp, room: darkwood:room:grove, count: 1, roam: true}
5. The closed EventKind set (and the custom lane)
The engine owns the event set — content subscribes to these kinds but cannot define a new bare kind. The full enum:
OnCheck, OnAbilityResolved, OnHit, OnDamageTaken, OnKill, OnLeaveRoom,
BeforeCastCommit, OnEnterCombat, OnAffectBlocked, OnRest, OnShortRest, OnLongRest,
OnApplyAffect, OnAffectTick, OnAffectExpire, OnEnter, ToHit, OnTrackStep, OnLevel,
OnSkillUse.
Fire status (honest):
- Live (actually fired today):
OnCheck,OnAbilityResolved,OnHit,OnDamageTaken,OnKill,OnLeaveRoom,BeforeCastCommit,OnEnter,ToHit,OnEnterCombat(on entering a fight, opponent asother— the initiative-roll checkpoint),OnAffectBlocked(an incoming affect vetoed bygrants_immunity), and the rest trio —OnRest(kind-agnostic, always fires) plus the specificOnShortRest/OnLongRest(the kind rides a distinct event kind, never themagargument, which doubles as an amount multiplier and would silently double a handler’sheal). - Reserved (in the enum, not yet fired — a handler simply never runs):
OnApplyAffect,OnAffectTick,OnAffectExpire. - Progression kinds:
OnTrackStep,OnLevel,OnSkillUsefire from the track/ability machinery.
The custom-event lane (implemented)
Any event name containing a colon is a content-namespaced custom kind — "<pack>:Name". You
fire one with mud.fire("<pack>:Name", subject, data?) and handle it with the subject’s
on("<pack>:Name", fn) trigger. Custom events run through the same depth (max event depth 8),
width (max 256 handlers), and zone recursion budgets as engine events, and the same harm gate
— they carry no privileged status. A bare (colon-less) unknown name is rejected. This is the
supported way to build your own event vocabulary.
Worked custom-event flow — a boss signals the world, a herald reacts and spawns a follow-up
(zones/01-darkwood.yaml):
-- on the boss:
on("death", function(ev)
signal_world("boss.died", {ref = "boss:warden", boss = "goblin-chief", zone = "darkwood"})
end)
-- on a herald elsewhere:
on_world("spawn.boss", function(ev)
if ev.zone ~= "darkwood" then return end
mud.spawn(ev.proto, self:room())
if ev.announce and ev.announce ~= "" then
mud.broadcast(self:room(), ev.announce)
end
end)
Reaction examples — Counterspell (rx:cancel) and Shield (rx:modify("ac", 5)) — from the same
file:
on("BeforeCastCommit", function(ev, rx)
if not state.countered then state.countered = true; rx:cancel() end
end)
on("ToHit", function(ev, rx)
if not state.shielded then state.shielded = true; rx:modify("ac", 5) end
end)
Reserved / stub — do not build against these yet
Flagged as reserved so you don’t waste effort: the formulas names to_hit / soak / xp_for
(defined, not wired — only regen is consulted); the reserved event kinds OnRest,
OnApplyAffect, OnAffectTick, OnAffectExpire (never fired yet); and the stub globals/methods
mud.transform, mud.summon, h:group(), h:is_enemy(), h:distance() (see
Pack Lua Scripting).
Related deep-dives: Abilities & Effects, Combat System, Scoped Event Bus.