-
Notifications
You must be signed in to change notification settings - Fork 0
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.
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
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(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.
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 aquiescent()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:transferOutremoves 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 andpopreads 0 on both sides — a window in which teardown would closez.dead, abandon the post, and leave a live session attached to no room, owned by no zone. The claim is taken byclaimTransferTarget, which resolves the destination and claims it in one hold of the same mutexUnhostZonechecks 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), sotransferOutowns it through a deferred compensator andtransferInreports underflow.claimTransferTargetalso refuses ahandedOffzone or adrainingshard — 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. -
postselects on a per-zonedeadchannel that teardown closes once the actor has returned. Without it, a blockingpostinto 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.
Player I/O crosses from the gate over the Play gRPC stream. Per connection (server.go):
- The first frame must be
Attach; the character id is sanitized at the trust boundary (textsan.CleanName, capped at 20 runes). - A writer goroutine is spawned — the single caller of
stream.Send(gRPC streams are not concurrent-Send-safe), fed by the player'soutchannel. - 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.
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) |
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.genis 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.
TelosMUD — Wiki under construction.
- Builder Reference
- Builder Commands
- Trust Tier Model
- Pack Authoring
- Pack MUD Settings
- Pack Lua Scripting
- Pack Lua Hooks
- Pack Entity Reference
- Building Instanced Zones
- Engine Developer Reference
- Architecture Overview
- Entity Component Model
- Zone Runtime & Actor Model
- Instanced Zones
- Command Parser & Targeting
- Edge & Protocol
- GMCP Reference
- Persistence & Durability
- Content Loading & Hot Reload
- Abilities & Effects
- Combat System
- Loot, Spawns & Crafting
- Accounts & Auth Internals
- Orchestration & Directors
- Scoped Event Bus
- Cross-Shard Handoff
- Lua Sandbox Internals
- Distributed Systems Model
- RPC & Protobuf