Skip to content

Building Instanced Zones

Kurt edited this page Jul 20, 2026 · 2 revisions

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:

  1. A private copy of this zone may be minted for a party.
  2. 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_entrances is a separate map and not a flag inside exits. Every path that moves a player on another party's initiative — a script's h:move, a directional flee, a room-and-adjacent AoE, the cross-shard router — resolves its direction through exits. 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 that entrances has 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:

  1. Walking out through any exit that names another zone. Ordinary movement.
  2. Dying, which respawns them at the copy's start_roominside the instance, so the run continues. Ending a run on a wipe is a content rule (author it in on_death), not an engine one.
  3. 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.
  4. 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. The mud.send_to_instance script above is the idiom for a conditional entrance, not a quote from shipped content.

Clone this wiki locally