Building Instanced Zones
Audience: Builder Status: ✅ Ready
An instance is a private, runtime-minted copy of a zone you authored — the classic dungeon or party copy. A player asks to go in, the engine builds a fresh copy of your zone’s content just for them, and it is reaped when they leave. Two parties in “the crypt” are in two different crypts and cannot see each other.
This page is what a content author needs: the opt-in, the one Lua verb, the filtering idiom, and — most importantly — the list of things that deliberately do not work inside a copy. The engine-side design rationale lives in Instanced Zones.
Related: Pack Entity Reference (the instanceable field),
Pack Lua Scripting (the mud table), Pack Lua Hooks.
Opting in: instanceable: true
A zone is not instanceable by default. You opt in on the zone definition:
zones:
- ref: crypt
name: The Forgotten Crypt
start_room: crypt:room:entrance # REQUIRED for an instanceable zone — see below
instanceable: true
Without the flag, a mint of that zone is refused at the sink and the player is told the way fails to open. The flag is checked every time, not just at load.
Why it is opt-in, and what you are agreeing to
The opt-in exists because a mint runs your zone’s full boot reset list. Every item and mob the zone declares is created fresh in each copy. A player alone in a private copy can strip it, walk out through any exit that leaves the zone (their whole inventory travels with them), and mint another.
That is not a dupe — it is a generation faucet, bounded only by the mint caps. So the flag is you saying two things at once:
- A private copy of this zone may be minted for a party.
- I have budgeted for my resets running once per copy.
Only the author of a zone can make that second call, which is why the engine states no policy about which zones may be instanced and simply enforces your answer.
The default is false because the failure directions are asymmetric: a missing opt-in breaks a dungeon door; a missing refusal breaks the economy. Without it, instancing would reach every loaded zone — including ones another builder deliberately gated behind a locked door, a quest, or a level check. A private copy has no doorman, so a mint routes around every in-world gate at once.
In the demo pack, crypt is the one zone that sets it. Midgaard, darkwood and the overworld
deliberately do not: they are the shared persistent world, and a private copy of a town would
duplicate its shopkeepers’ stock.
The start-room requirement
An instanceable zone must declare a start_room, and it must name a room that zone actually
declares. This is enforced at mint — loudly, in front of the builder — rather than at a player’s
death hours later. Both halves matter:
- Entry lands in the start room. With no start room, the arrival resolves to nothing and the entering player is disconnected mid-entry, having done nothing wrong.
- A death inside respawns at the start room. With no start room, a player who dies at the boss is revived at full health standing in the boss room. There is no cross-zone respawn to fall back on; the engine evicts them to their entry anchor instead — correct, but degraded and nobody authored it.
An ordinary non-instanceable zone can get away without a start_room. An instanceable one cannot.
Entering: the declared door, or mud.send_to_instance
There are two ways in, and the declared door is the one you usually want.
The dungeon door: instance_entrances
Declare it on the room, exactly like an exit, but in its own map:
- ref: midgaard:room:guild_hall
name: The Guild Hall
exits:
north: midgaard:room:market
instance_entrances:
enter: crypt
Now enter is a direction the player types, and walking it mints a private copy of crypt and
puts them inside. It shows up in the room’s exits line alongside the ordinary ones
(Exits: enter, north, east, down).
This exists because the natural door idiom — a room’s enter trigger calling
mud.send_to_instance — was refused: for a room trigger the invoking actor is the room, not
the entrant, so the self-only rule rejected it with a message about targeting that misdiagnosed what
was really an attach-point problem. A declared entrance fixes the attach point instead of relaxing
the rule: because the crossing is a movement the player performs, the mover is the actor, and
self-only is satisfied structurally — there is no third party in the call frame and no new gate to
get wrong. Every existing refusal (caps, rate limit, nesting, mid-fight, no verified account,
draining) already speaks to the player and needed no new code.
Why
instance_entrancesis a separate map and not a flag insideexits. Every path that moves a player on another party’s initiative — a script’sh:move, a directionalflee, a room-and-adjacent AoE, the cross-shard router — resolves its direction throughexits. Keeping doors out of that map means none of them can traverse one, rather than each of them remembering not to. A build-failing lint pins thatentranceshas exactly one reader.
mud.send_to_instance(target, template)
The scripted route, for when the entrance is conditional in a way a declared door can’t express. Content does not mint, name, reap, or enumerate instances — this is the only instance-related verb it can reach.
It is self-only — and that decides where you can call it
The target must be the invoking actor. Any other target is refused outright — before the template is even looked at, so the call cannot even be used to probe whether a template exists.
“The invoking actor” is who the script is acting as, and that is not always who you expect. This is the single most important thing to get right:
| Attach point | Acts as | Can it send a player in? |
|---|---|---|
commands — a custom verb |
the player who typed it | Yes. self is the player |
ability on_resolve_lua |
the caster | Yes, for a self-targeted ability — self and ctx.actor are the caster |
room lua: → on("enter") |
the room | No. ev.actor is the entrant but self is the room, so the send is refused |
mob lua: → on("greet") |
the mob | No. Same reason |
So an entity trigger cannot pull an entrant into a dungeon. A door that fires on walking into a room is not expressible, by design. The working idiom is a verb the player types (or an ability they use):
commands:
- verb: descend
aliases: [enter-crypt]
lua: |
-- `self` is the player who typed it, so `self` IS the invoking actor.
if not self:room():has_room_flag("crypt_door") then
self:send("There is nothing to descend here.")
return
end
self:act("$n steps down into the dark and is gone.")
mud.send_to_instance(self, "crypt")
A mob or a room may still advertise the way — flavor text on greet telling the player to
descend — it simply cannot move them itself. Likewise there is no “party leader pulls the party
in”: each member enters under their own power, through their own action.
Why the restriction is that blunt
A forced relocation of another player is harm, and this is the most severe form of it the engine can express: the destination is across a zone boundary, is private, is chosen by the script’s author, and the victim cannot be seen, reached or helped from outside it. An ordinary teleport moves you within observable space; this moves you outside it.
The obvious control would be the harm gate h:teleport and h:recall use, but it does not cover this
threat model on two counts:
- It does not gate a mob actor at all. The gate short-circuits on a non-player actor before the safe-room veto, so for a mob tick, an aggro handler or a mob-owned ability it returns true unconditionally. A cultist in a temple or a mob in a newbie inn could otherwise send a non-consenting player into a private dungeon with neither pvp policy, safe rooms, consent nor spawn protection applying.
- It self-exempts. It returns true immediately when the target is the acting entity — so for the self case, which is the whole idiom, it is a no-op rather than a chokepoint.
Self-only kills the entire class and costs nothing content actually wants. A consented party-summon would need explicit target consent (an accepted invitation from the target, not a party-membership check or a pvp flag) and a safe-room veto that does not exempt mob actors. Neither exists, so the capability does not ship.
It is fire-and-forget
It returns true when the request was accepted for dispatch — not when the player has arrived.
The actual build happens on a shard worker over hundreds of milliseconds to seconds, and the crossing
happens later still. There is no callback and no “wait for it”: a script must never be able to make a
zone actor wait on a zone build. A script that needs to know the player left can subscribe to movement
like anything else.
A refusal is a clean false, never a raised error, so your door script’s flavor text, its cost
deduction and its logging all still run. The player has already been told why — the engine owns
these lines:
| Line the player sees | Why |
|---|---|
The way begins to open... |
Accepted; the build is queued |
The way is already opening. Be patient. |
They already have a mint in flight |
You cannot open a way from inside one. |
No nesting: instances do not nest |
You can't leave while fighting! Flee first. |
Same rule as walking out of a zone |
The way will not open right now. Try again shortly. |
The shard’s mint queue is saturated |
The way fails to open. |
The mint was refused: a cap, the rate limit, a draining shard, or a template that is unknown, not instanceable, or has no start room |
The way closes again — you are fighting. |
They engaged during the pause |
The way closes before you can step through. |
The destination stopped being valid during the pause |
The way does not open for you. |
The session carries no verified account, so the per-account cap cannot be charged |
Because the pause is unbounded and the player is live throughout it, everything is re-checked on arrival. Design your door so that a player who wanders off mid-build is not left in a broken state — the engine’s half of that is handled, but your cost deduction is not.
Filtering: mud.zone(), and why ev.zone ~= "darkwood" is wrong
mud.zone() returns the live zone id of the running script: darkwood in the authored zone,
darkwood#<serial> inside a copy of it.
An instance is built from your zone’s content, so its rooms and your scripts are identical to the template’s. A hardcoded comparison against the authored ref therefore matches inside every live copy:
-- WRONG. Every instance of darkwood also matches this, so one scheduled event fires
-- in the shared zone AND in every private copy at once.
on_world("spawn.boss", function(ev)
if ev.zone ~= "darkwood" then return end
...
end)
-- RIGHT. ev.zone names the authored target zone; mud.zone() names THIS actor.
-- They differ inside a copy, so the copy correctly declines.
on_world("spawn.boss", function(ev)
if ev.zone ~= mud.zone() then return end
mud.spawn(ev.proto, self:room())
end)
This is the general idiom for any zone-targeted world event, and the demo pack’s darkwood boss herald is the worked example (it was written the wrong way first).
The engine independently withholds its own reserved schedule events from instances, so the demo would be safe either way. Both exist deliberately: the engine bound makes the engine’s events safe, and
mud.zone()is how content expresses the same distinction for its own events, whose semantics the engine cannot know.
What does not work inside an instance
Each of these is a deliberate exclusion, not an oversight. Your content will behave differently in a copy than in the template, and this is the exhaustive list of how.
| Feature | Inside an instance | Why |
|---|---|---|
Persistent resets (persistent: true) |
Refused, logged as a warning | Durable objects are keyed by the authored room ref, which is identical across every copy, and the dedup is per-zone. N copies would each load the same durable rows — N lootable copies of a unique object. A real item dupe |
Timed repop (reset_secs) |
Suppressed | An instance is one bounded run. A template that respawns its boss on the reset timer while the party is still in the lair yields that boss’s full loot table every reset_secs, forever, with no world-level scarcity. The boot reset still runs — the dungeon is populated exactly once |
signal_region / signal_world |
Refused, logged as a warning | The signal envelope drops its source, so a director cannot tell one party’s private progress from the shared world’s. An instance may never drive shared world state |
Reserved director schedule events (spawn.boss) |
Withheld (never delivered) | One schedule would otherwise spawn the boss, with its full loot table, in the template and every live copy — and each kill would reschedule the shared world timer, last-writer-wins |
| Nesting | Refused at request time | You cannot open a way from inside one |
| Hot reload | Frozen | A live copy is pinned to the content it was minted from: no room reconcile, no Lua recompile. Your edit lands on the next mint. See Content Loading & Hot Reload |
What does still work, so you are not surprised in the other direction:
- Boot resets run in full — that is the point, and that is the faucet you opted into.
- Region and world state reads resolve through the template, so content that gates on region state is not silently inert in every copy.
- Content-authored world events still reach instances. Only the engine’s own reserved events are
withheld, because the engine cannot know whether you meant yours to arrive. Filter with
mud.zone(). - Exits that leave the zone work normally. A copy is a closed copy: an exit naming another zone transfers out of the dungeon exactly as it would from the template. That is the ordinary way out.
- Loot rolls are salted per copy, so two copies do not roll identically and a copy does not restart the loot stream at a predictable point.
Getting back out
Players leave a copy in four ways, and you only author the first:
- Walking out through any exit that names another zone. Ordinary movement.
- Dying, which respawns them at the copy’s
start_room— inside the instance, so the run continues. Ending a run on a wipe is a content rule (author it inon_death), not an engine one. - An operator draining the shard, which walks them back out to the door they came in by:
The way behind you closes, and you find yourself back where you entered. - The idle reaper, once the copy is empty.
The zone + room a player entered from is recorded as their exit anchor and is where every involuntary exit lands them. You do not author it and cannot read it.
Caps to design around
Instancing is capped, and the caps are engine defaults, not content policy. Design content that works within them:
| Bound | Default |
|---|---|
| Concurrent live copies per account | 3 |
| Mints per account per minute | 6 |
| Live copies per world process | 256 |
An account that abandons a mint (asks to enter, then walks away or quits) gets its slot back immediately rather than having it pinned. The operator view of these — including the fact that they are per process, so a fleet multiplies them — is in Running at Scale.
Worked example: the demo pack’s crypt
internal/content/packs/demo/zones/02-crypt.yaml is the reference. It sets instanceable: true,
declares a start_room, and carries an ordinary boot reset list (a skeletal guardian, a tomb
guardian, a bone blade) plus a reset_secs: 90 that is honored in the shared zone and suppressed in
every copy. Its entrance room has an up exit back to the Midgaard guild hall — which is both the
walk-in route and the ordinary way out.
The demo pack’s guild hall now ships a real declared door (
instance_entrances: {enter: crypt}), so the feature is exercised by authored content and the walk-in route still exists beside it. Themud.send_to_instancescript above is the idiom for a conditional entrance, not a quote from shipped content.