Skip to content

Building Your First MUD

A comprehensive, step-by-step guide to creating a Multi-User Dungeon with MAID.

This guide takes you from an empty directory to a fully playable MUD world. You'll start by defining rooms, NPCs, and items in YAML, then layer on Python-based game logic — custom systems, commands, and events — to bring your world to life.


What you'll build:

  • A small village with connected rooms, shops, and points of interest
  • NPCs with AI-powered dialogue
  • Items — weapons, potions, quest objects
  • A custom ECS system (weather)
  • A custom player command (forage)
  • Custom events for inter-system communication
  • Custom validation rules for your content
  • Custom pipeline phases for advanced loading

Table of Contents

Part I — YAML-First World Building

  1. Getting Started
  2. Understanding Content Files
  3. Creating Rooms
  4. Creating NPCs
  5. Creating Items
  6. Validation
  7. Testing Your World
  8. Schema Support
  9. Reference

Part II — Hybrid Packs: Adding Python Logic

  1. Adding Custom Systems
  2. Adding Custom Commands
  3. Custom Events
  4. Custom Validation Rules
  5. Advanced: Cross-Pack References
  6. Advanced: Schema Migrations
  7. Advanced: Custom Pipeline Phases

Part I — YAML-First World Building


Chapter 1: Getting Started

Prerequisites

Before you begin, make sure you have:

  • Python 3.12+ — MAID uses modern Python features (type unions, match, etc.)
  • uv — the fast Python package manager used by MAID
  • A text editor — VS Code recommended (MAID can generate schema files for autocomplete)

Check your Python version

python3 --version   # Should be 3.12 or higher
uv --version        # Should be installed

Scaffolding a New Project

MAID provides a quickstart command that generates a project skeleton:

maid quickstart new my-world --template minimal

Three templates are available:

Template What you get
minimal YAML-only — no Python code at all
standard YAML data + a DataDrivenContentPack subclass
full Standard, plus IDE config, CI, and a multi-zone world

For this guide, start with standard so you can add Python code later:

maid quickstart new my-world --template standard --author "Your Name"

You can also pass --description and --license:

maid quickstart new my-world \
    --template standard \
    --author "Your Name" \
    --description "My first MUD world" \
    --license MIT

What the Scaffold Creates

After running the quickstart command, you'll see a project like this:

my-world/
├── pyproject.toml          # Python package metadata & dependencies
├── manifest.toml           # Content pack manifest
├── README.md
├── src/
│   └── my_world/
│       ├── __init__.py
│       ├── pack.py         # Your DataDrivenContentPack subclass
│       ├── data/
│       │   ├── rooms.yaml  # Room definitions
│       │   ├── npcs.yaml   # NPC definitions
│       │   └── items.yaml  # Item definitions
│       ├── systems/
│       │   ├── __init__.py
│       │   └── welcome.py  # Example ECS system
│       ├── commands/
│       │   ├── __init__.py
│       │   └── info.py     # Example custom command
│       └── events/
│           ├── __init__.py
│           └── custom_events.py  # Example event types
└── tests/
    └── test_pack.py        # Basic tests

The pack.py file is the heart of your content pack. It subclasses DataDrivenContentPack, which automatically discovers and loads all YAML files in the data/ directory.

Installing Dependencies

cd my-world
uv sync

Running the Server

Once your content is ready, start the MAID server:

uv run maid server start

By default, this starts:

  • Telnet on port 4000
  • WebSocket on port 8080

Connect with any MUD client (e.g., Mudlet, TinTin++) or a raw telnet session:

telnet localhost 4000

Content packs must be loaded

The server needs to know about your content pack. See Chapter 7 for how to configure pack loading.


Chapter 2: Understanding Content Files

The data/ Directory

All world content — rooms, NPCs, items — is defined in YAML files inside your pack's data/ directory. The loader pipeline automatically discovers every .yaml file in this directory (including subdirectories).

You can organize files however you like:

data/
├── rooms.yaml          # All rooms in one file
├── npcs.yaml           # All NPCs in one file
└── items.yaml          # All items in one file

Or split by area:

data/
├── village/
│   ├── rooms.yaml
│   ├── npcs.yaml
│   └── items.yaml
├── forest/
│   ├── rooms.yaml
│   ├── npcs.yaml
│   └── items.yaml
└── dungeon/
    ├── rooms.yaml
    └── npcs.yaml

File organization is flexible

The pipeline doesn't care about directory structure or file names. It identifies entity types from the _meta.schema field and the top-level key (e.g., rooms:, npcs:, items:).

YAML File Structure

Every YAML content file follows this structure:

_meta:
  schema: maid:<entity_type>:v1    # Schema identifier
  description: "Human-readable description"
  author: "Your Name"

<entity_type_plural>:               # Top-level key: rooms, npcs, items
  <entity_id>:                      # Snake_case entity identifier
    _uuid: "..."                    # Optional: stable UUID
    components:                     # Component data attached to this entity
      ComponentName:
        field: value
    tags:                           # String tags for querying
      - tag_one
      - tag_two
    attributes:                     # Key-value metadata
      custom_key: custom_value

The _meta Header

Every YAML file must begin with a _meta block:

_meta:
  schema: maid:room:v1
  description: "Village rooms"
  author: "Your Name"

The schema field tells the pipeline which entity type this file defines and which schema version to validate against. Available schemas:

Schema Entity type Top-level key
maid:room:v1 Room rooms:
maid:npc:v1 NPC npcs:
maid:item:v1 Item items:

Entity Definition Structure

Each entity is a mapping under the top-level key, identified by a snake_case ID:

rooms:
  village_square:      # <-- This is the entity ID
    _uuid: "..."       # Optional stable UUID
    components:        # Required: component data
      DescriptionComponent:
        name: "Village Square"
        short_desc: "A quiet village square"
        long_desc: "A detailed description..."
    tags: [room, outdoor, safe_zone]
    attributes:
      is_start_room: true

Key fields:

Field Required Description
_uuid No Stable UUID string — set this for entities you
reference from Python code. If omitted, the pipeline
generates a deterministic UUID from the pack name
and entity ID.
components Yes Dict of component class names → component data.
tags No List of string tags for querying and filtering.
attributes No Arbitrary key-value metadata.
exits No Rooms only — direction → @ref:room/id mapping.
zone No Rooms only — zone identifier string.
location No NPCs/items — @ref:room/id placement.

The @ref: System

Content files reference other entities using @ref: expressions:

# Reference another entity in the same pack
exits:
  north: "@ref:room/general_store"

# Place an NPC in a room
location: "@ref:room/village_square"

The syntax is:

@ref:<entity_type>/<entity_id>

During loading, the pipeline's resolve_refs phase replaces these strings with actual UUIDs. This means you never need to hard-code UUIDs in your YAML — just use meaningful IDs.

Cross-pack references

To reference entities in another content pack, use the extended syntax: @ref:<pack_name>:<type>/<id>. See Chapter 14 for details.


Chapter 3: Creating Rooms

Rooms are the foundation of your MUD world. Each room is a location players can visit, with a description, exits to other rooms, and optional metadata.

Step-by-Step: Your First Room File

Create (or edit) data/rooms.yaml:

_meta:
  schema: maid:room:v1
  description: "My world rooms"
  author: "Your Name"

