Skip to content

Tutorial Part 2: Architecture Deep Dive

Estimated time: 20 minutes


Welcome back! In Part 1, you installed MAID and connected to your first server. Now we will explore the architecture that makes MAID tick. Understanding these concepts will make you a more effective developer as you build your own MUD content.

Why Understanding Architecture Matters

Before diving into building commands, systems, and content, it helps to understand how MAID is structured. This knowledge will:

  • Help you debug issues by understanding data flow
  • Guide your design decisions when extending MAID
  • Enable you to write efficient, well-integrated code
  • Make the codebase feel familiar rather than foreign

By the end of this tutorial, you will understand:

  1. The Entity Component System (ECS) pattern
  2. How the tick-based game loop works
  3. The event system for decoupled communication
  4. How content packs extend the engine

Entity Component System (ECS) Pattern

MAID uses the Entity Component System architectural pattern. ECS separates data from behavior, making the codebase flexible and easy to extend.

What is ECS?

ECS divides game objects into three distinct parts:

Concept Description Example
Entity A unique identifier that groups components together A player character, an NPC, an item
Component Pure data attached to an entity Health points, position, inventory
System Logic that processes entities with specific components Combat system, movement system

This separation offers several advantages:

  • Composition over inheritance: Build complex entities by combining simple components
  • Data locality: Components of the same type are stored together for cache efficiency
  • Flexibility: Add new behaviors without modifying existing code

Entities: Just IDs

In MAID, an Entity is essentially just a UUID with components attached. Entities themselves contain no game logic.

from maid_engine.core.ecs import Entity, EntityManager

# Create an entity manager
manager = EntityManager()

# Create an entity - it's just an ID at this point
player = manager.create()
print(player.id)  # e.g., UUID('a1b2c3d4-...')

An entity without components is like an empty container. It only becomes meaningful when you add components.

Components: Pure Data

Components are data containers with no behavior. They inherit from Component and use Pydantic for validation:

from maid_engine.core.ecs import Component
from uuid import UUID

class PositionComponent(Component):
    """Tracks where an entity is in the world."""
    room_id: UUID
    x: int = 0
    y: int = 0

class HealthComponent(Component):
    """Tracks entity health."""
    current: int
    maximum: int

    @property
    def is_alive(self) -> bool:
        return self.current > 0

MAID's standard library (maid-stdlib) provides many common components:

Component Purpose
PositionComponent Tracks room location
HealthComponent Health points and regeneration
ManaComponent Magical energy
InventoryComponent Item storage
DescriptionComponent Name and descriptions
PlayerComponent Player-specific metadata
NPCComponent NPC behavior configuration
CombatComponent Combat statistics
StatsComponent RPG attributes (STR, DEX, etc.)

Combining Entities and Components

Here is how you build a player character by combining components:

from uuid import uuid4
from maid_stdlib.components import (
    PositionComponent,
    HealthComponent,
    InventoryComponent,
    DescriptionComponent,
    PlayerComponent,
)

# Create the entity
player = manager.create()

# Add components to define what this entity is
player.add(PositionComponent(room_id=starting_room_id))
player.add(HealthComponent(current=100, maximum=100))
player.add(InventoryComponent(capacity=20))
player.add(DescriptionComponent(
    name="Gandalf",
    short_desc="A wise wizard",
    keywords=["wizard", "old", "man"]
))
player.add(PlayerComponent(account_id=account_id))

# Now the entity is a fully-functional player character!

Systems: The Logic

Systems contain the game logic. They process entities that have specific components:

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

class HealthRegenerationSystem(System):
    """Regenerates health for all entities over time."""

    priority = 50  # Lower number = runs earlier

    async def update(self, delta: float) -> None:
        # Query all entities that have HealthComponent
        for entity in self.entities.with_components(HealthComponent):
            health = entity.get(HealthComponent)

            # Only regenerate if not at full health
            if health.current < health.maximum:
                regen_amount = health.regeneration_rate * delta
                health.heal(int(regen_amount))

Key points about systems:

  • Systems query entities by the components they have
  • Systems read and modify component data
  • Systems run every tick in priority order
  • Systems are registered by content packs

ECS Relationship Diagram

flowchart TB
    subgraph Entities
        E1[Entity: Player<br/>UUID: abc-123]
        E2[Entity: Goblin<br/>UUID: def-456]
        E3[Entity: Sword<br/>UUID: ghi-789]
    end

    subgraph Components
        C1[PositionComponent]
        C2[HealthComponent]
        C3[InventoryComponent]
        C4[PlayerComponent]
        C5[NPCComponent]
        C6[ItemComponent]
        C7[CombatComponent]
    end

    subgraph Systems
        S1[MovementSystem]
        S2[CombatSystem]
        S3[HealthRegenSystem]
    end

    E1 --> C1
    E1 --> C2
    E1 --> C3
    E1 --> C4
    E1 --> C7

    E2 --> C1
    E2 --> C2
    E2 --> C5
    E2 --> C7

    E3 --> C6

    S1 -.->|queries entities with| C1
    S2 -.->|queries entities with| C7
    S3 -.->|queries entities with| C2

