Skip to content

Complete Game Tutorial

This tutorial shows how to combine multiple content packs to create a complete MUD game. You will learn how different systems integrate, how to make architecture decisions, and best practices for building a full game.

This is an architectural overview, not a copy-paste program. The larger snippets illustrate structure and patterns; where they show engine APIs, this page uses the real ones (world.create_entity(), world.register_room(), world.place_entity_in_room(), world.entities.get(), engine.stop()). A few pass-bodied methods are intentional stubs for you to fill in.

Constructing Settings() requires a secure admin key. In development set MAID_DEBUG=true (which relaxes the check) or provide a 32+ character MAID_ADMIN_SECRET_KEY; otherwise Settings() raises a validation error.

Overview

A complete MAID game typically consists of:

  1. Core Systems: Combat, magic, movement
  2. World Content: Rooms, NPCs, items
  3. Progression: Levels, skills, quests
  4. Economy: Currency, shops, trading
  5. Social: Chat, groups, guilds

Architecture Overview

┌─────────────────────────────────────────────────────────────┐
│                     Your Game                                │
│  ┌─────────────┐  ┌─────────────┐  ┌─────────────┐          │
│  │   Combat    │  │    Magic    │  │   Economy   │          │
│  │   System    │  │   System    │  │   System    │          │
│  └──────┬──────┘  └──────┬──────┘  └──────┬──────┘          │
│         │                │                │                  │
│         └────────────────┼────────────────┘                  │
│                          │                                   │
│                  ┌───────▼────────┐                          │
│                  │ maid-classic-rpg│                          │
│                  └───────┬────────┘                          │
├──────────────────────────┼──────────────────────────────────┤
│                  ┌───────▼────────┐                          │
│                  │   maid-stdlib   │                          │
│                  └───────┬────────┘                          │
├──────────────────────────┼──────────────────────────────────┤
│                  ┌───────▼────────┐                          │
│                  │   maid-engine   │                          │
│                  └────────────────┘                          │
└─────────────────────────────────────────────────────────────┘

System Integration

How Systems Communicate

Systems communicate through the EventBus, not direct calls:

# Combat system emits damage event (damage_type is required)
await self.events.emit(DamageDealtEvent(
    source_id=attacker.id,
    target_id=target.id,
    damage=25,
    damage_type="physical",
))

# XP system listens for kills
async def _on_entity_death(self, event: EntityDeathEvent) -> None:
    if event.killer_id:
        await self._award_xp(event.killer_id, event.entity_id)

# Loot system also listens for kills
async def _on_entity_death(self, event: EntityDeathEvent) -> None:
    await self._spawn_loot(event.entity_id)

# Quest system tracks kills too
async def _on_entity_death(self, event: EntityDeathEvent) -> None:
    await self._update_kill_quests(event.killer_id, event.entity_id)

Shared Components

Systems share components to avoid duplication:

# Many systems use HealthComponent
player.add(HealthComponent(current=100, maximum=100))

# Combat reads it
health = player.get(HealthComponent)
health.damage(25)

# Magic reads it
health.heal(30)

# Regeneration system updates it
if health.current < health.maximum:
    health.heal(int(health.regeneration_rate * delta))

Example: Complete Game Structure

Project Layout

my-mud-game/
    src/
        my_mud_game/
            __init__.py
            main.py                    # Entry point
            config.py                  # Game configuration
            packs/
                __init__.py
                world/                 # World content pack
                    pack.py
                    rooms/
                    npcs/
                    items/
                gameplay/              # Gameplay systems pack
                    pack.py
                    systems/
                    commands/
                progression/           # Progression pack
                    pack.py
                    xp_system.py
                    skills.py
    data/
        world.yaml                    # World definition
        npcs.yaml                     # NPC definitions
        items.yaml                    # Item definitions
    tests/
    pyproject.toml

Main Entry Point

# src/my_mud_game/main.py
"""Main entry point for the MUD game."""

import asyncio

from maid_engine.core.engine import GameEngine
from maid_engine.config.settings import (
    GameSettings,
    Settings,
    TelnetSettings,
    WebSettings,
)
from maid_stdlib.pack import StdlibContentPack
from maid_classic_rpg.pack import ClassicRPGContentPack

from .packs.world import WorldContentPack
from .packs.gameplay import GameplayContentPack
from .packs.progression import ProgressionContentPack