rooms:
  town_square:
    components:
      DescriptionComponent:
        name: "Town Square"
        short_desc: "The bustling center of town"
        long_desc: |-
          You stand in the center of a lively town square. A weathered
          stone fountain splashes cheerfully nearby. Market stalls line
          the eastern edge, and the imposing Town Hall rises to the north.

          Cobblestone paths lead in every direction.
    tags:
      - room
      - outdoor
      - safe_zone
      - start_room
    exits:
      north: "@ref:room/town_hall"
      east: "@ref:room/market"
      south: "@ref:room/south_road"
      west: "@ref:room/tavern"
    zone: town_center
    attributes:
      is_start_room: true

Room Components

Every room needs at least a DescriptionComponent. The ExtendedRoomComponent is optional and enables dynamic descriptions (time of day, weather, seasons) — but only add it when you populate it (see the warning below). The static rooms in this tutorial use DescriptionComponent alone.

DescriptionComponent

The main component that defines what players see:

DescriptionComponent:
  name: "Town Square"             # Room title shown in the prompt
  short_desc: "The center of town"  # One-line summary (for brief mode)
  long_desc: |-                   # Full description (shown on first visit
    A detailed, multi-line           # or when player types 'look')
    description of the room...

Use YAML block scalars for long descriptions

  • |- (literal strip) preserves line breaks, strips trailing newline
  • | (literal keep) preserves line breaks and trailing newline
  • >- (folded strip) joins lines with spaces — good for wrapping prose

ExtendedRoomComponent

Enables dynamic room features (time/weather/season variants, mood, atmosphere, random details). It is optional — omit it for rooms with a static description.

Important: When a room entity has an ExtendedRoomComponent, the room renderer uses it instead of (not in addition to) the DescriptionComponent, reading the extended component's descriptions.base_description as the main room text. An empty ExtendedRoomComponent: {} has an empty base description, so the room renders blank — it shadows the long_desc you set on the DescriptionComponent. Only declare ExtendedRoomComponent when you populate it.

For a static room, use DescriptionComponent alone (as shown above) and do not declare an ExtendedRoomComponent.

To use dynamic features, populate the component. In YAML the loader validates it against its real schema, which nests the rich fields under a descriptions object (the top-level keys are not time_descriptions/atmosphere), and base_description supplies the main room text:

ExtendedRoomComponent:
  descriptions:
    base_description: |-
      You stand in the center of a lively town square. A weathered
      stone fountain splashes cheerfully nearby.
    time_variants:
      night: "Lanterns cast dancing shadows across the cobblestones."
    weather_effects:
      rain: "Rain patters on the cobblestones, creating puddles."
    mood: "peaceful"
    atmosphere_text: "The sounds of daily commerce fill the air."

time_variants, season_variants, and weather_effects use the lowercase TimeOfDay/Season/Weather enum values as keys (e.g. night, winter, rain).

You can also build the same data in-game (BUILDER access level) with the builder commands:

@room.desc.time night = Lanterns cast dancing shadows across the cobblestones.
@room.desc.weather rain = Rain patters on the cobblestones, creating puddles.
@room.mood here peaceful
@room.atmosphere here The sounds of daily commerce fill the air.

To build the same data programmatically, construct ExtendedRoomComponent(descriptions=ExtendedDescriptions(...)) with TimeOfDay/Weather/Season enum keys (time_variants, weather_effects, season_variants, mood, atmosphere_text).

See the Extended Rooms Guide for the full feature set.

Exits and Connections

Exits connect rooms together. Each exit maps a direction to a room reference:

exits:
  north: "@ref:room/town_hall"
  east: "@ref:room/market"
  south: "@ref:room/south_road"
  west: "@ref:room/tavern"
  up: "@ref:room/clock_tower"
  down: "@ref:room/cellar"

Standard directions: north, south, east, west, northeast, northwest, southeast, southwest, up, down.

Exits are one-way by default

If room A has north: @ref:room/B, room B does not automatically get a south exit back to A. You must define exits in both rooms.

Zones and Areas

The zone field groups rooms into logical areas. Zones are useful for:

  • Area-level access control
  • Regional weather or effects
  • Admin commands (@wipe zone, @find <type> zone:<name>)
  • Map generation
rooms:
  town_square:
    zone: town_center
    # ...

  town_hall:
    zone: town_center
    # ...

  forest_entrance:
    zone: dark_forest
    # ...

Practical Example: A Connected Village

Here's a complete rooms.yaml with four connected rooms:

_meta:
  schema: maid:room:v1
  description: "Thornfield Village rooms"
  author: "Your Name"

rooms:
  # ------------------------------------------------------------------
  # VILLAGE CENTER
  # ------------------------------------------------------------------
  village_square:
    _uuid: "00000000-0001-0001-0001-000000000001"
    components:
      DescriptionComponent:
        name: "Village Square"
        short_desc: "The heart of Thornfield Village"
        long_desc: |-
          You stand in the heart of Thornfield Village. A moss-covered
          stone well sits at the center of a cobblestone plaza. Timber-
          framed buildings crowd around the square, their upper stories
          leaning out over the street as if whispering secrets to each
          other.

          The General Store lies to the north. An old tavern with a
          creaking sign stands to the west. A dirt path leads south
          toward the village gate.
    tags: [room, outdoor, safe_zone, start_room]
    exits:
      north: "@ref:room/general_store"
      west: "@ref:room/tavern"
      south: "@ref:room/village_gate"
    zone: thornfield_village
    attributes:
      is_start_room: true

  # ------------------------------------------------------------------
  # GENERAL STORE
  # ------------------------------------------------------------------
  general_store:
    _uuid: "00000000-0001-0001-0001-000000000002"
    components:
      DescriptionComponent:
        name: "General Store"
        short_desc: "A cluttered shop smelling of cedar and leather"
        long_desc: |-
          Shelves line every wall of this cramped shop, stacked high
          with rope, lanterns, dried rations, and the hundred other
          necessities of frontier life. A worn wooden counter runs
          along the back, its surface scarred by decades of coins
          and barter goods.

          The door south leads back to the village square.
    tags: [room, indoor, safe_zone, shop]
    exits:
      south: "@ref:room/village_square"
    zone: thornfield_village
    attributes:
      shop_type: general

  # ------------------------------------------------------------------
  # TAVERN
  # ------------------------------------------------------------------
  tavern:
    _uuid: "00000000-0001-0001-0001-000000000003"
    components:
      DescriptionComponent:
        name: "The Rusty Flagon"
        short_desc: "A warm tavern with a crackling fireplace"
        long_desc: |-
          Warmth and the smell of roasting meat greet you as you push
          through the heavy oak door. A stone fireplace dominates the
          far wall, its flames casting a flickering orange glow across
          rough-hewn tables and benches. A long bar stretches along
          one side, bottles of varying opacity lined up behind it.

          The village square is back to the east.
    tags: [room, indoor, safe_zone, tavern]
    exits:
      east: "@ref:room/village_square"
    zone: thornfield_village

  # ------------------------------------------------------------------
  # VILLAGE GATE
  # ------------------------------------------------------------------
  village_gate:
    _uuid: "00000000-0001-0001-0001-000000000004"
    components:
      DescriptionComponent:
        name: "Village Gate"
        short_desc: "A wooden gate at the edge of the village"
        long_desc: |-
          A sturdy wooden gate marks the southern boundary of Thornfield
          Village. Beyond it, a dirt road winds into the shadows of the
          Darkwood Forest. The gate hangs open during the day but is
          barred at night.

          The village square lies to the north.
    tags: [room, outdoor, village_boundary]
    exits:
      north: "@ref:room/village_square"
    zone: thornfield_village
    attributes:
      area_boundary: true