The Tick Loop

MAID uses a tick-based game loop. This means the game world advances in discrete time steps called "ticks."

How Game Time Works

Unlike real-time systems that process events immediately, tick-based systems batch updates:

  • All systems run once per tick
  • Events are processed at defined intervals
  • This provides predictable, consistent behavior
  • The default tick rate is 4 ticks per second (configurable)

Tick Rate Configuration

You can adjust the tick rate in your configuration:

# Environment variable
MAID_GAME__TICK_RATE=4.0  # 4 ticks per second (default)

# Or in .env file
MAID_GAME__TICK_RATE=10.0  # 10 ticks per second (faster updates)

Higher tick rates provide smoother updates but use more CPU. For most MUDs, 4 ticks per second is sufficient.

What Happens Each Tick

Each tick follows a specific sequence:

flowchart TD
    Start([Tick Starts]) --> Delta[Calculate delta time<br/>since last tick]
    Delta --> Event[Emit TickEvent]
    Event --> Process[Process pending events]
    Process --> Systems[Run all systems<br/>in priority order]
    Systems --> Network[Handle network I/O]
    Network --> Stats[Update statistics]
    Stats --> Sleep[Sleep until<br/>next tick time]
    Sleep --> Start

In code, the tick loop looks like this (simplified):

async def _tick_loop(self) -> None:
    while not self._stop_event.is_set():
        tick_start = time.monotonic()
        delta = tick_start - self._last_tick_time
        self._last_tick_time = tick_start

        # 1. Emit tick event for listeners
        self._world.events.emit_sync(
            TickEvent(tick_number=self._tick_count, delta=delta)
        )

        # 2. Process the world (runs all systems)
        await self._world.tick(delta)

        # 3. Calculate sleep time to maintain tick rate
        tick_duration = time.monotonic() - tick_start
        sleep_time = self._tick_interval - tick_duration

        if sleep_time > 0:
            await asyncio.sleep(sleep_time)

        self._tick_count += 1

Understanding Delta Time

The delta parameter passed to systems represents the time (in seconds) since the last tick:

async def update(self, delta: float) -> None:
    # delta is typically ~0.25 seconds at 4 ticks/second

    # Use delta for time-based calculations
    distance = speed * delta  # Movement per tick
    health_regen = regen_rate * delta  # Healing per tick

Using delta ensures consistent behavior regardless of actual tick timing variations.


Event System

MAID uses an event-driven architecture where systems communicate through events rather than direct calls.

Events as Decoupled Communication

Events allow systems to communicate without knowing about each other:

flowchart LR
    Combat[Combat System] -->|emits| DE[DamageDealtEvent]
    DE -->|handled by| Log[Logging System]
    DE -->|handled by| UI[UI Update System]
    DE -->|handled by| Quest[Quest System]

    Combat -.->|does not know about| Log
    Combat -.->|does not know about| UI
    Combat -.->|does not know about| Quest

This decoupling provides:

  • Modularity: Systems can be added/removed without breaking others
  • Testability: Test systems in isolation
  • Extensibility: Add new behaviors by subscribing to existing events

Event Types

MAID defines two categories of events:

Core Lifecycle Events (in maid-engine):

Event When Emitted
TickEvent At the start of each tick
EntityCreatedEvent When an entity is created
EntityDestroyedEvent When an entity is destroyed
PlayerConnectedEvent When a player connects
PlayerDisconnectedEvent When a player disconnects
RoomEnterEvent When an entity enters a room
RoomLeaveEvent When an entity leaves a room

Game Events (in content packs like maid-stdlib and maid-classic-rpg):

Event When Emitted
DamageDealtEvent When damage is dealt
ItemPickedUpEvent When an item is picked up
ItemDroppedEvent When an item is dropped
CombatStartedEvent When combat begins
QuestCompletedEvent When a quest is completed

EventBus: Publish/Subscribe Pattern

The EventBus implements the publish/subscribe pattern:

from maid_engine.core.events import EventBus, Event, EventPriority
from dataclasses import dataclass
from uuid import UUID

# Define a custom event
@dataclass
class DamageDealtEvent(Event):
    source_id: UUID
    target_id: UUID
    damage: int
    damage_type: str = "physical"

# Get the event bus (usually from world.events)
bus = EventBus()

# Subscribe to an event
async def on_damage(event: DamageDealtEvent):
    print(f"Damage dealt: {event.damage} ({event.damage_type})")

