Entity Component Model
Audience: Engine Developer Status: ✅ Ready
Everything in the world — every player, mob, item, and room — is a single Go type, Entity. An entity is identity + a uniform containment tree + a component bag. Capabilities (is it alive? can it be worn? does it hold contents?) are optional components, not subclasses. This is composition over inheritance, tuned for a MUD: the world interacts with whole objects, so components are stored in a per-entity map keyed by reflect.Type rather than in hot columnar arrays.
Related: Zone Runtime & Actor Model (who owns and mutates entities), Persistence & Durability (how the instance delta is saved), Command Parser & Targeting (how entities are found).
The Entity struct
internal/world/entity.go defines the one universal type. Its fields fall into three groups:
- Identity —
rid RuntimeID,proto ProtoRef,pid *PersistID. - Template + display —
prototype *Prototype(the immutable template this instance is a copy-on-write delta over;nilfor players), andkeywords []string/short/long, aliased to the prototype until the first write. - Containment + components —
location *Entityandcontents []*Entity(the uniform tree;locationisnilfor rooms),comps componentSet, and two hot-path escape hatchesroom *Room/living *Livingkept in sync withcompsso movement/look/combat never pay a map lookup.zone *Zonenames the single-writer owner.
Three kinds of identity
| Id | Type | Lifetime | Purpose |
|---|---|---|---|
ProtoRef |
string, canonically "zone:kind:name" |
authored, stable | content key; exit destinations are ProtoRefs |
RuntimeID |
uint64, 1-based |
ephemeral, per-zone | cheap handle; never persisted, never crosses a shard |
PersistID |
UUID string | durable | nil unless the entity has been saved |
entityByRID (identity.go) is the resolution primitive: it walks the zone’s room map and each room’s contents recursively. This is how a Lua handle re-resolves on every method call — a handle holds only a (rid, zone) pair, never an *Entity pointer, so a dead or departed rid simply isn’t found and the handle safely no-ops.
A RuntimeID is unique only within its zone. Each zone allocates from its own 1-based counter, so
entityByRIDdepends on no two entities in a zone sharing a rid. An intra-shard transfer (walking between two co-hosted zones) therefore has to re-home the moving entity and its entire carried subtree to fresh destination-zone rids — otherwise the arrivals keep the source zone’s rids, collide with the destination’s rooms/mobs/items, andentityByRID(whose scan returns the first match under Go’s randomized map order) resolves the wrong entity ~half the time. That collision is what made the overworld minimap “render on some rooms but not others”: aself:room()read in theroomdisplay template intermittently resolved a colliding room and fell through to thenilfallback. Re-homing touches only theridfield — the containment graph is all pointer-based — while the affect tick and HUD pulse are re-armed separately.
The component set
A Component is any type implementing componentKind() Kind. Kind is a serialization/label tag; the map key is reflect.Type, so two structs never collide. Generic accessors Get[T] / Must[T] / Has[T] / Add[T] / Remove[T] manage the bag, and Add/Remove keep the e.room / e.living hot pointers in sync.
| Component | Role |
|---|---|
Room |
exits (dir → ProtoRef), sector, coord, open-set namedFlags |
Living |
the big hot component — resources, attributes, flags, tracks, abilities, position, combat/threat state (below) |
PlayerControlled |
session bridge + aliases (account is a reserved stub) |
Physical |
weight, size, material (a field) |
Container |
capacity, weight limit, closed/locked, key ref |
Wearable / Wearer |
the slots an item may occupy / the wearer’s worn map + summed gear mods |
Weapon |
dice, damage type, class, attack verb |
Scripted |
the prototype-shared Lua source string only |
Affected |
runtime affect instances + summed modifier maps + prevents set + tick handle |
Quality |
per-instance rolled loot Level + Affixes |
ItemMeta |
proto metadata: bind rule, tier, tags, maxStack, salvage rules |
Bound / Kept |
per-instance untradeable / no-drop markers (empty structs) |
Stack |
per-instance material count |
CorpseOwner |
corpse loot-ownership window |
Two naming traps worth calling out, because the schema might suggest otherwise:
- There is no
Materialcomponent. “Material” is a string field onPhysical; stackable materials are modeled byItemMeta.maxStackplus theStackcomponent. WearerandWearableshare theKindWearabletag but are distinctreflect.Types, so they coexist (one on the wearer, one on the item).
The Living component
Phase 5 removed all hardcoded hp/mp integers. Vitals are content-defined resources (resCur map[string]int, with max derived from an attribute); stats are content-defined attributes (attrBase map[string]float64). Everything reads through accessors, so a contentless entity reports 0 — the bare-engine invariant. Living also carries open-set flags, per-track progress, granted abilities, professions, a memoized (never-persisted, never-shared) attribute cache, the integer position, the transient fighting pointer and threat map, and cooldowns. Convenience refs like hp/mana are just names, not hardcoded mechanics.
Prototype vs instance: flyweight + copy-on-write
A Prototype (prototype.go) is an immutable template — ref, keywords/short/long, and a component template holding canonical component pointers that instances share by reference until they copy-on-write.
The contract:
- Shared-immutable (read across many zone goroutines, never mutated after construction): the display strings and the whole component template graph.
- Instance-local (written only by the owning zone goroutine):
rid/pid/zone,location/contents(containment is never shared), and any COW’d field or component.
spawn is the instancing path: a fresh RID, keywords/short/long aliased (not copied), comps a shallow copy of the template (same *Component pointers → data shared), and empty containment. No field is copied — sharing is the whole point.
flowchart LR
P["*Prototype<br/>(immutable, shared)"] -- "spawn: alias + shallow-copy comps" --> I1["instance A<br/>(own rid, empty contents)"]
P --> I2["instance B"]
I1 -- "first write via mutableLiving" --> C1["cloneComponent → instance-local Living"]
P -. "stays GC-alive while any instance aliases it" .- I2
COW mechanics
- Scalars/strings (
setShort/setLong): plain assignment — string immutability is the COW. - Keyword slice (
mutableKeywords): reallocates the first time it detects the slice header still points at the prototype’s backing array. - Components (
mutableComponent[T]): detects “still aliased” by pointer identity againstprototype.comps[T], then deep-clones viacloneComponentand re-adds onto the instance only.cloneComponenthas an explicit case per component type and adefault: panic— a deliberate tripwire so any new component with reference-typed fields must be handled, or COW would silently alias the prototype. - Hot-path shortcuts
mutableRoom/mutableLiving— the mandatory choke-point everyLivingmutator calls before writing.
The prototype cache and atomic hot-reload swap
protoCache is per-shard, shared read-only across zone goroutines. The live table is an atomic.Pointer[protoTable]: readers (get/spawn) do one lock-free Load; writers (boot define, runtime reload) build a fresh map copy with one entry changed and Store it, serialized by writeMu. A reload never touches in-flight COW deltas — the old *Prototype stays GC-alive while any instance aliases it, and only later spawns see the edit. See Content Loading & Hot Reload.
The world containment tree
Rooms, containers, and inventories are the same contents/location mechanism:
- Zone → room: a room is an
Entitywith aRoomcomponent andlocation == nil; the zone holds it inz.roomskeyed byProtoRef. Rooms are singletons — one instance per ref. - Room → entity: occupants and ground items live in the room entity’s
contents. - Container/inventory → item: same
contents. A worn item stays in the wearer’scontents— “equipped” is a state over a carried item (theWearer.wornslot map just indexes it), exactly the Diku model.
Move(e, dest) is the single containment primitive: detach from the old location.contents, set location, append to dest.contents (dest == nil removes from the tree, used on handoff departure). Callers must already own e; cross-zone moves go through the zone inbox.