ASCII map of this layout

              [General Store]
                    |
[The Rusty Flagon]--[Village Square]
                    |
              [Village Gate]

Chapter 4: Creating NPCs

NPCs bring your world to life. They can be shopkeepers, quest givers, guards, enemies, or ambient characters. MAID NPCs support AI-powered dialogue out of the box.

Adding NPCs to npcs.yaml

Create (or edit) data/npcs.yaml:

_meta:
  schema: maid:npc:v1
  description: "Thornfield Village NPCs"
  author: "Your Name"

npcs:
  shopkeeper:
    components:
      DescriptionComponent:
        name: "Greta the Shopkeeper"
        short_desc: "A stout woman with flour-dusted hands"
        long_desc: |
          Greta is a broad-shouldered woman with calloused hands and
          a no-nonsense expression. Her apron is perpetually dusted
          with flour from the bakery next door, where her husband
          works. She runs the General Store with brisk efficiency.
        keywords: ["greta", "shopkeeper", "merchant", "woman"]
      NPCComponent:
        behavior_type: "friendly"
        is_merchant: true
        dialogue_id: "shopkeeper_greta"
        wander_radius: 0
      DialogueComponent:
        ai_enabled: true
        personality: >-
          Practical, no-nonsense shopkeeper. Secretly kind-hearted
          but hides it behind a gruff exterior. Very protective of
          the village.
        speaking_style: >-
          Direct and to the point. Uses short sentences. Occasionally
          lets warmth slip through. Calls everyone 'dear' when she
          forgets to be gruff.
        npc_role: "shopkeeper at the General Store in Thornfield Village"
        knowledge_domains:
          - "adventuring supplies"
          - "village gossip"
          - "local history"
        greeting: "*looks up from her ledger* What'll it be?"
        farewell: "*nods* Don't be a stranger."
        max_response_tokens: 150
        temperature: 0.7
      HealthComponent:
        current: 25
        maximum: 25
    tags: [npc, friendly, merchant, village]
    location: "@ref:room/general_store"

NPC Components

DescriptionComponent

Same as rooms — defines what players see when they look at the NPC:

DescriptionComponent:
  name: "Greta the Shopkeeper"       # Name shown in room descriptions
  short_desc: "A stout woman..."     # One-line summary
  long_desc: |                       # Full description for 'look <npc>'
    Detailed description...
  keywords: ["greta", "shopkeeper"]  # Words players can use to target this NPC

Keywords are important

Players interact with NPCs by name. The keywords list defines what strings match this NPC. Always include the NPC's first name, role, and any obvious descriptors.

NPCComponent

Core NPC metadata:

NPCComponent:
  behavior_type: "friendly"     # friendly, neutral, hostile, passive
  is_merchant: true             # Whether this NPC can trade
  dialogue_id: "shopkeeper_greta"  # Unique dialogue identifier
  faction_id: "thornfield"     # Optional faction affiliation
  wander_radius: 0             # 0 = stays in place, >0 = wanders

DialogueComponent (AI-Powered)

This is where MAID's AI integration shines. The DialogueComponent configures how the NPC responds to player conversation:

DialogueComponent:
  ai_enabled: true                    # Enable AI-powered responses
  personality: >-                     # Who is this NPC?
    A gruff but kind-hearted shopkeeper...
  speaking_style: >-                  # How do they talk?
    Short sentences, direct, occasionally warm...
  npc_role: "shopkeeper at the General Store"
  knowledge_domains:                  # What can they discuss?
    - "adventuring supplies"
    - "village gossip"
  secret_knowledge:                   # What they know but are cautious about
    - "heard wolves howling near the old mine"
  will_discuss:                       # Topics they'll freely discuss
    - "goods for sale"
    - "weather"
  wont_discuss:                       # Off-limits topics
    - "the war"
    - "her past"
  greeting: "*looks up* What'll it be?"
  farewell: "*nods* Safe travels."
  fallback_response: "*shrugs* Can't help you with that."
  max_response_tokens: 150            # Limit response length
  temperature: 0.7                    # LLM creativity (0.0–1.0)
  cooldown_seconds: 2.0               # Minimum time between responses

See the NPC Dialogue Guide for the full AI dialogue configuration reference.

AI providers

AI dialogue requires a configured LLM provider (Anthropic, OpenAI, or Ollama). Set MAID_AI_DEFAULT_PROVIDER and the appropriate API key. See the AI Configuration Reference.

HealthComponent

Gives the NPC hit points:

HealthComponent:
  current: 25
  maximum: 25

StatsComponent

For NPCs that participate in combat or skill checks:

StatsComponent:
  strength: 10
  dexterity: 12
  constitution: 11
  intelligence: 14
  wisdom: 16
  charisma: 15
  level: 3

Placing NPCs in Rooms

Use the location field with an @ref: to place the NPC:

npcs:
  shopkeeper:
    # ... components ...
    location: "@ref:room/general_store"

The NPC will appear in that room when the content pack loads.

Example: A Shopkeeper and a Guard

_meta:
  schema: maid:npc:v1
  description: "Thornfield Village NPCs"
  author: "Your Name"

npcs:
  # ------------------------------------------------------------------
  # SHOPKEEPER — friendly merchant in the General Store
  # ------------------------------------------------------------------
  shopkeeper:
    _uuid: "00000000-0001-0001-0002-000000000001"
    components:
      DescriptionComponent:
        name: "Greta the Shopkeeper"
        short_desc: "A stout woman with flour-dusted hands tends the counter."
        long_desc: |
          Greta is a broad-shouldered woman with calloused hands and a
          no-nonsense expression. Her apron is perpetually dusted with
          flour from the bakery next door.
        keywords: ["greta", "shopkeeper", "merchant", "woman", "storekeeper"]
      NPCComponent:
        behavior_type: "friendly"
        is_merchant: true
        dialogue_id: "shopkeeper_greta"
        wander_radius: 0
      DialogueComponent:
        ai_enabled: true
        personality: >-
          Practical, no-nonsense. Secretly kind-hearted but hides it
          behind gruffness. Very protective of the village.
        speaking_style: >-
          Direct and brief. Calls everyone 'dear' when she forgets
          to be gruff.
        npc_role: "shopkeeper at the General Store in Thornfield Village"
        knowledge_domains:
          - "adventuring supplies"
          - "village history"
          - "local gossip"
        greeting: "*looks up from her ledger* What'll it be?"
        farewell: "*nods* Don't be a stranger."
        max_response_tokens: 150
        temperature: 0.7
      HealthComponent:
        current: 25
        maximum: 25
    tags: [npc, friendly, merchant, village]
    location: "@ref:room/general_store"

  # ------------------------------------------------------------------
  # GUARD — patrols near the village gate
  # ------------------------------------------------------------------
  gate_guard:
    _uuid: "00000000-0001-0001-0002-000000000002"
    components:
      DescriptionComponent:
        name: "Henrik the Gate Guard"
        short_desc: "A broad-shouldered guard leans on his spear, watching the road."
        long_desc: |
          Henrik is a tall, broad-shouldered man in a chainmail hauberk
          that has seen better days. A dented iron helm sits slightly
          askew on his head, and a heavy spear rests against his
          shoulder. Despite his imposing frame, his eyes are kind, and
          he greets travelers with a tired smile.
        keywords: ["henrik", "guard", "soldier", "gate guard"]
      NPCComponent:
        behavior_type: "neutral"
        is_merchant: false
        dialogue_id: "guard_henrik"
        wander_radius: 0
      DialogueComponent:
        ai_enabled: true
        personality: >-
          A dutiful but weary guard. Served in the border wars and is
          now content with quiet village duty. Worries about the
          growing boldness of creatures in the Darkwood.
        speaking_style: >-
          Measured and careful. Military cadence creeps into his speech.
          Sighs often.
        npc_role: "gate guard at Thornfield Village"
        knowledge_domains:
          - "local threats"
          - "village defenses"
          - "the Darkwood Forest"
        greeting: "*nods* Heading out, or just passing through?"
        farewell: "Stay sharp out there."
        max_response_tokens: 150
        temperature: 0.7
      HealthComponent:
        current: 40
        maximum: 40
      StatsComponent:
        strength: 14
        dexterity: 12
        constitution: 15
        intelligence: 10
        wisdom: 12
        charisma: 11
        level: 5
    tags: [npc, neutral, guard, village]
    location: "@ref:room/village_gate"

