Migrating from Evennia to MAID¶
This guide helps experienced Evennia developers understand MAID's architecture and translate their knowledge to MAID's patterns. Both engines are Python-based MUD frameworks, so many concepts will feel familiar — but the underlying architecture is fundamentally different.
Note
Automated import tooling is planned but not yet available. This guide covers manual migration patterns.
Architecture at a Glance¶
| Evennia | MAID | |
|---|---|---|
| Language | Python 3.11+ | Python 3.12+ |
| Async model | Twisted (reactor pattern) | asyncio (async/await) |
| Object model | Typeclass inheritance | Entity Component System (ECS) |
| Database | Django ORM (PostgreSQL/SQLite) | DocumentStore (PostgreSQL JSONB / in-memory) |
| Commands | CmdSet merge on objects |
LayeredCommandRegistry with pack priorities |
| Timed logic | DefaultScript |
System.update(delta) each tick |
| Networking | Twisted + Django | asyncio + Telnet/WebSocket |
| Plugins | Django apps / contribs | ContentPack protocol |
| Configuration | settings.py (Django) |
Environment variables (MAID_ prefix) |
| Web framework | Django | REST API + React admin UI |
Key Paradigm Shifts¶
1. Typeclasses → Entity + Components¶
This is the biggest change. In Evennia, you define object behavior through typeclass inheritance:
# Evennia: Typeclass inheritance
class Weapon(DefaultObject):
def at_object_creation(self):
self.db.damage = 10
self.db.weapon_type = "sword"
class MagicWeapon(Weapon):
def at_object_creation(self):
super().at_object_creation()
self.db.mana_cost = 5
self.db.spell_effect = "fireball"
In MAID, there are no typeclasses. An entity is just a UUID with components attached. Behavior comes from which components an entity has, not from class hierarchy:
# MAID: Composition with components
from maid_engine.core.ecs import Entity, Component
class WeaponComponent(Component):
damage: int = 10
weapon_type: str = "sword"
class MagicEffectComponent(Component):
mana_cost: int = 5
spell_effect: str = "fireball"
# A magic sword — just attach both components
sword = world.create_entity()
sword.add(WeaponComponent(damage=15, weapon_type="sword"))
sword.add(MagicEffectComponent(mana_cost=5, spell_effect="fireball"))
sword.add_tag("item")
Why this matters: In Evennia, adding new cross-cutting behavior (e.g., making any object breakable) requires modifying the inheritance tree or using mixins. In MAID, you just add a component — no existing code changes needed.
2. Scripts → Systems¶
Evennia's DefaultScript handles timed and repeating logic:
# Evennia: Script for timed logic
class PoisonScript(DefaultScript):
def at_script_creation(self):
self.key = "poison"
self.interval = 5 # seconds
self.db.damage_per_tick = 3
def at_repeat(self):
target = self.obj
target.db.health -= self.db.damage_per_tick
target.msg(f"The poison burns! (-{self.db.damage_per_tick} HP)")
In MAID, this logic lives in a System that processes all relevant entities
each tick:
# MAID: System processes all poisoned entities
from maid_engine.core.ecs import System
class PoisonComponent(Component):
damage_per_tick: float = 3.0
duration_remaining: float = 30.0
class PoisonSystem(System):
priority: ClassVar[int] = 50
async def update(self, delta: float) -> None:
for entity in self.entities.with_components(PoisonComponent, HealthComponent):
poison = entity.get(PoisonComponent)
health = entity.get(HealthComponent)
poison.duration_remaining -= delta
if poison.duration_remaining <= 0:
entity.remove(PoisonComponent)
continue
health.damage(int(poison.damage_per_tick * delta))
await self.events.emit(DamageDealtEvent(
source_id=entity.id,
target_id=entity.id,
damage=int(poison.damage_per_tick * delta),
damage_type="poison",
))
Key difference: In Evennia, each poisoned object has its own script instance
with its own timer. In MAID, one PoisonSystem handles all poisoned entities in
a single pass per tick — simpler and more performant.
3. Django Signals → EventBus¶
Evennia uses Django signals and object hooks for cross-system communication:
# Evennia: Object hooks
class MyRoom(DefaultRoom):
def at_object_receive(self, obj, source_location, **kwargs):
if obj.is_typeclass("typeclasses.characters.Character"):
obj.msg("Welcome to the room!")
def at_object_leave(self, obj, target_location, **kwargs):
obj.msg("You leave the room.")
MAID uses a centralized EventBus with publish/subscribe:
# MAID: Event subscription
class WelcomeSystem(System):
async def startup(self) -> None:
self.events.subscribe(RoomEnterEvent, self.on_room_enter)
async def on_room_enter(self, event: RoomEnterEvent) -> None:
entity = self.entities.get(event.entity_id)
if entity and entity.has(PlayerComponent):
# Use events to communicate with players instead of direct session access
await self.events.emit(MessageEvent(
sender_id=event.entity_id,
target_ids=[event.entity_id],
channel="system",
message="Welcome to the room!",
))
4. obj.db / obj.ndb → Component Fields¶
Evennia stores data on objects via .db (persistent) and .ndb (non-persistent):
MAID stores data as typed, validated fields on components:
# MAID: Typed component fields (Pydantic validation)
class HealthComponent(Component):
current: int
maximum: int
regeneration_rate: float = 1.0
# Usage
health = entity.get(HealthComponent)
health.current = 80 # Validated, type-checked, tracked for persistence
Advantage: Full type safety and validation — no more runtime AttributeError
from typos in .db key names.
5. CmdSet → LayeredCommandRegistry¶
Evennia's CmdSet system merges command sets from the caller, location, and
objects:
# Evennia: Command with CmdSet
class CmdAttack(Command):
key = "attack"
locks = "cmd:all()"
def func(self):
target = self.caller.search(self.args.strip())
if not target:
return
self.caller.msg(f"You attack {target.key}!")
class CombatCmdSet(CmdSet):
def at_cmdset_creation(self):
self.add(CmdAttack())
MAID uses a LayeredCommandRegistry where content packs register commands at
different priorities:
# MAID: Command with registry
async def cmd_attack(ctx: CommandContext) -> bool:
if not ctx.args:
await ctx.session.send("Attack whom?")
return True
target_name = ctx.rest
# Find target in room
pos = ctx.world.get_entity(ctx.player_id).get(PositionComponent)
for entity in ctx.world.entities_in_room(pos.room_id):
name = entity.try_get(DescriptionComponent)
if name and target_name.lower() in name.name.lower():
await ctx.session.send(f"You attack {name.name}!")
return True
await ctx.session.send("You don't see that here.")
return True
# In your ContentPack.register_commands():
registry.register(
"attack",
cmd_attack,
pack_name="my-game",
aliases=["kill", "hit"],
category="combat",
description="Attack a target",
usage="attack <target>",
locks="NOT in_room(safe_zone)",
)
With argument parsing decorators:
from maid_engine.commands import arguments, ArgumentSpec, ArgumentType, ParsedArguments
@arguments(
ArgumentSpec("target", ArgumentType.ENTITY),
)
async def cmd_attack(ctx: CommandContext, args: ParsedArguments) -> bool:
target = args["target"] # Already resolved EntityReference
await ctx.session.send(f"You attack {target.keyword}!")
return True
6. Locks → Lock Expressions¶
Both engines use string-based permission expressions, so this will feel familiar:
# Evennia
locks = "cmd:perm(Admin);edit:perm(Builder)"
# MAID — similar syntax, different functions
locks = "perm(admin) OR level(20)"
locks = "perm(builder) AND NOT in_combat()"
Concept Mapping¶
| Evennia | MAID | Notes |
|---|---|---|
DefaultObject |
Entity + components |
No base class hierarchy |
DefaultRoom |
Entity + PositionComponent + room tag |
Rooms are entities too |
DefaultCharacter |
Entity + PlayerComponent + HealthComponent + ... |
Build up from components |
DefaultExit |
Entity + ExitMetadataComponent |
Exits are entities |
DefaultScript |
System |
One system handles all relevant entities |
CmdSet |
ContentPack command registration |
Pack-scoped, priority-based |
Command class |
Async handler function + CommandDefinition |
Functions, not classes |
obj.db.key |
Component fields | Typed, validated |
obj.ndb.key |
Component fields (non-persisted) or world.set_data() |
Namespace-isolated |
obj.tags.add("tag") |
entity.add_tag("tag") |
Very similar |
obj.locks.check() |
Lock expressions on commands | String-based, extensible |
search_object() |
EntityManager.with_components() / with_tag() |
Query by composition |
create_object() |
world.create_entity() + .add(Component) |
Two-step: create then compose |
obj.msg() |
await session.send() |
Async, session-based |
at_object_creation() |
on_load() in ContentPack or EventBus subscription |
Event-driven |
GLOBAL_SCRIPTS |
Singleton System |
System processes each tick |
EvMenu |
Custom system (not yet in stdlib) | Build menus with session I/O |
EvTable / EvForm |
Text formatting utilities in maid_stdlib.utils |
Similar helpers available |
settings.py |
Environment variables (MAID_ prefix) |
MAID_GAME__TICK_RATE=4.0 |
| Django admin | Web admin UI at /admin-ui/ |
React-based |
What's Familiar¶
These Evennia concepts translate almost directly:
- Tags —
obj.tags.add()→entity.add_tag()(nearly identical API) - Lock strings — Similar expression syntax for permissions
- Python everywhere — Still Python, just newer (3.12+ with type hints)
- In-game building —
@create,@dig,@setcommands exist in MAID too - Separation of engine and game — Evennia separates engine from typeclasses;
MAID separates
maid-enginefrom content packs - Help system — Commands have descriptions and usage strings
- Session handling — Abstract session concept for different connection types
Getting Started: Your First MAID Content Pack¶
Here's how to migrate a simple Evennia typeclass setup to a MAID content pack:
Step 1: Define Your Components¶
Replace your typeclasses with components that capture the data:
# packages/my-game/src/my_game/components.py
from maid_engine.core.ecs import Component
class CharacterStatsComponent(Component):
"""Replaces db attributes on DefaultCharacter."""
strength: int = 10
dexterity: int = 10
intelligence: int = 10
level: int = 1
experience: int = 0
Step 2: Create Systems for Logic¶
Replace Scripts and typeclass methods with Systems:
# packages/my-game/src/my_game/systems.py
from maid_engine.core.ecs import System
class ExperienceSystem(System):
"""Replaces at_defeat() hooks on Character typeclass."""
priority: ClassVar[int] = 60
async def startup(self) -> None:
self.events.subscribe(EntityDeathEvent, self.on_kill)
async def on_kill(self, event: EntityDeathEvent) -> None:
if not event.killer_id:
return
killer = self.entities.get(event.killer_id)
if not killer or not killer.has(CharacterStatsComponent):
return
stats = killer.get(CharacterStatsComponent)
stats.experience += 100
if stats.experience >= stats.level * 1000:
stats.level += 1
Step 3: Bundle into a ContentPack¶
Replace your Evennia app setup with a ContentPack:
# packages/my-game/src/my_game/pack.py
from maid_engine.plugins import BaseContentPack
from maid_engine.plugins.manifest import ContentPackManifest
class MyGamePack(BaseContentPack):
@property
def manifest(self) -> ContentPackManifest:
return ContentPackManifest(
name="my-game",
version="1.0.0",
description="My migrated Evennia game",
)
def get_dependencies(self) -> list[str]:
return ["stdlib"] # Depend on standard library
def get_systems(self, world: World) -> list[System]:
return [ExperienceSystem(world)]
def register_commands(self, registry: LayeredCommandRegistry) -> None:
registry.register("attack", cmd_attack, pack_name="my-game")
registry.register("stats", cmd_stats, pack_name="my-game")
async def on_load(self, engine: GameEngine) -> None:
# One-time setup (replaces at_server_start)
pass
Step 4: Load the Pack¶
engine = GameEngine(settings)
engine.load_content_pack(StdlibContentPack())
engine.load_content_pack(MyGamePack())
await engine.start()
Migration Checklist¶
- [ ] Inventory all typeclasses → plan component equivalents
- [ ] Identify all Scripts → plan System replacements
- [ ] List all CmdSets → plan command registrations
- [ ] Map
at_*hooks → EventBus subscriptions - [ ] Convert
obj.db.*→ typed component fields - [ ] Replace Django ORM queries →
EntityManagerqueries - [ ] Update
settings.py→ environment variables - [ ] Wrap everything in a
ContentPack
Further Reading¶
- Concept Mapping Table — Quick reference across all engines
- ECS Guide — Deep dive into MAID's ECS
- Content Packs — Plugin system details
- Command System — Layered command processing
- Events — EventBus documentation