Skip to content

Zone Runtime and Actor Model

Kurt edited this page Jul 10, 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.
  • 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