Chapter 5: Creating Items

Items are things players can pick up, use, equip, or interact with: weapons, potions, keys, quest objects, and more.

Adding Items to items.yaml

Create (or edit) data/items.yaml:

_meta:
  schema: maid:item:v1
  description: "Thornfield Village items"
  author: "Your Name"

items:
  iron_sword:
    components:
      DescriptionComponent:
        name: "Iron Sword"
        short_desc: "A well-balanced iron sword"
        long_desc: |
          A straight-bladed sword forged from good iron. The edge is
          keen and the balance is true. A simple leather grip wraps
          the tang, secured with copper wire. Nothing fancy, but a
          reliable weapon for any adventurer.
        keywords: ["sword", "iron", "blade", "weapon"]
      ItemComponent:
        item_type: "weapon"
        quality: "common"
        weight: 3.5
        value: 25
        stack_count: 1
        max_stack: 1
        durability: 60
        max_durability: 60
      CombatComponent:
        attack_power: 12
        accuracy: 75
        speed: 12
        critical_chance: 8
    tags: [item, weapon, quality_common]
    location: "@ref:room/general_store"

Item Components

ItemComponent

Core item metadata:

ItemComponent:
  item_type: "weapon"       # weapon, armor, potion, quest_item, material, etc.
  quality: "common"         # common, uncommon, rare, epic, legendary
  weight: 3.5               # Weight in arbitrary units
  value: 25                 # Base value in currency
  stack_count: 1            # Current stack size
  max_stack: 1              # Max stack size (1 = non-stackable)
  durability: 60            # Current durability
  max_durability: 60        # Maximum durability

CombatComponent

For weapons and armor:

# Weapon
CombatComponent:
  attack_power: 12
  accuracy: 75
  speed: 12
  critical_chance: 8

# Armor (defense stats)
CombatComponent:
  defense: 8
  evasion: 5

Item Types

Here are examples of common item types:

Weapons

items:
  rusty_dagger:
    components:
      DescriptionComponent:
        name: "Rusty Dagger"
        short_desc: "A small, rusty dagger"
        long_desc: "A short blade, pitted with rust but still sharp enough to cut."
        keywords: ["dagger", "rusty", "knife", "blade"]
      ItemComponent:
        item_type: "weapon"
        quality: "common"
        weight: 1.0
        value: 5
      CombatComponent:
        attack_power: 5
        accuracy: 80
        speed: 15
        critical_chance: 12
    tags: [item, weapon, quality_common]

Potions

items:
  healing_potion:
    components:
      DescriptionComponent:
        name: "Healing Potion"
        short_desc: "A small vial of glowing red liquid"
        long_desc: |
          A thumb-sized glass vial filled with a luminous red liquid.
          It smells faintly of herbs and honey.
        keywords: ["potion", "healing", "vial", "red"]
      ItemComponent:
        item_type: "potion"
        quality: "common"
        weight: 0.2
        value: 10
        stack_count: 1
        max_stack: 10
    tags: [item, potion, consumable, healing]

Quest Items

items:
  old_locket:
    components:
      DescriptionComponent:
        name: "Old Locket"
        short_desc: "A tarnished silver locket on a broken chain"
        long_desc: |
          A small silver locket, its surface dark with tarnish. The
          chain is broken. Inside is a faded portrait of a young woman
          and a lock of auburn hair.
        keywords: ["locket", "silver", "necklace", "old"]
      ItemComponent:
        item_type: "quest_item"
        quality: "uncommon"
        weight: 0.1
        value: 0
    tags: [item, quest_item, unique]

Placing Items

Use location to place items in rooms:

items:
  iron_sword:
    # ... components ...
    location: "@ref:room/general_store"

  healing_potion:
    # ... components ...
    location: "@ref:room/tavern"

Items without a location are unplaced entities — they exist in the data but aren't assigned to any room. They can be spawned or moved into rooms later by systems or commands.

Complete Items Example

_meta:
  schema: maid:item:v1
  description: "Thornfield Village items"
  author: "Your Name"

items:
  # ------------------------------------------------------------------
  # WEAPONS
  # ------------------------------------------------------------------
  iron_sword:
    _uuid: "00000000-0001-0001-0003-000000000001"
    components:
      DescriptionComponent:
        name: "Iron Sword"
        short_desc: "A well-balanced iron sword"
        long_desc: |
          A straight-bladed sword forged from good iron. Reliable,
          if unremarkable.
        keywords: ["sword", "iron", "blade", "weapon"]
      ItemComponent:
        item_type: "weapon"
        quality: "common"
        weight: 3.5
        value: 25
        durability: 60
        max_durability: 60
      CombatComponent:
        attack_power: 12
        accuracy: 75
        speed: 12
        critical_chance: 8
    tags: [item, weapon, quality_common]
    location: "@ref:room/general_store"

  # ------------------------------------------------------------------
  # POTIONS
  # ------------------------------------------------------------------
  healing_potion:
    _uuid: "00000000-0001-0001-0003-000000000002"
    components:
      DescriptionComponent:
        name: "Healing Potion"
        short_desc: "A vial of glowing red liquid"
        long_desc: "A luminous red potion that smells of herbs and honey."
        keywords: ["potion", "healing", "vial", "red"]
      ItemComponent:
        item_type: "potion"
        quality: "common"
        weight: 0.2
        value: 10
        stack_count: 3
        max_stack: 10
    tags: [item, potion, consumable, healing]
    location: "@ref:room/general_store"

  # ------------------------------------------------------------------
  # QUEST ITEMS
  # ------------------------------------------------------------------
  old_locket:
    _uuid: "00000000-0001-0001-0003-000000000003"
    components:
      DescriptionComponent:
        name: "Old Locket"
        short_desc: "A tarnished silver locket on a broken chain"
        long_desc: |
          A small silver locket. Inside is a faded portrait and a
          lock of auburn hair.
        keywords: ["locket", "silver", "necklace", "old"]
      ItemComponent:
        item_type: "quest_item"
        quality: "uncommon"
        weight: 0.1
        value: 0
    tags: [item, quest_item, unique]
    location: "@ref:room/tavern"

Chapter 6: Validation

Before loading your content into the engine, validate it to catch errors early.

Validating Content

Run the validator from your project directory, passing the path to your data files (a directory is scanned recursively):

