Skip to content

Pack Authoring

Kurt edited this page Jul 24, 2026 · 3 revisions

Pack Authoring

Audience: Builder Status: ✅ Ready

A content pack is where a TelosMUD game lives: a set of YAML documents describing every zone, mob, item, ability, and rule. This page is the format reference — the two authoring shapes, how files are discovered and merged, what happens when packs overlap, the reserved core: namespace, and the three ways a pack gets into a running world. If you just want the big picture of what packs are and how they're enabled, read Content Packs Intro first; for the field-by-field schema of every entity, see the Pack Entity Reference.

Two authoring shapes

A pack named <name> is resolved as exactly one of two shapes, and the loader tries them in order:

  1. Single filepacks/<name>.yaml, the whole pack in one YAML document. This is tried first. If it exists, the directory form is ignored entirely.
  2. Directory treepacks/<name>/**/*.yaml, many small files merged into one pack. Used only when no packs/<name>.yaml exists.

If neither is present the pack simply resolves to "not found" with no error — enabling an absent pack contributes nothing to the world. This is deliberate: it preserves the bare-engine invariant that the engine ships no content of its own.

The demo pack uses the directory form; the minimal core bootstrap pack does too. The rest of this page describes the directory tree, since that's what any non-trivial pack uses.

The tree, by example

The core and demo packs both live under internal/content/packs/. Here is the demo pack's actual tree — a complete, real example you can read as a template:

internal/content/packs/demo/
  pack.yaml                    # manifest: scalars + globals head (see Pack MUD Settings)
  attributes.yaml              # one file per pack-global def section...
  resources.yaml
  damage_types.yaml
  combat_profiles.yaml
  abilities.yaml
  affects.yaml
  affix_defs.yaml
  rarity_tiers.yaml
  loot_tables.yaml
  recipes.yaml
  wear_slots.yaml
  tracks.yaml
  bundles.yaml
  chargens.yaml
  channels.yaml
  regions.yaml
  spawn_schedules.yaml
  display_defs.yaml
  zones/
    00-midgaard/               # a zone SPLIT across files (all merged into one zone)...
      00-zone.yaml
      10-rooms.yaml
      20-items.yaml
      30-mobs.yaml
      40-resets.yaml
    01-darkwood.yaml           # ...or a whole zone in a single flat file
    02-crypt.yaml

Two things this example demonstrates. First, every file is optional and there is no fixed filename: a file's content is identified by the YAML keys inside it (zones:, abilities:, rooms: under a zone, and so on), not by its name. The filenames above are a readability convention. Second, a zone can be one file or a subtree of filesmidgaard is spread across five files that merge into a single zone, while darkwood and crypt are each one file. Both forms work identically.

Discovery and ordering

The loader walks the whole tree (any nesting depth), keeps every .yaml and .yml file, and sorts the full path list lexically before merging. The merge order is therefore deterministic and independent of the filesystem's directory order.

This is why the demo files carry numeric prefixes — 00-zone, 10-rooms, 20-items, 30-mobs, 40-resets, and 00-midgaard/, 01-darkwood.yaml, 02-crypt.yaml. The prefixes are purely a readability/ordering convention. The merge is keyed by an entity's ref, not by file position, so ordering only decides who wins a last-write-wins tie (see below) — it never changes which entities exist. Use prefixes to keep a tree readable; don't rely on them for semantics.

Merge semantics (within a pack)

Each file parses to a partial pack, and the loader folds them together in sorted-path order:

  • Pack scalars (default_combat, pvp_lua): last non-empty value wins.
  • formulas (a map): merged key by key, last-write-wins per formula name.
  • commands and display_defs: appended, not de-duplicated (the world registry resolves the final winner downstream, last-write-wins).
  • All other pack-global sections (attributes, resources, abilities, affects, loot tables, …): last-write-wins by ref — a later file redefining the same ref replaces the earlier one. (trust_tiers key on name instead of ref.)
  • Zones are unioned, not replaced. When the same zone ref appears in several files, their rooms, item_prototypes, and mob_prototypes are merged last-write-wins by ref; the zone's resets are concatenated (resets have no ref, so their identity is their order); and scalar zone fields (name, start_room, reset_secs) take the last non-empty/non-zero value. This union is exactly what lets midgaard/10-rooms.yaml and midgaard/20-items.yaml combine into one zone.