handler_id = bus.subscribe(
    DamageDealtEvent,
    on_damage,
    priority=EventPriority.NORMAL
)

# Emit an event
await bus.emit(DamageDealtEvent(
    source_id=attacker.id,
    target_id=defender.id,
    damage=25,
    damage_type="physical"
))

# Unsubscribe when done
bus.unsubscribe(handler_id)

Event Priorities

Handlers can specify priority to control execution order:

Priority When to Use
HIGHEST Critical handlers (e.g., permission checks)
HIGH Important handlers (e.g., damage reduction)
NORMAL Default for most handlers
LOW Non-critical handlers (e.g., cosmetic effects)
LOWEST Final handlers (e.g., logging)

Cancelling Events

Events can be cancelled to prevent further processing:

async def check_invulnerability(event: DamageDealtEvent):
    target = world.entities.get(event.target_id)
    if target and target.has_tag("invulnerable"):
        event.cancel()  # Prevents damage from being applied

# Register with high priority to run before damage is applied
bus.subscribe(
    DamageDealtEvent,
    check_invulnerability,
    priority=EventPriority.HIGHEST
)

Content Pack Architecture

MAID uses a layered content pack system that separates infrastructure from game content.

What is a Content Pack?

A content pack is a plugin that provides:

  • ECS Systems (game logic)
  • Event types (custom events)
  • Commands (player interactions)
  • Document schemas (data persistence)
  • Setup/teardown hooks (initialization)

The Three Layers

                     +-----------------------+
                     |   maid-classic-rpg    |  <-- Game-specific content
                     |   - Combat system     |
                     |   - Magic system      |
                     |   - Quest system      |
                     +-----------------------+
                              |
                              | depends on
                              v
                     +-----------------------+
                     |     maid-stdlib       |  <-- Reusable components
                     |   - Core components   |
                     |   - Base systems      |
                     |   - Utility functions |
                     +-----------------------+
                              |
                              | depends on
                              v
                     +-----------------------+
                     |     maid-engine       |  <-- Pure infrastructure
                     |   - ECS framework     |
                     |   - Event bus         |
                     |   - Networking        |
                     |   - Storage           |
                     +-----------------------+
Layer Purpose You Modify This For
maid-engine Core infrastructure New engine features
maid-stdlib Common game mechanics Reusable components
maid-classic-rpg Classic MUD content Your game's content

The ContentPack Protocol

Content packs implement the ContentPack protocol:

from maid_engine.plugins.protocol import BaseContentPack
from maid_engine.plugins.manifest import ContentPackManifest

class MyContentPack(BaseContentPack):
    @property
    def manifest(self) -> ContentPackManifest:
        return ContentPackManifest(
            name="my-content-pack",
            version="1.0.0",
            display_name="My Content Pack",
            description="Custom content for my MUD",
        )

    def get_dependencies(self) -> list[str]:
        """Declare dependencies on other content packs."""
        return ["maid-stdlib"]

    def get_systems(self, world: World) -> list[System]:
        """Return systems to register with the engine."""
        return [
            MyCustomSystem(world),
            AnotherSystem(world),
        ]

    def get_events(self) -> list[type[Event]]:
        """Return event types this pack defines."""
        return [
            MyCustomEvent,
            AnotherEvent,
        ]

    def register_commands(self, registry: CommandRegistry) -> None:
        """Register commands with the command system."""
        registry.register(my_command_handler)
        registry.register(another_command_handler)

    def register_document_schemas(self, store: DocumentStore) -> None:
        """Register schemas for data persistence."""
        store.register_schema("my_collection", MyDataModel)

    async def on_load(self, engine: GameEngine) -> None:
        """Called when the pack is loaded. Initialize resources here."""
        await self.load_game_data()

    async def on_unload(self, engine: GameEngine) -> None:
        """Called when the pack is unloaded. Clean up here."""
        await self.save_state()

Why This Architecture?

The content pack architecture enables:

  1. Extensibility: Add new game features without modifying core code
  2. Modularity: Load only the content you need
  3. Hot Reload: Update content packs without restarting the server
  4. Sharing: Distribute content packs as standalone packages
  5. Layered Commands: Higher-priority packs can override commands

High-Level Architecture Diagram

Here is how all the pieces fit together:

flowchart TB
    subgraph Clients
        T[Telnet Client]
        W[Web Client]
    end

    subgraph "Network Layer"
        TS[Telnet Server<br/>Port 4000]
        WS[WebSocket Server<br/>Port 8080]
    end

    subgraph "Game Engine"
        CMD[Command Registry]

        subgraph "World"
            EM[Entity Manager]
            SM[System Manager]
            EB[Event Bus]
        end

        subgraph "Tick Loop"
            TL[Tick Loop<br/>4 ticks/sec]
        end
    end

    subgraph "Content Packs"
        CP1[maid-stdlib]
        CP2[maid-classic-rpg]
    end

    subgraph "Storage"
        DS[Document Store]
    end

    T --> TS
    W --> WS
    TS --> CMD
    WS --> CMD
    CMD --> EM

    TL --> SM
    SM --> EM
    SM --> EB

    CP1 --> SM
    CP2 --> SM

    EM <--> DS