uv run maid data validate data/

data validate runs the first four pipeline phases (discover → parse → prepare → resolve refs) plus the semantic rules, and prints a summary:

Validation passed (0 warnings)

If problems are found it lists them and exits non-zero:

Validation failed (2 errors)

Use uv run maid data validate --list-rules to print every semantic rule (ID, severity, and what it applies to). Pass --lenient to downgrade strict failures, or --skip-rule <ID> / --skip-rules a,b to suppress specific rules.

Common Validation Errors

Missing component fields

ERROR [MAID-PRE010] data/rooms.yaml:12 — DescriptionComponent missing
required field 'name'

Fix: Add the required field to the component.

Broken references

ERROR [MAID-R001] data/npcs.yaml:25 — Unresolved reference
'@ref:room/blacksmith' — no room with id 'blacksmith' found

Fix: Check that the referenced entity ID exists in your rooms file. Entity IDs are case-sensitive and must match exactly.

Health exceeds maximum

ERROR [MAID-S001] data/npcs.yaml:42 — HealthComponent.current must be
<= HealthComponent.maximum

Fix: Ensure current does not exceed maximum in HealthComponent.

Room with no exits

WARNING [MAID-S005] data/rooms.yaml:60 — Room has no exits; players may
become trapped

Fix: Add at least one exit, or suppress the warning if the room is intentionally isolated (e.g., a jail cell).

Invalid schema

ERROR [MAID-PRE011] data/rooms.yaml:2 — Unknown schema 'maid:room:v2'

Fix: Use a valid schema identifier (e.g., maid:room:v1).

Linting Content

data lint runs the lightweight discover + parse phases only (no reference resolution or semantic rules) and reports any YAML/parse warnings and errors. It requires a path:

uv run maid data lint data/

Typical output:

No lint issues found

or, when a file has problems, the discover/parse diagnostics (MAID-Y* / MAID-D* codes):

MAID-Y001 data/rooms.yaml:12 Unquoted YAML 1.1 boolean literal
MAID-D007 data/npcs.yaml File not listed in load_order; loading by convention order

lint is not a style checker. It does not enforce naming conventions, description quality, tag consistency, or "unused entity" detection — those checks do not exist. The richer structural/semantic rules (MAID-S*, MAID-R*) run under data validate, not data lint.

Run both validate and lint

data lint data/ is a fast YAML/parse sanity check; data validate data/ runs the full structural and referential rule set. Use validate before loading content.


Chapter 7: Testing Your World

Loading Your Content Pack

Your content pack needs to be loaded by the engine. There are two main approaches:

1. Install your pack as a dependency (recommended for development):

In your MAID server's pyproject.toml, add your pack:

[tool.uv.sources]
my-world = { path = "../my-world", editable = true }

Then in your server startup code:

from maid_engine.core.engine import GameEngine
from maid_stdlib.pack import StdlibContentPack
from my_world.pack import MyWorldContentPack

engine = GameEngine(settings)
engine.load_content_pack(StdlibContentPack())
engine.load_content_pack(MyWorldContentPack())
await engine.start()

2. Minimal template — for YAML-only packs, point the engine at your data directory directly.

Starting the Server

uv run maid server start

Connect via telnet:

telnet localhost 4000

Or open the web client at http://localhost:8080/play/.

Testing Rooms

Once connected, try these commands:

look                    # See the current room description
north                   # Move north (or any direction with an exit)
south                   # Move back
look town_hall          # Look at a specific thing

Verify:

  • Room descriptions display correctly
  • Exits work in both directions
  • Zone names are correct

Testing NPCs

look greta              # Examine an NPC
talk greta Hello!       # Talk to an AI-powered NPC
ask greta about supplies  # Ask about a topic

Verify:

  • NPCs appear in the correct rooms
  • AI dialogue works (if configured)
  • NPC descriptions look right

Testing Items

look sword              # Examine an item
get sword               # Pick up an item
inventory               # Check your inventory
drop sword              # Drop an item

Using Builder Commands

Builder commands let you inspect entities in detail. These require builder access level:

@examine village_square         # Detailed entity inspection
@examine greta                  # Inspect an NPC
@stat greta                     # Quick stats summary
@find npc                       # Find all NPCs
@find item zone:thornfield_village  # Find items in a zone (zone: filter)

See the Building Commands Reference for the full set of builder commands.


Chapter 8: Schema Support

MAID provides schema tooling to make content authoring easier and less error-prone.

Listing Available Schemas

uv run maid data schema list

This shows all registered component types with a short description.

Viewing Schema Details

uv run maid data schema show RoomComponent

Pass a component class name (as shown by schema list) to display its fields, types, defaults, and whether each field is required.

Exporting Schemas

Export schemas in machine-readable format for use with external tooling:

uv run maid data schema export ./schemas

schema export requires a target directory — JSON Schema files for every entity type are written there for use with linters, CI pipelines, or documentation generators.

IDE Autocomplete

Set up YAML autocomplete in your IDE:

uv run maid data schema setup-ide

This generates schema files and configures your editor for autocompletion when editing MAID YAML files. For VS Code, this creates .vscode/settings.json entries that associate your YAML files with the appropriate JSON schemas.

VS Code + YAML extension

Install the YAML extension by Red Hat. After running setup-ide, you'll get autocomplete, validation, and hover documentation for all MAID YAML files.

Benefits of Schema Validation

  • Catch errors early — typos, missing fields, type mismatches
  • Autocomplete — your editor knows what fields are available
  • Documentation — schemas serve as living documentation
  • Versioning — schemas version independently, enabling migration

Chapter 9: Reference

Guides and Documentation

Guide Description
Content Packs Overview Content pack architecture
Creating Content Packs Detailed pack creation guide
ECS Overview Entity Component System architecture
Command System Guide Commands, arguments, hooks, locks
Events Guide Event system and custom events
NPC Dialogue Guide AI-powered NPC dialogue
Extended Rooms Guide Dynamic room descriptions
Building Commands In-game building command reference

Component Reference

Common components used in content files:

Component Used by Purpose
DescriptionComponent All entities Name, descriptions, keywords
ExtendedRoomComponent Rooms Dynamic descriptions, mood
NPCComponent NPCs Behavior, faction, merchant flag
DialogueComponent NPCs AI dialogue configuration
HealthComponent NPCs, players Hit points
StatsComponent NPCs, players Ability scores and level
ItemComponent Items Type, quality, weight, value
CombatComponent Items, NPCs Attack/defense stats
InventoryComponent NPCs, players Inventory contents
PositionComponent All entities Grid position (if using grid)

Validation Rule Reference

The pipeline includes built-in validation rules:

Code Severity Description
MAID-PRE011 Error Unknown or invalid schema identifier
MAID-PRE010 Error Component validation failed (e.g. missing required field)
MAID-R001 Error Unresolved @ref: reference
MAID-S001 Error Health current exceeds maximum
MAID-S005 Warning Room has no exits
MAID-Y001–Y012 Warning YAML style lint issues

Part II — Hybrid Packs: Adding Python Logic

YAML defines what exists in your world. Python defines how it behaves. Part II shows how to add custom game logic alongside your YAML content.


Chapter 10: Adding Custom Systems

When You Need Python

YAML handles static content well, but you'll need Python for:

  • Game logic that runs every tick (weather, respawning, regeneration)
  • Responding to events (combat, movement, chat)
  • Stateful processes (quest tracking, cooldowns)
  • Anything that changes over time