async def main():
    """Start the game server."""
    # Settings() validates the admin secret key. For local development, run with
    # MAID_DEBUG=true in the environment (or set a 32+ char MAID_ADMIN_SECRET_KEY)
    # so this construction does not raise a validation error.
    settings = Settings(
        name="My MUD Game",
        game=GameSettings(
            tick_rate=4.0,
        ),
        telnet=TelnetSettings(
            port=4000,
        ),
        web=WebSettings(
            port=8080,
        ),
    )

    # Create engine
    engine = GameEngine(settings)

    # Load content packs in dependency order
    # Core library
    engine.load_content_pack(StdlibContentPack())

    # Classic RPG features (combat, magic, etc.)
    engine.load_content_pack(ClassicRPGContentPack())

    # Game-specific packs
    engine.load_content_pack(WorldContentPack())
    engine.load_content_pack(GameplayContentPack())
    engine.load_content_pack(ProgressionContentPack())

    # Start the server and run until a shutdown signal arrives.
    #
    # NOTE: engine.start() installs its own asyncio SIGINT/SIGTERM handlers
    # (see GameEngine._setup_signals). Those handlers call engine.stop() and
    # SUPPRESS the default KeyboardInterrupt, so a manual
    # `while True: await asyncio.sleep(1)` / `except KeyboardInterrupt` loop
    # would never exit on Ctrl-C. Use engine.run() instead: it starts the
    # engine, waits on the internal stop event (set by the signal handlers or
    # by any call to engine.stop()), then shuts down cleanly.
    print("Starting My MUD Game...")
    await engine.run()
    print("Shutting down...")


if __name__ == "__main__":
    asyncio.run(main())

World Content Pack

# src/my_mud_game/packs/world/pack.py
"""World content pack - defines rooms, NPCs, items."""

from maid_engine.plugins.protocol import BaseContentPack


class WorldContentPack(BaseContentPack):
    """Content pack containing world definitions."""

    @property
    def manifest(self):
        from maid_engine.plugins.manifest import ContentPackManifest
        return ContentPackManifest(
            name="my-game-world",
            version="1.0.0",
            display_name="My Game World",
            dependencies={"stdlib": ">=0.1.0", "classic-rpg": ">=0.1.0"},
        )

    def get_dependencies(self):
        return ["stdlib", "classic-rpg"]

    async def on_load(self, engine):
        """Load world data and spawn initial entities."""
        await self._load_rooms(engine)
        await self._load_npcs(engine)
        await self._load_items(engine)

    async def _load_rooms(self, engine):
        """Create rooms from data files."""
        # Rooms are plain data dicts registered with the world by UUID. Exits map
        # a direction to the destination room's UUID.
        from uuid import uuid4

        world = engine.world

        start_id = uuid4()
        tavern_id = uuid4()

        # Register the starting room, with an exit north to the tavern.
        world.register_room(
            start_id,
            {
                "name": "Town Square",
                "description": "The bustling center of town.",
                "exits": {"north": tavern_id},
            },
        )

        # Register a connected room, with an exit south back to the square.
        world.register_room(
            tavern_id,
            {
                "name": "The Rusty Dagger Tavern",
                "description": "A cozy tavern with a roaring fireplace.",
                "exits": {"south": start_id},
            },
        )

    async def _load_npcs(self, engine):
        """Spawn NPCs in rooms."""
        # Create NPCs with AI and dialogue
        pass

    async def _load_items(self, engine):
        """Place items in the world."""
        pass

Gameplay Content Pack

# src/my_mud_game/packs/gameplay/pack.py
"""Custom gameplay systems."""

from maid_engine.plugins.protocol import BaseContentPack

from .systems import RestingSystem, HungerSystem


class GameplayContentPack(BaseContentPack):
    """Custom gameplay mechanics."""

    @property
    def manifest(self):
        from maid_engine.plugins.manifest import ContentPackManifest
        return ContentPackManifest(
            name="my-game-gameplay",
            version="1.0.0",
            dependencies={"stdlib": ">=0.1.0"},
        )

    def get_systems(self, world):
        return [
            RestingSystem(world),
            HungerSystem(world),
        ]

    def register_commands(self, registry):
        from .commands import register_commands
        register_commands(registry, self.manifest.name)

Best Practices

1. Use Events for Cross-System Communication

# Good: Events for loose coupling
await self.events.emit(PlayerLeveledUpEvent(player_id=player.id, new_level=10))

# Bad: Direct system calls
skill_system = self.world.systems.get(SkillSystem)
skill_system.unlock_skills_for_level(player_id, 10)

2. Keep Components Focused

# Good: Focused components
class HealthComponent(Component):
    current: int
    maximum: int