Request Flow: Connection to Response

Here is what happens when a player types a command:

sequenceDiagram
    participant P as Player
    participant N as Network Server
    participant C as Command Registry
    participant H as Command Handler
    participant W as World
    participant E as Event Bus

    P->>N: "look"
    N->>C: Parse command
    C->>C: Find matching handler
    C->>H: Execute handler
    H->>W: Query entities in room
    W-->>H: Return entities
    H->>E: Emit RoomLookEvent (optional)
    H-->>N: Return output text
    N-->>P: Room description

Exercises

Practice what you have learned with these exercises.

Exercise 1: Identify Components on a Player Character

Look at this entity and identify what type of character it represents:

entity = manager.create()
entity.add(PositionComponent(room_id=town_square_id))
entity.add(HealthComponent(current=50, maximum=100))
entity.add(ManaComponent(current=80, maximum=80))
entity.add(InventoryComponent(capacity=20))
entity.add(StatsComponent(strength=12, intelligence=18, wisdom=16))
entity.add(PlayerComponent(character_class="wizard"))
entity.add(DescriptionComponent(name="Merlin", short_desc="A powerful mage"))

Questions:

  1. What components does this entity have?
  2. Based on the stats, what is this character's specialty?
  3. Is this a player or NPC? How can you tell?
  4. What is the character's current health percentage?
Answers
  1. PositionComponent, HealthComponent, ManaComponent, InventoryComponent, StatsComponent, PlayerComponent, DescriptionComponent
  2. Magic user (high INT and WIS, full mana, class is "wizard")
  3. Player (has PlayerComponent, not NPCComponent)
  4. 50% (50 current / 100 maximum)

Exercise 2: Trace an Event Through the System

Consider this scenario: A player attacks a goblin and deals 25 damage.

Questions:

  1. What event would be emitted?
  2. What systems might subscribe to this event?
  3. How could an invulnerability system cancel the damage?
  4. What priority would the invulnerability check need?
Answers
  1. DamageDealtEvent with source_id (player), target_id (goblin), damage (25)
  2. HealthSystem (apply damage), QuestSystem (track kills), LoggingSystem (record combat), UISystem (show damage numbers)
  3. Subscribe to DamageDealtEvent, check if target has "invulnerable" tag, call event.cancel()
  4. EventPriority.HIGHEST to run before damage is applied

Exercise 3: Review a Content Pack Manifest

Look at this content pack manifest and answer the questions:

ContentPackManifest(
    name="my-pvp-arena",
    version="2.1.0",
    display_name="PvP Arena System",
    description="Adds player vs player arena combat with rankings",
    author="GameDev",
    dependencies=["maid-stdlib", "maid-classic-rpg"],
)

Questions:

  1. What is the pack's internal name (used for dependencies)?
  2. What packs must be loaded before this one?
  3. Could this pack override commands from maid-classic-rpg?
  4. What kind of systems might this pack provide?
Answers
  1. my-pvp-arena
  2. maid-stdlib and maid-classic-rpg
  3. Yes, since it loads after maid-classic-rpg, it has higher priority
  4. ArenaMatchmakingSystem, ArenaRankingSystem, ArenaCombatSystem, PvPZoneSystem

What's Next?

Congratulations! You now understand MAID's core architecture:

  • ECS Pattern: Entities are IDs, components are data, systems are logic
  • Tick Loop: Game advances in discrete time steps
  • Event System: Decoupled communication via publish/subscribe
  • Content Packs: Modular, layered architecture for extensibility

In the next tutorial, you will put this knowledge into practice by creating your first commands.

Coming up in Part 3: Your First Commands

  • Understanding the command system
  • Creating simple commands
  • Argument parsing
  • Testing your commands

< Previous: Part 1 - Installation Back to Tutorial Index Next: Part 3 - Commands >


Quick Reference

ECS Concepts

Concept Description Code Example
Entity Unique ID manager.create()
Component Data container entity.add(HealthComponent(...))
System Game logic class MySystem(System): async def update(...)

Event Priorities

Priority Value Use Case
HIGHEST 1 Permission checks, damage prevention
HIGH 2 Damage modification, buffs
NORMAL 3 Standard game logic
LOW 4 Cosmetic effects
LOWEST 5 Logging, statistics

ContentPack Methods

Method Purpose
manifest Pack metadata
get_dependencies() Required packs
get_systems() ECS systems
get_events() Event types
register_commands() Player commands
on_load() Initialization
on_unload() Cleanup