-
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.
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