Writing an ECS System

All systems inherit from maid_engine.core.ecs.system.System:

"""Weather system — changes weather conditions over time."""

from __future__ import annotations

import random
from typing import TYPE_CHECKING, ClassVar

from maid_engine.core.ecs.system import System

if TYPE_CHECKING:
    from maid_engine.core.world import World


class WeatherSystem(System):
    """Periodically changes the weather in outdoor rooms."""

    # Lower priority = runs earlier in the tick
    priority: ClassVar[int] = 200

    def __init__(self, world: World) -> None:
        super().__init__(world)
        self._weather: str = "clear"
        self._time_until_change: float = 300.0  # 5 minutes
        self._conditions: list[str] = [
            "clear", "cloudy", "rain", "storm", "fog", "wind",
        ]

    async def startup(self) -> None:
        """Called when the system starts."""
        self.world.set_data("current_weather", self._weather)

    async def update(self, delta: float) -> None:
        """Called every game tick.

        Args:
            delta: Time since last tick in seconds.
        """
        self._time_until_change -= delta
        if self._time_until_change <= 0:
            self._weather = random.choice(self._conditions)
            self.world.set_data("current_weather", self._weather)
            self._time_until_change = random.uniform(120.0, 600.0)

    async def shutdown(self) -> None:
        """Called when the system stops."""
        pass

Key concepts:

  • priority — controls execution order. Lower values run first.
  • update(delta) — called every game tick. delta is seconds since last tick.
  • startup() / shutdown() — called when the system starts/stops.
  • self.world — access the World instance.
  • self.entities — shortcut to the EntityManager.
  • self.events — shortcut to the EventBus.

Querying Entities

Systems query entities through the EntityManager:

async def update(self, delta: float) -> None:
    # Get all entities with a specific component
    for entity in self.entities.with_components(HealthComponent):
        health = entity.get(HealthComponent)
        if health.current < health.maximum:
            health.current = min(health.current + 1, health.maximum)

    # Get entities by tag
    for entity in self.entities.with_tag("respawnable"):
        # Process respawnable entities...
        pass

Registering Systems

Register your system in your content pack's get_systems() method:

# In pack.py
def get_systems(self, world: World) -> list[System]:
    from my_world.systems.weather import WeatherSystem

    return [
        WeatherSystem(world),
    ]

The engine calls get_systems() during content pack loading and registers each system with the SystemManager.

See the ECS Systems Guide and the Content Packs Systems Guide for more details.

Example: A Respawn System

"""Respawn system — respawns dead NPCs after a cooldown."""

from __future__ import annotations

import time
from typing import TYPE_CHECKING, ClassVar

from maid_engine.core.ecs.system import System
from maid_stdlib.components import HealthComponent

if TYPE_CHECKING:
    from uuid import UUID

    from maid_engine.core.world import World


class RespawnSystem(System):
    """Respawns NPCs that have been killed after a configurable delay."""

    priority: ClassVar[int] = 300

    def __init__(self, world: World, respawn_delay: float = 60.0) -> None:
        super().__init__(world)
        self._respawn_delay = respawn_delay
        self._death_times: dict[UUID, float] = {}

    async def update(self, delta: float) -> None:
        now = time.monotonic()

        # Check for newly dead NPCs
        for entity in self.entities.with_tag("respawnable"):
            health = entity.try_get(HealthComponent)
            if health and health.current <= 0:
                entity_id = entity.id
                if entity_id not in self._death_times:
                    self._death_times[entity_id] = now

        # Respawn NPCs whose timers have expired
        to_respawn = [
            eid for eid, death_time in self._death_times.items()
            if now - death_time >= self._respawn_delay
        ]

        for entity_id in to_respawn:
            entity = self.entities.get(entity_id)
            if entity:
                health = entity.try_get(HealthComponent)
                if health:
                    health.current = health.maximum
            del self._death_times[entity_id]

Chapter 11: Adding Custom Commands

Commands are how players interact with your world. MAID provides decorators for declarative argument parsing.

Creating a Command Handler

Every command handler is an async function that receives a CommandContext:

"""Custom forage command — lets players gather herbs in outdoor rooms."""

from __future__ import annotations

import random
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from maid_engine.commands import CommandContext


async def cmd_forage(ctx: CommandContext) -> bool:
    """Search the area for useful herbs and materials.

    Usage: forage
    """
    # Check if player is in an outdoor room
    room_id = ctx.world.get_entity_room(ctx.player_id)
    if room_id is None:
        await ctx.session.send("You can't forage here.\n")
        return False

    room = ctx.world.get_room(room_id)
    if not room:
        await ctx.session.send("You can't forage here.\n")
        return False

    room_entity = ctx.world.entities.get(room_id)
    if not room_entity or "outdoor" not in room_entity.tags:
        await ctx.session.send("You can only forage in outdoor areas.\n")
        return False

    # Random forage result
    results = [
        "You find some wild herbs growing beside the path.",
        "You discover a patch of edible mushrooms.",
        "You gather a handful of useful berries.",
        "After searching for a while, you find nothing of use.",
    ]
    await ctx.session.send(random.choice(results) + "\n")
    return True

The @arguments Decorator

For commands that need structured argument parsing, use @arguments:

from maid_engine.commands.decorators import arguments
from maid_engine.commands.arguments import ArgumentSpec, ArgumentType, ParsedArguments


@arguments(
    ArgumentSpec("target", ArgumentType.ENTITY),
    ArgumentSpec("count", ArgumentType.INTEGER, required=False, default=1),
)
async def cmd_give_gold(ctx: CommandContext, args: ParsedArguments) -> bool:
    """Give gold to another character.

    Usage: givegold <target> [amount]
    """
    target = args["target"]
    count = args["count"]
    await ctx.session.send(f"You give {count} gold to {target}.\n")
    return True

Argument types include:

Type Description
ArgumentType.STRING Free-form text
ArgumentType.INTEGER Whole number
ArgumentType.FLOAT Decimal number
ArgumentType.ENTITY Entity reference (resolved)
ArgumentType.DIRECTION Cardinal direction
ArgumentType.REST Remaining input as text

The @pattern Decorator

For commands with complex syntax like "give X to Y":

from maid_engine.commands.decorators import pattern
from maid_engine.commands.arguments import (
    ArgumentSpec,
    ArgumentType,
    ParsedArguments,
    SearchScope,
)


@pattern(
    "<item> to <target>",
    item=ArgumentSpec(
        "item",
        ArgumentType.ENTITY,
        search_scope=SearchScope.INVENTORY,
    ),
    target=ArgumentSpec("target", ArgumentType.ENTITY),
)
async def cmd_give(ctx: CommandContext, args: ParsedArguments) -> bool:
    """Give an item to another character.

    Usage: give <item> to <target>
    """
    item = args["item"]
    target = args["target"]
    await ctx.session.send(f"You give {item} to {target}.\n")
    return True

Registering Commands

Register commands in your content pack's register_commands() method:

# In pack.py
def register_commands(self, registry: LayeredCommandRegistry) -> None:
    from maid_stdlib.commands import register_stdlib_commands
    register_stdlib_commands(registry)

    from my_world.commands.forage import cmd_forage
    from my_world.commands.trading import cmd_give_gold

    registry.register("forage", cmd_forage, pack_name="my-world")
    registry.register("givegold", cmd_give_gold, pack_name="my-world")

