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
- Getting Started
- Understanding Content Files
- Creating Rooms
- Creating NPCs
- Creating Items
- Validation
- Testing Your World
- Schema Support
- Reference
Part II — Hybrid Packs: Adding Python Logic
- Adding Custom Systems
- Adding Custom Commands
- Custom Events
- Custom Validation Rules
- Advanced: Cross-Pack References
- Advanced: Schema Migrations
- 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
Scaffolding a New Project¶
MAID provides a quickstart command that generates a project skeleton:
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:
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¶
Running the Server¶
Once your content is ready, start the MAID server:
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:
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:
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:
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) theDescriptionComponent, reading the extended component'sdescriptions.base_descriptionas the main room text. An emptyExtendedRoomComponent: {}has an empty base description, so the room renders blank — it shadows thelong_descyou set on theDescriptionComponent. Only declareExtendedRoomComponentwhen 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
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:
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:
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):
data validate runs the first four pipeline phases (discover → parse → prepare
→ resolve refs) plus the semantic rules, and prints a summary:
If problems are found it lists them and exits non-zero:
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¶
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¶
Fix: Ensure current does not exceed maximum in HealthComponent.
Room with no exits¶
Fix: Add at least one exit, or suppress the warning if the room is intentionally isolated (e.g., a jail cell).
Invalid schema¶
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:
Typical output:
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
lintis 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 underdata validate, notdata 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:
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¶
Connect via telnet:
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¶
This shows all registered component types with a short description.
Viewing Schema Details¶
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:
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:
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.deltais 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:
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:
- 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). - During the resolve_refs phase,
@ref:pack:type/idexpressions are looked up in the global registry. - 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:
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:
Then applied:
The migration framework:
- Operates on raw dict data before component instantiation
- Optionally creates
.bakbackup files before modifying originals (off by default — passbackup=Truetomigrate_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:
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
nameproperty returning a human-friendly string. - An
execute(context)method that receives theLoaderContext(mutable pipeline state) and returns aPhaseResult.
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/.