Skip to content

Zone Runtime and Actor Model

Kurt edited this page Jul 19, 2026 · 5 revisions

Zone Runtime and Actor Model

Audience: Engine Developer Status: ✅ Ready

A zone is an actor: one goroutine that exclusively owns every room, player, item, and mob in that zone and is the only code that reads or mutates their state. Because there is exactly one writer, game logic needs no locks. This single-writer model is the spine of the whole engine — combat, scripting, movement, and persistence all rest on "you are running on the zone goroutine, so the world is yours."

Related: Entity Component Model, Distributed Systems Model, Cross-Shard Handoff, Edge & Protocol.

The actor loop

Zone.Run (internal/world/zone.go) is one dedicated goroutine. Its select loop has exactly three cases:

flowchart TD
    subgraph ZoneGoroutine["Zone.Run — single writer"]
        S{select}
        S -->|"ctx.Done()"| Stop["stop + tear down"]
        S -->|"<-inbox"| H["handle(msg): type-switch → handler"]
        S -->|"<-ticker.C (250ms)"| T["pulses.tick()"]
        H --> S
        T --> S
    end
    Ext["everything off-goroutine<br/>(gate reader, saver, reload bus, peers)"] -->|"post(msg)"| Inbox[("inbox<br/>buffered, cap 256")]
    Inbox --> S
Loading

handle type-switches on a msg interface to the per-message handler, and is wrapped in a recover() — the process-survival net. An unrecovered panic in any handler would otherwise crash the entire world process; instead it logs the message type + stack and continues.

post() — the only cross-goroutine ingress

post(m) is simply z.inbox <- m — the only sanctioned way to reach zone state from outside the loop. The inbox is a buffered channel of capacity 256. A non-blocking variant, postOrDrop, is reserved for recoverable notices (e.g. a hot-reload invalidation) where blocking on one saturated zone must not head-of-line-stall a shard-wide fan-out; it is never used for state the zone must not miss.

The single-writer discipline is exactly what lets inbox messages carry remote data across the goroutine boundary safely. For example, whoRenderMsg carries a roster snapshot produced by an off-goroutine Redis read, so that the render — which enters the zone-owned Lua VM — happens back on the zone goroutine.

Runtime zone teardown

A shard hosts zones for its whole life, but individual zones come and go at runtime — most importantly, a zone the rebalancer moves to a peer must be stopped on the source, or the source keeps an empty, unowned, un-renewed zone whose actor goroutine goes on pulsing its heartbeat, running resets, and holding a Lua VM: one zombie per migration. So each zone gets its own actor context, and UnhostZone can stop one without stopping the shard — it drops the zone, its handoff tokens, and its region-scope mapping, stops lease renewal, cancels the actor, and waits for the goroutine to return (the Lua VM is torn down in that goroutine's defer, so "gone" isn't true until it does).

Teardown is deliberately narrow and guarded, because getting it wrong recreates the very orphan or split-brain it exists to prevent:

  • It refuses unless the zone is genuinely disposable — this shard is not the zone's live owner, the zone is quiescent, and it is neither the home zone nor a local bootstrap zone. An unowned lease is not automatically safe to drop: a lapsed lease reads as ownerless, and a shard still renewing that zone is about to reclaim it. Only a zone deliberately handed away is the source's to drop.
  • Quiescence is not pop == 0. A brand-new character who quits inside their async character-creation has their final logout snapshot parked in a pending-flush stash and has already left the player map — so population reads zero while a durable write is still owed, and the message that replays that write is delivered to this zone's inbox and nowhere else. Zones mirror the stash count as an atomic and expose a quiescent() that both the teardown gate and the drain-wait consult, so a pop-only gate can't silently drop that state. A third counter, incoming, covers a player in flight on the intra-shard transfer path: transferOut removes them from the source and hands the session to the destination asynchronously through its inbox, so for the width of that queue hop the session is in no zone's player map and pop reads 0 on both sides — a window in which teardown would close z.dead, abandon the post, and leave a live session attached to no room, owned by no zone. The claim is taken by claimTransferTarget, which resolves the destination and claims it in one hold of the same mutex UnhostZone checks quiescence under; claiming just before the send is not enough, because a teardown fitting in the resolve→send gap would land the claim on a dead zone — the handover dropped and the counter wedged, strictly worse than no claim at all. A leaked claim is permanent (the zone could never be unhosted or rebalanced, and every later drain would burn its full deadline), so transferOut owns it through a deferred compensator and transferIn reports underflow. claimTransferTarget also refuses a handedOff zone or a draining shard — hosting the object is not sufficient to be its destination, since a drain or rebalance flips the lease before the zone drains, and admitting a walker there would keep this shard mutating a zone whose lease lives elsewhere. This was reachable via the rebalance path even before instancing; it becomes routine under the instance reaper. Two sibling paths have the identical resolve-then-deliver-async shape and now take the same kind of claim: a login attach and a cross-shard Prepare. The login one is the biting case — the server resolves a zone, posts attachMsg, and pop only rises when the handler runs setPlayer, so a teardown in that gap abandons the post and the Play stream never receives Attached (the gate does not re-resolve on it). Three constraints shaped the fix: the window is reachable through only one of the four attach branches (the durable zone_ref one — the token branch already ran setPlayer, residency is resident by definition, and the home fallback is refused by UnhostZone); neither path may reuse claimTransferTarget, whose draining refusal would break the handoff re-dial that a draining shard deliberately admits and whose handedOff refusal would strand a player prepared before a mid-drain lease flip; and Prepare needs a release on post failure, since it selects raw on z.inbox and its ctx.Done() arm — an RPC deadline, the common failure — would otherwise return with the claim held and the message enqueued nowhere, converting an occasional clean abort into a permanently un-unhostable zone. The four-branch attach decision, which previously took three lock acquisitions across two mutexes, now runs under one hold, with operator WARNs hoisted out via a route enum so nothing logs under the hot routing mutex.