See the Command System Guide for the full feature set, including hooks, locks, and permission expressions.


Chapter 12: Custom Events

Events are the backbone of inter-system communication in MAID. Instead of systems calling each other directly, they emit events that other systems subscribe to.

Defining Event Dataclasses

Events are frozen dataclasses that inherit from Event:

"""Custom events for the foraging system."""

from dataclasses import dataclass
from uuid import UUID

from maid_engine.core.events import Event


@dataclass
class ForageAttemptEvent(Event):
    """Emitted when a player attempts to forage.

    Attributes:
        player_id: UUID of the foraging player.
        room_id: UUID of the room where foraging occurred.
        success: Whether the forage attempt succeeded.
        item_found: ID of the item found, if any.
    """

    player_id: UUID
    room_id: UUID
    success: bool
    item_found: str | None = None


@dataclass
class WeatherChangedEvent(Event):
    """Emitted when the weather changes.

    Attributes:
        previous: The previous weather condition.
        current: The new weather condition.
    """

    previous: str
    current: str

Emitting Events

Emit events from systems or commands using the EventBus:

# In a system's update() method
async def update(self, delta: float) -> None:
    if weather_changed:
        await self.events.emit(WeatherChangedEvent(
            previous=old_weather,
            current=new_weather,
        ))

# In a command handler
async def cmd_forage(ctx: CommandContext) -> bool:
    success = random.random() > 0.5
    room_id = ctx.world.get_entity_room(ctx.player_id)
    if room_id is None:
        return False
    await ctx.world.events.emit(ForageAttemptEvent(
        player_id=ctx.player_id,
        room_id=room_id,
        success=success,
        item_found="wild_herbs" if success else None,
    ))
    return True

Subscribing to Events

Subscribe to events in a system's startup() method:

class ForageTrackingSystem(System):
    """Tracks foraging statistics."""

    priority: ClassVar[int] = 250

    def __init__(self, world: World) -> None:
        super().__init__(world)
        self._forage_counts: dict[str, int] = {}

    async def startup(self) -> None:
        self.events.subscribe(
            ForageAttemptEvent,
            self._on_forage_attempt,
        )

    async def _on_forage_attempt(self, event: ForageAttemptEvent) -> None:
        player_key = str(event.player_id)
        self._forage_counts[player_key] = (
            self._forage_counts.get(player_key, 0) + 1
        )

    async def update(self, delta: float) -> None:
        pass  # This system is event-driven, no tick processing needed

Registering Events

Register your custom events in get_events():

# In pack.py
def get_events(self) -> list[type[Event]]:
    from my_world.events import ForageAttemptEvent, WeatherChangedEvent

    return [
        ForageAttemptEvent,
        WeatherChangedEvent,
    ]

This registers the event types with the EventBus so subscribers can be type-checked and the event system can validate handlers.

See the Events Guide and Custom Events Guide for more details.


Chapter 13: Custom Validation Rules

You can add your own validation rules to catch content errors specific to your world.

Writing a Semantic Rule

Validation rules are simple classes with a check() method. Each rule inspects an entity and returns a list of LoadError objects:

"""Custom validation rules for my world."""

from __future__ import annotations

from dataclasses import dataclass

from maid_engine.loader.models import ErrorSeverity, LoadError


@dataclass(slots=True, frozen=True)
class NPCLocationRequiredRule:
    """Ensure all NPCs have a location defined.

    Every NPC should be placed in a room. An NPC without a location
    will exist in the world but won't be visible to players.
    """

    id: str = "MY-S001"
    description: str = "NPCs must have a location"
    severity: ErrorSeverity = ErrorSeverity.WARNING
    applies_to: set[str] | None = None

    def check(self, entity: object, context: object) -> list[LoadError]:
        # Only check NPC entities
        if getattr(entity, "entity_type", None) != "npc":
            return []

        # Check for location in raw data
        raw_data = getattr(entity, "raw_data", {})
        location = raw_data.get("location")

        if not location:
            return [
                LoadError(
                    code=self.id,
                    file_path=str(getattr(entity, "source_file", "<unknown>")),
                    line=getattr(entity, "line", None),
                    field_path="location",
                    message="NPC has no location — it won't appear in any room",
                    severity=self.severity,
                    suggestion="Add 'location: \"@ref:room/<room_id>\"' to place this NPC",
                ),
            ]

        return []


@dataclass(slots=True, frozen=True)
class MerchantHasInventoryRule:
    """Ensure merchant NPCs have items in their shop room."""

    id: str = "MY-S002"
    description: str = "Merchant NPCs should have items available"
    severity: ErrorSeverity = ErrorSeverity.WARNING
    applies_to: set[str] | None = None

    def check(self, entity: object, context: object) -> list[LoadError]:
        if getattr(entity, "entity_type", None) != "npc":
            return []

        components = getattr(entity, "components", {})
        npc_comp = components.get("NPCComponent")
        if not npc_comp or not getattr(npc_comp, "is_merchant", False):
            return []

        # This is a warning — the builder should ensure
        # items exist in the merchant's room
        return [
            LoadError(
                code=self.id,
                file_path=str(getattr(entity, "source_file", "<unknown>")),
                line=getattr(entity, "line", None),
                field_path="components.NPCComponent.is_merchant",
                message="Merchant NPC — verify items exist in this NPC's room",
                severity=ErrorSeverity.WARNING,
                suggestion="Place items with 'location' matching this NPC's room",
            ),
        ]

Registering Custom Rules

Register rules in your content pack's custom_rules() method:

# In pack.py
def custom_rules(self) -> list[object]:
    from my_world.rules import NPCLocationRequiredRule, MerchantHasInventoryRule

    return [
        NPCLocationRequiredRule(),
        MerchantHasInventoryRule(),
    ]

These rules will run during the pipeline's validation phases alongside the built-in rules.


Chapter 14: Advanced: Cross-Pack References

When your content pack depends on entities from another pack, use cross-pack references.

The @ref:pack:type/id Syntax

# Reference an entity in the stdlib pack
location: "@ref:stdlib:room/common_jail"

# Reference an item defined in the classic-rpg pack
items:
  enhanced_sword:
    components:
      # ...
    attributes:
      base_template: "@ref:classic-rpg:item/iron_sword"

The full syntax is:

@ref:<pack_name>:<entity_type>/<entity_id>

Dependencies

Your content pack must declare the dependency in get_dependencies():

def get_dependencies(self) -> list[str]:
    return ["stdlib", "classic-rpg"]  # Must match manifest.name values

Dependency names must match manifest names

get_dependencies() returns pack names matching the manifest.name field. For example, the stdlib pack's name is "stdlib", not "maid-stdlib".

How Resolution Works

The loader pipeline resolves cross-pack references through the GlobalReferenceRegistry:

  1. Each content pack registers its entities with a local registry during the pipeline, then merges into the global registry after the pipeline completes (post-pipeline in on_load).
  2. During the resolve_refs phase, @ref:pack:type/id expressions are looked up in the global registry.
  3. If the referenced pack hasn't loaded yet, the pipeline reports an error — this is why dependency order matters.

The engine does not auto-resolve pack load order. You must call engine.load_content_pack() for each pack manually, in dependency order — loading a pack whose dependencies are not yet loaded raises ValueError. Once your get_dependencies() is correct and you load packs in the right order, references will resolve:

