Skip to content

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):

# Evennia
self.db.health = 100        # Persistent
self.ndb.combat_target = None  # Session-only

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:

  • Tagsobj.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, @set commands exist in MAID too
  • Separation of engine and game — Evennia separates engine from typeclasses; MAID separates maid-engine from 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 → EntityManager queries
  • [ ] Update settings.py → environment variables
  • [ ] Wrap everything in a ContentPack

Further Reading