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:

  • Identityrid RuntimeID, proto ProtoRef, pid *PersistID.
  • Template + displayprototype *Prototype (the immutable template this instance is a copy-on-write delta over; nil for players), and keywords []string / short / long, aliased to the prototype until the first write.
  • Containment + componentslocation *Entity and contents []*Entity (the uniform tree; location is nil for rooms), comps componentSet, and two hot-path escape hatches room *Room / living *Living kept in sync with comps so movement/look/combat never pay a map lookup. zone *Zone names 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 entityByRID depends 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, and entityByRID (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”: a self:room() read in the room display template intermittently resolved a colliding room and fell through to the nil fallback. Re-homing touches only the rid field — 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 Material component. “Material” is a string field on Physical; stackable materials are modeled by ItemMeta.maxStack plus the Stack component.
  • Wearer and Wearable share the KindWearable tag but are distinct reflect.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 against prototype.comps[T], then deep-clones via cloneComponent and re-adds onto the instance only. cloneComponent has an explicit case per component type and a default: 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 every Living mutator 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 Entity with a Room component and location == nil; the zone holds it in z.rooms keyed by ProtoRef. 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’s contents — “equipped” is a state over a carried item (the Wearer.worn slot 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.