from maid_engine.core.engine import GameEngine
from maid_stdlib.pack import StdlibContentPack
from maid_classic_rpg.pack import ClassicRPGContentPack
from my_world.pack import MyWorldPack

engine = GameEngine(settings)

# Load in dependency order: stdlib first, then classic-rpg, then your pack.
engine.load_content_pack(StdlibContentPack())
engine.load_content_pack(ClassicRPGContentPack())
engine.load_content_pack(MyWorldPack())

await engine.start()

Chapter 15: Advanced: Schema Migrations

As your content evolves, you may need to change schema structure. MAID provides a migration framework for upgrading YAML content between schema versions.

Schema Versioning

Schemas are versioned in the _meta.schema field:

_meta:
  schema: maid:room:v1   # Version 1

When you need to change the schema (rename fields, restructure data), create a new version (v2) and write a migration.

Writing a Migration

Migrations are registered with the SchemaMigrationRegistry:

from maid_engine.loader.schema_migration import (
    SchemaMigration,
    SchemaMigrationRegistry,
)
from maid_engine.loader.models import SchemaVersion


registry = SchemaMigrationRegistry()


def rename_desc_to_long_desc(data: dict) -> dict:
    """Rename the legacy DescriptionComponent ``desc`` field to ``long_desc``.

    Works on shallow copies so the input dict is never mutated, and
    **drops** the old ``desc`` key from the result. A naive
    ``{**old, "long_desc": old.pop("desc")}`` spread would *retain* ``desc``
    alongside the new field, because the ``**`` spread copies every key
    (including ``desc``) before ``pop`` runs.
    """
    components = dict(data.get("components", {}))
    desc_comp = dict(components.get("DescriptionComponent", {}))

    # Pop off the copy so the legacy key is excluded from the output.
    legacy = desc_comp.pop("desc", None)
    if legacy is not None and not desc_comp.get("long_desc"):
        desc_comp["long_desc"] = legacy

    components["DescriptionComponent"] = desc_comp
    return {**data, "components": components}


registry.register(SchemaMigration(
    from_version=SchemaVersion.parse("v1"),
    to_version=SchemaVersion.parse("v2"),
    entity_type="room",
    description="Rename legacy 'desc' field to 'long_desc' in DescriptionComponent",
    transform=rename_desc_to_long_desc,
))

Running Migrations

Migrations can be run in dry-run mode first to preview changes:

result = registry.migrate_file(Path("data/rooms.yaml"), dry_run=True)

Then applied:

result = registry.migrate_file(Path("data/rooms.yaml"), dry_run=False)

The migration framework:

  • Operates on raw dict data before component instantiation
  • Optionally creates .bak backup files before modifying originals (off by default — pass backup=True to migrate_file() to enable)
  • Supports chaining multiple versions (v1 → v2 → v3)
  • Reports per-entity migration results

Backups are opt-in

registry.migrate_file(path) overwrites the file in place with no backup unless you pass backup=True:

result = registry.migrate_file(Path("data/rooms.yaml"), backup=True)

Always commit your YAML to version control before running a migration, or enable backup=True so a .bak copy is written alongside the original.

Keep migrations small

Each migration should do one thing. Chain multiple small migrations rather than writing one large transform.


Chapter 16: Advanced: Custom Pipeline Phases

The loader pipeline processes YAML content through six phases. You can inject custom phases for advanced loading scenarios.

The 6-Phase Pipeline

# Phase Purpose
1 discover Finds YAML files in data directories
2 parse Parses YAML, validates structure
3 prepare Resolves schemas, validates components, assigns UUIDs
4 resolve_refs Replaces @ref: expressions with UUIDs
5 instantiate Creates entities with components in the World
6 post_load Final validation and callbacks

Writing a Custom Phase

A phase is any class that implements the Phase protocol:

from __future__ import annotations

import logging
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from maid_engine.loader.models import LoaderContext
    from maid_engine.loader.pipeline import PhaseResult

logger = logging.getLogger(__name__)


class RoomAuditPhase:
    """Custom phase that reports a summary of loaded rooms.

    Runs after the built-in ``instantiate`` phase. It only *reads*
    pipeline state — it does not touch the world's room index, which
    the ``instantiate`` phase already populates (see the warning below).
    """

    @property
    def name(self) -> str:
        return "room_audit"

    async def execute(self, context: LoaderContext) -> PhaseResult:
        from maid_engine.loader.pipeline import PhaseResult

        room_count = sum(
            1
            for entity_def in context.entity_definitions
            if entity_def.entity_type == "room" and not entity_def.is_template
        )
        logger.info("Loaded %d rooms into the world index", room_count)

        return PhaseResult(phase_name=self.name, errors=[])

Do not re-register rooms from a custom phase

The built-in instantiate phase already registers every room in the world's room index using a normalized room_data dict (name, description, resolved exits, area_id, and merged attributes), and it places entities that declare a location. Calling world.register_room(uuid, entity_def.raw_data) from a custom phase would overwrite that normalized structure with the raw YAML dict, corrupting room lookups and exits. Custom phases should add new behaviour, not duplicate the built-in wiring.

The Phase protocol requires:

  • A name property returning a human-friendly string.
  • An execute(context) method that receives the LoaderContext (mutable pipeline state) and returns a PhaseResult.

Registering Custom Phases

Register phases in your content pack's custom_phases() method:

# In pack.py
def custom_phases(self) -> list[Phase]:
    from my_world.phases import RoomAuditPhase

    return [RoomAuditPhase()]

Custom phases are appended after the default six phases by default. If you need to control ordering, override phase_order() instead:

def phase_order(self) -> list[Phase]:
    """Full custom phase ordering."""
    default_phases = super().phase_order()
    # Insert our phase after 'instantiate' (index 4)
    # but before 'post_load' (index 5)
    return [
        *default_phases[:5],
        RoomAuditPhase(),
        *default_phases[5:],
    ]

Real-World Example: The Tutorial World's WorldWiringPhase

The tutorial world (maid-tutorial-world) uses a custom pipeline phase to wire rooms into the world after entity instantiation:

class WorldWiringPhase:
    """Registers rooms, areas, and entity placement after instantiation.

    - Registers room entities in the world's room index
    - Resolves exits from raw_data and connects rooms
    - Registers areas from zone metadata
    - Places NPCs and items in their designated rooms
    """

    @property
    def name(self) -> str:
        return "world_wiring"

    async def execute(self, context: LoaderContext) -> PhaseResult:
        # 1. Register areas from zone definitions
        # 2. Wire rooms into the world room index
        # 3. Connect room exits
        # 4. Place NPCs in their rooms
        # 5. Place items in their rooms
        ...

This phase handles the domain-specific wiring that the generic pipeline doesn't know about — connecting rooms, placing NPCs, and registering areas. See the tutorial world source for the full implementation.


What's Next?

Congratulations — you now have all the tools to build a complete MUD with MAID! Here are some directions to explore:

  • Combat: Add combat systems using the classic-rpg pack as a reference.
  • Quests: Build quest systems with the quest generation pipeline.
  • AI NPCs: Deepen NPC intelligence with the memory system and knowledge graphs.
  • Multiplayer: Test with multiple concurrent players via telnet or the web client.
  • Publishing: Share your content pack via the plugin registry.

Study the tutorial world

The maid-tutorial-world package is a complete, working content pack designed as a learning resource. It demonstrates every pattern covered in this guide. Find it at packages/maid-tutorial-world/src/maid_tutorial_world/.