Skip to content

Pack Lua Hooks

Kurt edited this page Jul 26, 2026 · 10 revisions

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: only regen is actually consulted today. to_hit, soak, and xp_for are defined but not wired — a formula override for them has no effect yet.

There is no on_tick_lua, no on_depleted_lua, and no on_resolve Lua variant beyond the one above. The names on_apply / on_expire / on_tick / on_resolve / on_depleted without the _lua suffix are declarative op-lists, not Lua. A damage-over-time tick or a death hook can only be an op-list. on_reaction is 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 closed EventKind set in §5.
  • on_world(event, fn) / on_region(event, fn) — registered inside an entity's lua: trigger block; fire on a director scope broadcast. Binding: ev = the broadcast payload table. The write side is signal_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_signal is delivered every non-boss.died signal, including engine-internal ones (content.reload.audit, content.pull.result). Guard on your own event names; never write a catch-all/else branch 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.set refuses engine-reserved keys (the schedule:* prefix); use any other key freely.
  • director.broadcast refuses 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 allowed field is 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. spawn fires from a zone reset: the ordinary ephemeral repop and the persistent path that loads a durable object from object_instances. So a durable scripted mob wanders unprompted just like an ephemeral one. A mob created by mud.spawn from inside another script does not get spawn — arm it at the call site or in the spawned mob's top-level body.
  • Fires before later into ops, and parent-first. A spawn handler runs the instant the mob is placed — before a separate spawn_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 in on("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 traverse hook 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 on ev.exit to 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 an exits key: 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 as other — the initiative-roll checkpoint), OnAffectBlocked (an incoming affect vetoed by grants_immunity), and the rest trio — OnRest (kind-agnostic, always fires) plus the specific OnShortRest / OnLongRest (the kind rides a distinct event kind, never the mag argument, which doubles as an amount multiplier and would silently double a handler's heal).
  • Reserved (in the enum, not yet fired — a handler simply never runs): OnApplyAffect, OnAffectTick, OnAffectExpire.
  • Progression kinds: OnTrackStep, OnLevel, OnSkillUse fire 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.

Clone this wiki locally