A reconnect racing an in-flight transfer is refused outright. Between transferOut's delPlayer and transferIn's setPlayer the session is in no zone's player map, so the residency index misses, a token=="" reconnect falls through to the stale durable zone_ref, and a second copy fresh-logs. Pointing residency at the destination early would be worse — the attach and the transferInMsg are posted by different goroutines with no ordering, so an attach landing first would fresh-log and then be overwritten by setPlayer, orphaning the fresh copy's entity in the room with no players reference. So the answer is to refuse, in two layers: at resolve time an in-flight mark returns Unavailable before any ownership claim is minted (the gate re-resolves on Unavailable), and at delivery time Zone.attach re-checks on the zone goroutine, serialized against transferOut. The delivery layer is not redundant — a probe reproduced a live two-copy dupe it alone catches, because attachMsg can land behind an already-queued cross-zone move. Its load-bearing predicate is the residency mismatch, not the mark, since by delivery the mark may already be cleared. The mark's lifetime rides the incoming claim exactly — set once before delPlayer, cleared at the same two sites that release the claim — so it cannot leak without also leaking a claim, which reports loudly; a TTL backstop requires both age and positive proof that no zone holds an inbound-arrival claim, because a bare timer reopens the dupe against a merely-stalled destination.

  • post selects on a per-zone dead channel that teardown closes once the actor has returned. Without it, a blocking post into a torn-down inbox would wedge the shared saver drainer goroutine (its acks carry zone pointers and have no context to bail on) — stopping persistence for every zone on the shard.
  • Publishing a zone and arming its actor happen under one lock hold. Otherwise a teardown that races zone construction finds no actor to cancel, reports the zone gone, and leaves an un-cancellable goroutine running — the orphan, recreated by the primitive meant to prevent it. A later re-adoption takes the full build path, which is what lets the "handed off" marker be cleared with the zone rather than left stale.

How player input enters

Player I/O crosses from the gate over the Play gRPC stream. Per connection (server.go):

  1. The first frame must be Attach; the character id is sanitized at the trust boundary (textsan.CleanName, capped at 20 runes).
  2. A writer goroutine is spawned — the single caller of stream.Send (gRPC streams are not concurrent-Send-safe), fed by the player's out channel.
  3. That same goroutine becomes the reader loop: each client frame becomes zone.post(inputMsg{...}). It never touches world state itself.

Routing uses a per-connection curZone *atomic.Pointer[Zone]: when a zone binds the player it Stores itself there, so the reader loop posts subsequent input to the current zone even after an intra-shard move. inputMsg carries the gate's session-scoped seq; handleInput enforces exactly-once by dropping any seq at or below the player's appliedSeq high-water. A clean quit removes the player immediately; an unexpected drop starts a 60-second link-dead grace window.

The pulse / heartbeat scheduler

Note: the scheduler is a flat, linearly-scanned slice, not a timer wheel. A min-heap is deliberately deferred until a single zone ever holds thousands of timers.

pulseInterval is 250 ms — the Diku PULSE quantum. The scheduler holds no timer of its own: Zone.Run owns the single time.Ticker, and on each fire calls pulses.tick() inline on the zone goroutine, so every scheduled callback has the same single-writer access a command handler does. This is deliberately different from the few time.AfterFunc uses (link-death reap, pending-TTL), which fire on arbitrary timer goroutines and therefore only post a message — they never touch entity state directly.

API: every(pulses, fn) (periodic) and after(pulses, fn) (one-shot), each returning a pulseHandle cancel token. tick increments the pulse counter, fires due non-cancelled callbacks in registration order, reschedules periodics, and snapshots the due list and resets it to nil before firing — so callbacks that register more callbacks during the same tick are merged in, not lost.

Consumers hanging off the pulse:

Consumer Cadence
Combat rounds every(PULSE_VIOLENCE=10) ≈ 2.5 s — one driver per zone, self-cancelling (Combat System)
Affect ticks + regen every(1) per affected entity (Abilities & Effects)
mud.after (Lua) one-shot, inline on the zone goroutine, with a live-timer cap (Lua Sandbox Internals)
Repop / zone resets reset_secs → pulses (Persistence & Durability)
Save cadence checkpoint ≈ 10 s, flush ≈ 60 s (Persistence & Durability)

Handle invalidation and zoneGen

A Lua handle is validated on every method through resolveHandle, which today rests on two guarantees: the handle's captured zone pointer must match, and entityByRID must find the rid in this zone's containment walk (a dead, departed, or cross-zone rid is simply absent, and the handle no-ops).

Reserved: Zone.gen is a forward-looking generation counter a handle also captures and re-checks — but it is wired and never bumped in current code (the bump point is a deferred slice). Until then it is inert scaffolding; correctness comes entirely from the zone-pointer match plus the RID-not-found walk.

Clone this wiki locally