class ManaComponent(Component):
    current: int
    maximum: int

# Bad: Mega-component
class CharacterStatsComponent(Component):
    health: int
    max_health: int
    mana: int
    max_mana: int
    stamina: int
    # ... 50 more fields

3. Design for Extensibility

# Good: Can be extended by other packs
class CombatSystem(System):
    async def calculate_damage(self, attacker, defender, attack_type):
        base_damage = self._get_base_damage(attacker)

        # Emit event so other systems can modify
        event = DamageCalculationEvent(
            attacker_id=attacker.id,
            defender_id=defender.id,
            base_damage=base_damage,
            modifiers=[],
        )
        await self.events.emit(event)

        # Apply modifiers from other systems
        final_damage = base_damage
        for modifier in event.modifiers:
            final_damage = int(final_damage * modifier)

        return final_damage

4. Test Systems in Isolation

# Test combat system without magic system
@pytest.fixture
def combat_only_world(world):
    world.systems.register(CombatSystem(world))
    return world

async def test_basic_attack(combat_only_world):
    # Test combat in isolation
    pass

5. Document System Interactions

class QuestSystem(System):
    """Quest tracking and completion system.

    Dependencies:
    - StdlibContentPack: DescriptionComponent for quest givers
    - CombatSystem: Listens to EntityDeathEvent for kill quests
    - LootSystem: Listens to ItemPickedUpEvent for collection quests

    Events Emitted:
    - QuestAcceptedEvent: When player accepts a quest
    - QuestCompletedEvent: When quest objectives are met
    - QuestRewardEvent: When rewards are granted

    Events Listened:
    - EntityDeathEvent: Track kills for kill quests
    - ItemPickedUpEvent: Track items for collection quests
    - RoomEnterEvent: Track exploration for discovery quests
    """

Common Patterns

Loading World Data

# Load from YAML
import yaml
from uuid import UUID, uuid4

async def _load_rooms(self, engine):
    with open("data/rooms.yaml") as f:
        data = yaml.safe_load(f)

    world = engine.world

    # register_room() and PositionComponent.room_id use UUIDs, but YAML files
    # use readable string ids. Assign a UUID to every string id first, then
    # translate each exit's destination id through the same map.
    id_map: dict[str, UUID] = {room["id"]: uuid4() for room in data["rooms"]}

    for room_data in data["rooms"]:
        exits = {
            direction: id_map[dest]
            for direction, dest in room_data.get("exits", {}).items()
        }
        world.register_room(
            id_map[room_data["id"]],
            {
                "name": room_data["name"],
                "description": room_data["description"],
                "exits": exits,
            },
        )

Spawning NPCs

async def spawn_npc(self, world, template_id: str, room_id: UUID):
    """Spawn an NPC from a template."""
    template = self.npc_templates[template_id]

    entity = world.entities.create()
    entity.add(PositionComponent(room_id=room_id))
    entity.add(DescriptionComponent(
        name=template["name"],
        short_desc=template["description"],
    ))
    entity.add(HealthComponent(
        current=template["health"],
        maximum=template["health"],
    ))
    entity.add(NPCComponent(
        behavior_type=template.get("behavior", "passive"),
    ))

    if template.get("hostile"):
        # CombatComponent (maid_stdlib.components) holds attack/defense stats.
        entity.add(CombatComponent(
            attack_power=template.get("attack_power", 5),
            defense=template.get("defense", 0),
        ))
        entity.add_tag("hostile")

    # Index the entity into the room so movement/look see it.
    world.place_entity_in_room(entity.id, room_id)
    return entity

Saving Game State

async def save_player(self, player_id: UUID):
    """Save player state to document store."""
    player = self.world.entities.get(player_id)
    if not player:
        return

    # Serialize entity
    data = player.to_dict()

    # Save to document store
    players = self.document_store.get_collection("players")
    await players.upsert(player_id, data)

Where self.document_store comes from. There is no world.document_store. The store is owned by the engine — reach it via engine.document_store (a property returning the active DocumentStore), a StorageAware system whose set_storage() was called during load, or ctx.document_store inside a command. Store one of those on your class in on_load. Also note DocumentCollection.upsert(doc_id, document) expects a registered schema model (a BaseModel), so register a collection schema and pass a model instance rather than a raw dict in production code.

Summary

Building a complete game requires:

  1. Layered content packs that build on each other
  2. Event-driven communication between systems
  3. Shared components for common data
  4. Clear documentation of system interactions
  5. Comprehensive testing at all levels

Next Steps

Additional Resources