Pack Lua Scripting
Audience: Builder Status: ✅ Ready
When static YAML can’t express what you want — a spell that rolls its own dice, a mob that
remembers who it greeted, a boss that reacts to a world event — you attach Lua. TelosMUD runs
pack Lua inside a tightly sandboxed, per-zone virtual machine with a curated API, so you can
script rich behavior without touching the engine or risking the runtime. This page teaches the
authoring model: where Lua attaches, what the sandbox gives and denies you, the handle/global
API, and how self.state persists. The itemized list of which hooks and triggers exist is in
Pack Lua Hooks; the engine internals are in
Lua Sandbox Internals.
How Lua attaches: inline fields, no .lua files
All pack Lua is authored as inline string fields in YAML — there are no separate .lua
files. The loader copies each named field into the engine and compiles it lazily, once per zone,
on first use (never at load time). Two kinds of attach point:
- Entity trigger blocks — the
lua:field on a room, mob, or item prototype. The block is a script that callson(event, fn)to register handlers, and can keep per-instance state inself.state. - Named Lua fields on def-tables — a specific field whose value is a Lua body the engine runs at a defined moment.
The complete set of attach points:
| YAML field | On | What the Lua does |
|---|---|---|
lua |
mob / room / item prototype | Trigger block: on(event, fn) handlers + self.state. |
on_resolve_lua |
ability | Runs at the ability’s resolve step. |
on_apply_lua / on_expire_lua / on_dispel_lua |
affect | Affect lifecycle hooks. |
on_event_lua |
resource, affect | Map of engine-event-name → handler. |
on_reaction_lua |
resource, affect | Map of reaction-checkpoint → handler (receives rx). |
on_roll |
loot table | Conditional-drop hatch returning item refs. |
pvp_lua |
pack manifest | PvP consent policy (actor, target) → bool. |
formulas.<name> |
pack manifest | Named ruleset formula returning a number. |
render |
display def | Sheet template returning a string (uses the ui toolkit). |
lua |
custom command | A verb body (self, arg). |
Honest gap: the declarative op-list hooks
on_apply/on_expire/on_tick/on_resolve/on_depletedare not Lua — they are the op-list siblings. There is noon_tick_luaand noon_depleted_lua: a damage-over-time tick or a pool-depletion hook (which on avitalpool is also the death hook) can only be an op-list, not a Lua body. See Pack Lua Hooks for the full live-vs-reserved catalog.
The sandbox in one breath
Everything below is enforced by the engine; you don’t opt in. The design goal is that a hostile or buggy script can waste at most a few milliseconds of its own zone and never escape, never corrupt state, and never take down the server.
- One VM per zone, single-threaded. Each zone has its own Lua state, called only from that zone’s goroutine. There is no shared global VM and no cross-zone Lua.
- Stripped, then allowlisted stdlib. The VM starts with no standard library; the engine
then copies back only a curated allowlist. Available:
assert,error,pcall,xpcall,select,type,tostring,tonumber,pairs,ipairs,unpack,print(redirected to structured logging), and read-onlystring,table, andmathnamespaces. Writing to those namespaces raises “attempt to modify a read-only table.” - Removed entirely (not hidden — genuinely absent):
os,io,debug,require,load/loadstring/dofile,coroutine,getmetatable/setmetatable,rawget/rawset,_G/_ENV,string.dump. There is no way to reach the host, the filesystem, other zones, or the VM’s own environment. - Deterministic randomness.
math.randomis rebound to a per-zone seeded RNG (seed derived from the zone id) andmath.randomseedis a no-op. This makes combat and scripts reproducible for tests and replays. Prefermud.random/mud.roll(below) for game rolls. - No real time, no sleeping, no goroutines.
mud.now()is a deterministic pulse counter, not wall-clock time.mud.after(pulses, fn)schedules a callback on the zone timer wheel, run inline on the zone goroutine — there is nosleepand no way to spawn concurrency.
Budgets and the circuit breaker
Every Lua invocation runs through a single chokepoint that enforces per-call limits, and a per-script circuit breaker catches sustained misbehavior:
- Per-call budgets: an instruction budget (~100,000 instructions — a runaway loop raises “instruction budget exceeded”), a 5 ms wall-clock deadline (catches a low-instruction stall the count can’t), and call-stack / value-stack caps. A nested Lua call (e.g. a script whose harm op fires an event handled by more Lua) reuses the parent’s budget — you cannot re-nest to escape your limit. Those two numbers are defaults an operator can retune per deployment, so don’t hard-code an assumption about them; they also move together, since a budget that can’t be reached inside the deadline is rejected as a pair.
- A per-call string-allocation budget. Building strings costs against a budget for the whole call,
not just per operation — so ten thousand individually-legal concatenations are bounded too. The idiom
that hits this is the quadratic accumulator (
s = s .. piecein a loop, which re-copies everything each time); build a table andtable.concatit once instead, which is charged once. Aborting on this budget is treated as script misbehaviour by the circuit breaker, not as transient host load. - Amplification caps:
string.rep/format/gsub/find/match/gmatchandtable.concatreject over-cap output before allocating (output cap 1 MiB, pattern-input cap 64 KiB), so a single call can’t allocation-bomb the box. - Spawn/timer caps:
mud.spawnis capped per call (64) and per-zone live census (1024);mud.afterlive timers cap at 256. - Circuit breaker: each script has a weighted error budget. A logic error costs more than an instruction-budget abort, which costs more than a deadline (deadlines are weighted very light so host load can’t quarantine a correct script). Success decays the budget, so only a sustained failure rate trips it. When it trips, that script is disabled (its invocations become no-ops) — never the zone. Entity trigger scripts are keyed per-instance (one buggy mob is quarantined, not its whole prototype); shared defs (ability/affect/formula/policy) are keyed per ref, so a broken shared def stops content-wide by design. A successful hot reload resets it.
Error isolation and fail-closed defaults
Every run is pcall-isolated: a runtime error fizzles just that one action and goes to the ops
log (never to a player) — unless a staff member is watching with debug on, in which case it’s
echoed to the zone. A compile error leaves the def inert, so no-Lua content still boots. When
a script that’s supposed to return a value breaks, the engine falls back safely: a broken
formula → engine default; a broken pvp_lua → deny; a broken render → the built-in sheet;
a broken loot on_roll → no drops.
The API surface
Scripts manipulate the world only through validated handles and a few read-only global
tables. You never get a raw pointer to an entity; a handle carries an internal id, and every
method re-resolves it to a live entity in the current zone — a dead, departed, cross-zone, or
stale handle is a safe no-op. This is why storing h:id() and re-resolving later is the correct
pattern, and storing a handle in self.state is rejected (see below).
Which handles you receive depends on context: self (the scripted/acting entity),
ctx.actor / ctx.target / ctx.room (abilities), ev.actor / ev.other (triggers/events),
actor / target (PvP policy and formulas), ctx.looter / ctx.source (loot), plus any handle
a traversal method returns.
Entity handle methods
Read / identity (return nil/false/0 on a stale handle):
h:id()→ runtime id number ·h:name()/h:short()→ display nameh:attr(name)→ derived attribute (unknown ⇒ 0) ·h:level()→ the level attribute (0 if none)h:resource(ref)/h:resource_max(ref)→ current / max poolh:has_affect(ref)→ bool ·h:affect_magnitude(ref)→ number ·h:has_flag(name)→ bool (Living flags)h:has_room_flag(name)→ bool — a room content flag (on a room handle). Distinct fromhas_flag, which reads a Living’s flags; room flags aren’t Living flags, so use this one to test a room’s authored flags (e.g. a landmark).h:has_visible_creature()→ bool (on a room handle) — a deliberately coarse presence-only disclosure primitive: is there a visible mob here? It never returns a name, count, identity, or handle, and is heavily gated, failing closed on any miss: usable only from a display render (a mechanics script already hash:occupants()/mud.scan); both the target room and the viewer’s anchor room must carry the contentopen_sightflag (so disclosure is builder-declared and confined to the terrain a pack opts in);canSee-filtered (wizinvis/concealment stay hidden); and mobs only, never a player — so it can never become a PvP position-tracker. It does not re-open the foreign-room anti-scry:h:occupants()still discloses nothing for a room the viewer isn’t in.h:room()→ the location room handle, ornil·h:coord()→ the room’s authored grid position{x, y, z}, ornilfor a room with no coord (the same field GMCPRoom.Infoexposes)h:long()→ the entity’s long/room description texth:toggle(ref)→ the viewer’s on/off state for a contenttoggle_defspreference (a player-set value — never a trust signal)
Traversal (return handle-arrays or bools):
h:contents()→ contained items (unfiltered in a mechanics call; canSee-filtered in a display render of another container)h:equipment()→ worn items ·h:equipment_slots()→{slot, flag, item}arrayh:exits()→{dir, ref, to}array (tois a handle only for a same-zone destination)h:occupants()→ living occupants, viewer-canSee-filtered, viewer excludedh:room_items()→ coalesced ground items{item, name, long, count}h:can_see(other)→ routes the engine visibility chokepoint
Communication (no state writes; markup is sanitized; blocked inside a display render):
h:send(markup)→ to the entity’s own player sessionh:act(tmpl, obj?, vict?, to?)→ perspective template (to∈actor/victim/room/room_except_actor)h:say(text)·h:emote(text)
Effect ops — the harm surface. Each routes the engine’s existing gated funnel; the actor, source, and disposition are engine-set from the invocation, never a Lua argument:
h:damage{amount=, type=, can_avoid=, resource=}→ routesdealDamage(gate-checked first); returns the amount applied.resourcepicks which pool to damage — omit it to hit the primary vital; a pool the target has no capacity for is natural immunity (returns 0)h:heal(resource, amount)→ helpful, ungated raiseh:modify_resource(resource, delta)→ gated cross-player writeh:drain(resource, amount, to?)→ gated drain + ungated credith:apply_affect(id, {duration=, magnitude=, stacks=})(asource=key is ignored) ·h:remove_affect(id)·h:dispel{category=, count=}→ gatedh:move(dir)→ same-zone walk (fires leave hooks / opportunity actions); cross-zone is a no-op. Forcing another player is harm-gated (self / a mob / a consenting player is ungated) — this is the safe co-move primitive for a content-authored mount or grapple-drag; the full mount/follow link model is a separate epich:teleport(roomHandle)/h:recall()→ same-zone only; forcing a non-consenting player is harm-gated
Global tables
mud (per-zone utilities):
mud.random()/(n)/(m,n),mud.roll("2d6"|"4dF"|"3d6kh2"…)— the per-zone RNGmud.now()— deterministic pulse counter ·mud.log(level, msg)— logs are capped (~1 KB/line), rate-limited, and labeledsource=builder_lua; a script that floods the log every call is quarantined by the circuit breaker exactly like any other runaway, so use it for genuine diagnostics, not high-volume tracing.print(redirected to the same structured log) and the director’sdirector.logare bounded identically.mud.zone()— the live zone id of the running script. Inside an instance this is<template>#<random>, not the template ref, so filter a fan-out event on it rather than comparing against an authored zone namemud.send_to_instance(target, template)— mint/enter a private copy of an instanceable zone. For an unconditional door, prefer a declaredinstance_entrancesentry on the room instead: a room’s ownentertrigger can’t call this, because for a room trigger the invoking actor is the room, not the entrant. See Building Instanced Zones. Self-only by design: the target must be the invoking actor. The harm gate short-circuits on a non-player actor before the safe-room veto, so allowing a third-party target would let any mob script drag a non-consenting player somewhere nobody can see or help them. Note what “invoking actor” means in practice: a custom command or an ability acts as the player, but anon("enter")/on("greet")trigger acts as the room or mob, so a trigger cannot send the entrant in. Fire-and-forget —truemeans accepted for dispatch, not arrived. See Building Instanced Zonesmud.scan(room)→ content handles (display-filtered) ·mud.broadcast(room, markup)mud.spawn(proto, room)→ new handle (destination must be a room; player-controlled protos rejected; spawn-capped)mud.after(pulses, fn, {durable=?})→ timer handle on the zone wheel (durable=truesurvives a hot reload) ·mud.cancel(timer)mud.pvp_allowed(a, b)→ read-only gate querymud.fire("<pack>:Name", subject, data?)→ fire a namespaced custom event (bare names rejected — see Pack Lua Hooks)- Reserved stubs:
mud.transform(h, proto)(returns the handle unchanged) andmud.summon(h)(returns false) — safe to call, no effect yet.
world / region (supra-zone, read-only replica reads + a durable write surface):
world.flag(name)→ bool ·world.get(name)→ value/nilregion:get(name)→ value/nil(nilon a region-less zone) ·region.id()→ ref/nilsignal_region(event, payload?)/signal_world(event, payload?)→ enqueue a durable scoped event up to the director (the write surface). Engine-reserved event names are refused — signal-up and broadcast-down share one subject per scope, so an unguardedscope.state.setwould have been delivered to every zone’s read-replica as though a director had written it, bypassing the director’s lease and CAS entirely. Pick your own event names;boss.diedand other legitimate signal-up names are unaffected. See Scoped Event Bus.
ui (display-sheet toolkit — pure formatting, no world state):
ui.sheet([width|"full"]) returns a chainable sheet with :row(cells[,aligns]),
:rows(list, mapFn), :span(text[,align]), :divider(fill), :banner(text[,fill]), and
:render() → string. Aligns are left / right / center.
screen (full-screen / ANSI builder): screen.frame() returns a builder with :clear(),
:home(), :at(row,col), :color(name), :write(text), :show(). Coordinates are clamped and
colors come from a fixed allowlist.
gmcp: gmcp.send(playerHandle, "Mud.Package", table?) emits a content-namespaced GMCP frame;
the package name must pass a fail-closed allowlist (you cannot spoof Char.* / Core.*). See
GMCP Reference.
rx (reaction context) is passed only to reaction hooks and is documented with them in
Pack Lua Hooks.
Reserved/placeholder handle methods (safe to call, not yet real):
h:group()(always empty — no party model),h:is_enemy(other)(placeholder: “both living and co-located”),h:distance(other)(0 same / 1 co-located / 999 otherwise — not a real room-graph metric).
self.state: per-instance persistence
Every scripted entity gets a self.state table (also bound as the bare global state) — one per
entity instance, for keeping memory between handler calls. Its lifetime depends on what owns
it:
- Mobs and items:
self.stateis in-memory and transient — dropped when the entity leaves the world (like resets). Don’t expect a mob to remember across a reboot. - Players:
self.stateis durably persisted to the character record (ascriptsubtree of the character’s state JSON), saved on dump, restored on login, and carried across shard handoff. This is how a player can carry, say, a quest counter. - Across a hot reload: the handler code is swapped but the
self.statedata is preserved — a reload doesn’t wipe a scripted entity’s memory.
Guard your top-level initializers against reload. A hot reload re-runs the whole registration body (the code outside your
on(...)handlers) against the preservedstatetable — that is how youron(...)handlers get re-registered with the new code. So a bare top-level assignment likestate.greeted = {}re-executes on every reload and clobbers the live value, silently resetting memory you meant to keep. Always initialize idempotently with theoridiom so the assignment is a no-op when the key already exists:state.greeted = state.greeted or {} -- keeps existing data across a reload -- NOT: state.greeted = {} -- wipes it on every reloadThis applies to any top-level
state.x = ...seed (counters, flags, tables): writestate.x = state.x or <default>. Assignments inside a handler are fine — they run per event, not at registration.
The marshaller is a trust boundary. Only plain data crosses to storage. A function, closure,
userdata, or handle stored in self.state is rejected at save with a clean error naming
the bad key path — never silently dropped, never persisted as a pointer. Store h:id() and
re-resolve, never the handle itself. Caps: 64 KiB of JSON, depth 16, 4096 keys; a corrupt or
hostile stored row degrades to an empty table on load, and load reconstructs a plain table only —
it never executes code or resurrects a handle.
Worked examples (from the demo pack)
A greeter mob that remembers each visitor, using on("greet") + per-instance state
(zones/00-midgaard/30-mobs.yaml):
state.greeted = state.greeted or {} -- idempotent: survives a hot reload (see the note above)
on("greet", function(ev)
local id = ev.actor:id()
if not state.greeted[id] then
state.greeted[id] = true
self:say("Welcome, " .. ev.actor:name() .. "! Take what you need from the smithy north.")
end
end)
A scripted spell rolling its own dice and dealing gated damage — Lightning Bolt
(abilities.yaml):
local dmg = 0
for i = 1, 5 do dmg = dmg + math.random(1, 6) end
ctx.target:damage{amount = dmg, type = "fire"}
A boss firing a world-scoped event on death — goblin chief (zones/01-darkwood.yaml):
on("death", function(ev)
signal_world("boss.died", {ref = "boss:warden", boss = "goblin-chief", zone = "darkwood"})
end)
A score sheet built with the ui toolkit — display surface (display_defs.yaml):
local s = ui.sheet()
s:banner("{{FG_CYAN}}" .. self:name() .. "{{RESET}}", "=")
s:row({"Level", tostring(self:level())}, {"left", "right"})
s:divider("-")
s:row({"{{FG_GREEN}}Health{{RESET}}", self:resource("hp") .. "/" .. self:resource_max("hp")}, {"left", "right"})
return s:render()
More examples — reactions (rx:cancel, rx:modify), room-scoped affects, world-event reactors —
are shown alongside the hooks they use in Pack Lua Hooks.