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:
- The Entity Component System (ECS) pattern
- How the tick-based game loop works
- The event system for decoupled communication
- 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:
- Extensibility: Add new game features without modifying core code
- Modularity: Load only the content you need
- Hot Reload: Update content packs without restarting the server
- Sharing: Distribute content packs as standalone packages
- 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:
- What components does this entity have?
- Based on the stats, what is this character's specialty?
- Is this a player or NPC? How can you tell?
- What is the character's current health percentage?
Answers
- PositionComponent, HealthComponent, ManaComponent, InventoryComponent, StatsComponent, PlayerComponent, DescriptionComponent
- Magic user (high INT and WIS, full mana, class is "wizard")
- Player (has PlayerComponent, not NPCComponent)
- 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:
- What event would be emitted?
- What systems might subscribe to this event?
- How could an invulnerability system cancel the damage?
- What priority would the invulnerability check need?
Answers
DamageDealtEventwith source_id (player), target_id (goblin), damage (25)- HealthSystem (apply damage), QuestSystem (track kills), LoggingSystem (record combat), UISystem (show damage numbers)
- Subscribe to DamageDealtEvent, check if target has "invulnerable" tag, call
event.cancel() EventPriority.HIGHESTto 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:
- What is the pack's internal name (used for dependencies)?
- What packs must be loaded before this one?
- Could this pack override commands from
maid-classic-rpg? - What kind of systems might this pack provide?
Answers
my-pvp-arenamaid-stdlibandmaid-classic-rpg- Yes, since it loads after
maid-classic-rpg, it has higher priority - 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 |