Merge across packs (enabled-list precedence)

A world can have several packs enabled at once. Cross-pack merge mirrors the cross-file merge — last-write-wins by ref for globals — with one difference: whole zones are replaced by ref across packs (not unioned as they are across files within a pack). Precedence is the enabled-pack load order: a later pack in the enabled list overrides an earlier one by ref. (Which packs are enabled, and in what order, is an operations concern — see Content Packs Intro and Content Pack Operations.)

Two hard constraints on multi-pack worlds, enforced at import:

  • Refs must be globally disjoint across the batch. Because most tables are keyed by ref alone (globally unique, not per-pack), two packs must not ship the same ref. Namespacing your refs (e.g. mytown:room:square) gives you this for free.
  • Refs are bounded in both character set and length. A ref is a store btree primary key (and it also composes NATS comms subjects, GMCP JSON keys, the proto-cache key, and foreign refs in player-state JSONB), so beyond the charset lint there is a length lint: RefMaxLen = 256 (about 9.5× the longest real token, and well under the ~2704-byte btree-page ceiling that would otherwise fail the import transaction at runtime). The same length bound also covers the two content-authored store keys the charset lint deliberately skips — the pack name and each ruleset formula-override name — since an over-long value there fails the import identically. Both are a boot warning and a hard reload-reject, exactly like the charset lint.
  • Exits cannot cross packs. A room exit may target another zone in the same pack (a cross-zone exit like north: darkwood:room:grove is fine), but not a room in a different pack — packs are self-contained worlds, and the cross-pack case would violate the foreign key.

The reserved core: namespace

The embedded core pack is always layered underneath every real pack at world boot, so the enabled set is never empty and a fresh server always has at least a start room (real packs override it by ref). To protect that bootstrap floor, the zone ref core and the core: ref prefix are reserved: a non-core pack that ships them is warned by a load-time lint (LintReservedCoreRefs) and hard-rejected by the reload gate. Do not name a zone core or prefix your refs with core:.

How a pack gets into a world: the three ingestion paths

Authoring the tree is only half the story; the pack has to reach a running world. There are three paths.

1. Embedded (tests and bare dev)

The whole packs/ tree is compiled into the binaries via //go:embed (internal/content/demo.go). The unit tests and a bare dev run load content straight from the embed — no Postgres required. LoadPack(name) / LoadDemoPack() read this. The core pack is embedded-only.

2. telos-seedImportPack (dev seeding)

make seed runs telos-seed, which loads the embedded demo pack and writes it into Postgres. The import is an idempotent strip-and-replace per pack in a single transaction. It stamps a wall-clock-nanos content version (there is no logical version on this path) and publishes hot-reload invalidations over NATS (optional and non-fatal — the rows are written even if NATS is down). This is the fast, dev-oriented path.

3. telos-pullImportVersion (versioned ops / CI)

telos-pull resolves a git tag or SHA from the configured content store, reads a manifest.yaml at the repo root, verifies the tree hash, and imports exactly the packs the manifest names — atomically. It prunes packs that were dropped, strip-replaces the named packs, overwrites the pack registry, and bumps a monotonic content version, all in one serialized transaction; re-importing the same content hash is idempotent (no version bump). telos-pull --check dry-runs the pre-flight as a CI merge gate, and --emit-manifest computes the content hash and pack list over a local tree and writes the manifest.yaml for you. This is the path for a real, versioned content deployment; the mechanics are covered in Content Pack Operations.

Note: the world process never runs a pull. It always reads content rows from Postgres; the pull machinery (director / CI) is what updates those rows.

The external store layout

A published content repository has, at its root:

manifest.yaml       # version (required), content_hash (required), packs[] (required),
                    #   optional created_at, ci_run, engine_min
packs/              # the pack trees, exactly as authored
  <name>/...

content_hash is computed over the packs/ subtree and verified on import, so a tampered or truncated tree fails closed. packs[] is the enabled set the version publishes.

What lives where

Not modeled: a pack does not declare its own version, dependencies, or author inside pack.yaml. Versioning and the enabled-pack set are the external manifest.yaml's job, and cross-pack ordering is purely the operator's enabled-list order — there is no dependency resolver in the loader.

Clone this wiki locally