Migrating from Ranvier to MAID¶
This guide helps experienced Ranvier developers understand MAID's architecture and translate their knowledge to MAID's patterns. Both engines share some modern design sensibilities — data-driven content, plugin bundles, event-driven architecture — so many concepts will feel familiar. The main differences are language (JavaScript → Python), object model (class instances → ECS), and concurrency (Node.js event loop → Python asyncio).
Note
Automated import tooling is planned but not yet available. This guide covers manual migration patterns.
Architecture at a Glance¶
| Ranvier | MAID | |
|---|---|---|
| Language | JavaScript (Node.js) | Python 3.12+ |
| Async model | Node.js event loop (callbacks/promises) | asyncio (async/await) |
| Object model | Class instances + Behaviors | Entity Component System (ECS) |
| Data format | YAML/JSON area files | YAML content loader + DocumentStore |
| Database | JSON flat files (default) | DocumentStore (PostgreSQL JSONB / in-memory) |
| Commands | Command classes in bundles | LayeredCommandRegistry with pack priorities |
| Game loop | GameLoop class |
GameEngine tick loop |
| Plugins | Bundles (BundleManager) | ContentPack protocol |
| Events | EventManager on entities |
EventBus (centralized pub/sub) |
| Configuration | ranvier.json |
Environment variables (MAID_ prefix) |
| Networking | Telnet + WebSocket | Telnet (GMCP, MXP, MCCP2) + WebSocket |
Key Paradigm Shifts¶
1. Behaviors → Components + Systems¶
Ranvier uses Behaviors to attach logic to entities — a pattern between mixins and event listeners:
// Ranvier: Behavior on an NPC
module.exports = {
listeners: {
playerEnter: state => function (player) {
Broadcast.sayAt(player, 'The guard nods at you.');
},
hit: state => function (damage, attacker) {
if (this.getAttribute('health') < 50) {
Broadcast.sayAt(attacker, 'The guard calls for backup!');
}
}
}
};
In MAID, data lives in Components and logic lives in Systems:
# MAID: Component for data
class GuardComponent(Component):
calls_backup_at_health_pct: float = 50.0
has_called_backup: bool = False
class GuardBehaviorSystem(System):
priority: ClassVar[int] = 70
async def startup(self) -> None:
self.events.subscribe(RoomEnterEvent, self.on_player_enter)
self.events.subscribe(DamageDealtEvent, self.on_damage)
async def on_player_enter(self, event: RoomEnterEvent) -> None:
entity = self.entities.get(event.entity_id)
if not entity or not entity.has(PlayerComponent):
return
# Find guards in the room and notify the player via events
for guard in self.world.entities_in_room(event.room_id):
if guard.has(GuardComponent):
await self.events.emit(MessageEvent(
sender_id=guard.id,
target_ids=[event.entity_id],
channel="room",
message="The guard nods at you.",
))
async def on_damage(self, event: DamageDealtEvent) -> None:
target = self.entities.get(event.target_id)
if not target or not target.has(GuardComponent):
return
guard = target.get(GuardComponent)
health = target.get(HealthComponent)
if (health.percentage < guard.calls_backup_at_health_pct
and not guard.has_called_backup):
guard.has_called_backup = True
# Emit event for other systems to handle
await self.events.emit(MessageEvent(
sender_id=target.id,
target_ids=[event.source_id],
channel="combat",
message="The guard calls for backup!",
))
Key difference: Ranvier Behaviors attach listeners to individual entity instances. MAID Systems process all entities with matching components centrally. Data and logic are separated.
2. BundleManager → ContentPack¶
Ranvier organizes content into bundles with a manifest.yml:
# Ranvier: bundle manifest
# bundles/my-areas/manifest.yml
name: my-areas
version: 1.0.0
requires:
- combat-base
// bundles/my-areas/commands/look.js
module.exports = {
command: state => (args, player) => {
const room = player.room;
Broadcast.sayAt(player, room.title);
Broadcast.sayAt(player, room.description);
}
};
MAID uses ContentPack classes with a similar manifest concept:
# MAID: ContentPack
class MyAreasPack(BaseContentPack):
@property
def manifest(self) -> ContentPackManifest:
return ContentPackManifest(
name="my-areas",
version="1.0.0",
description="My game areas",
)
def get_dependencies(self) -> list[str]:
return ["stdlib"] # Like Ranvier's "requires"
def get_systems(self, world: World) -> list[System]:
return [GuardBehaviorSystem(world)]
def register_commands(self, registry: LayeredCommandRegistry) -> None:
registry.register("look", cmd_look, pack_name="my-areas")
async def on_load(self, engine: GameEngine) -> None:
# Load area data, create rooms, etc.
pass
This should feel natural — both systems use manifests, dependencies, and modular organization.
3. Entity Events → Centralized EventBus¶
Ranvier attaches events to individual entity instances:
// Ranvier: Events on entities
room.on('playerEnter', player => {
Broadcast.sayAt(player, 'You feel a chill.');
});
npc.on('hit', (damage, attacker) => {
// Handle being hit
});
item.on('get', (player) => {
Broadcast.sayAt(player, 'The ring feels warm.');
});
MAID uses a centralized EventBus — you subscribe to event types, not
individual entity events:
# MAID: Centralized event subscriptions
class AtmosphereSystem(System):
async def startup(self) -> None:
self.events.subscribe(RoomEnterEvent, self.on_enter)
self.events.subscribe(ItemPickedUpEvent, self.on_pickup)
async def on_enter(self, event: RoomEnterEvent) -> None:
room = self.entities.get(event.room_id)
if room and room.has_tag("spooky"):
await self.events.emit(MessageEvent(
sender_id=event.room_id,
target_ids=[event.entity_id],
channel="room",
message="You feel a chill.",
))
async def on_pickup(self, event: ItemPickedUpEvent) -> None:
item = self.entities.get(event.item_id)
if item and item.has_tag("magical-ring"):
await self.events.emit(MessageEvent(
sender_id=event.item_id,
target_ids=[event.entity_id],
channel="room",
message="The ring feels warm.",
))
Key insight: Instead of attaching handlers per entity, you filter by entity properties (tags, components) inside a centralized handler. This makes it easier to add cross-cutting behavior without modifying individual entities.
4. Attributes → Component Fields¶
Ranvier uses an attribute system for entity stats:
// Ranvier: Attributes
player.getAttribute('health');
player.setAttributeBase('health', 100);
player.modifyAttribute('health', -10);
// In area YAML
- id: sword
attributes:
damage: 10
speed: fast
MAID uses typed component fields:
# MAID: Component fields
health = entity.get(HealthComponent)
health.current # Read
health.damage(10) # Method call with validation
health.heal(5)
# Components are Pydantic models — validated, typed
class WeaponComponent(Component):
damage: int = 10
speed: str = "normal"
weapon_type: str = "sword"
Advantage: Components are Pydantic BaseModel subclasses with full type
validation. Invalid data is caught at assignment time, not at runtime when it
causes a crash.
5. YAML Areas → YAML Loader or Code¶
Both engines support YAML-defined areas, so this will feel familiar:
# Ranvier: areas/town/rooms.yml
- id: town-square
title: "Town Square"
description: "A bustling town square."
exits:
north: market
south: gate
npcs:
- id: guard
behaviors:
- ranvier-sentient
# MAID: data/areas/village/rooms.yml (loaded by WorldDataLoader)
- id: town-square
name: "Town Square"
description: "A bustling town square."
tags: ["room"]
exits:
north: market
south: gate
MAID's Pipeline processes YAML through six phases:
Discovery → Parsing → Validation → Resolution → Instantiation → Indexing.
6. JavaScript → Python¶
Common pattern translations:
// Ranvier (JavaScript)
const damage = Math.floor(Math.random() * strength);
const targets = room.npcs.filter(npc => npc.hasBehavior('hostile'));
targets.forEach(target => {
Broadcast.sayAt(player, `You see ${target.name} here.`);
});
# MAID (Python)
damage = random.randint(0, strength)
targets = [
e for e in world.entities_in_room(room_id)
if e.has(NPCComponent) and e.has_tag("hostile")
]
for target in targets:
name = target.get(DescriptionComponent)
await session.send(f"You see {name.name} here.")
| JavaScript (Ranvier) | Python (MAID) |
|---|---|
const x = 5 |
x: int = 5 |
arr.filter(fn) |
[x for x in arr if fn(x)] |
arr.map(fn) |
[fn(x) for x in arr] |
arr.forEach(fn) |
for x in arr: fn(x) |
async function() |
async def fn(): |
await promise |
await coroutine |
obj.property |
obj.property (same!) |
require('module') |
from module import name |
module.exports = {} |
Top-level definitions (no export syntax) |
null / undefined |
None |
Template literals `${x}` |
f-strings f"{x}" |
try { } catch (e) { } |
try: ... except Exception as e: ... |
Concept Mapping¶
| Ranvier | MAID | Notes |
|---|---|---|
GameEntity |
Entity |
Both are ID + data containers |
Room |
Entity + room tag + PositionComponent |
Rooms are entities |
Player |
Entity + PlayerComponent + components |
Composed from parts |
Npc |
Entity + NPCComponent + components |
Same composition pattern |
Item |
Entity + item-related components | No specific Item class |
| Behavior | System + Component |
Split data from logic |
EventManager (per-entity) |
EventBus (centralized) |
Global pub/sub |
| Bundle | ContentPack |
Very similar concept |
manifest.yml |
ContentPackManifest |
Programmatic, not YAML |
BundleManager |
ContentPackLoader |
Dependency-ordered loading |
GameLoop |
GameEngine tick loop |
Similar concept |
Broadcast.sayAt() |
await session.send() |
Async in MAID |
Broadcast.sayAtExcept() |
Iterate entities_in_room(), skip one |
Manual filtering |
state object |
World + GameEngine |
Split state management |
player.getAttribute() |
entity.get(Component).field |
Typed fields |
player.setAttributeBase() |
entity.get(Component).field = value |
Direct assignment |
| Command class | Async handler function | Functions, not classes |
requiredRole |
Lock expressions | More expressive |
AreaManager |
YAML loader + World.register_room() |
Multiple approaches |
| Channel | EventBus channels / MessageEvent |
Event-driven |
| Quest tracker | QuestGenerationSystem in stdlib |
Built-in support |
InputEvent pipeline |
LayeredCommandRegistry + pre/post hooks |
Priority-based |
What's Familiar¶
These Ranvier concepts map closely to MAID:
- YAML area files — Both engines support YAML-defined content. MAID's loader pipeline adds validation and resolution phases.
- Bundle/Pack concept — Ranvier bundles ≈ MAID ContentPacks. Both have manifests, dependencies, and modular organization.
- Event-driven design — Both engines use events for communication. MAID's EventBus is centralized rather than per-entity, but the pattern is similar.
- Async everywhere — Both use async I/O. JavaScript's Promises/async-await maps directly to Python's async/await.
- Game loop — Both have a configurable tick-based game loop.
- Telnet + WebSocket — Both support multiple connection protocols.
- Command system — Both have structured command handling, though MAID uses functions instead of classes.
Getting Started: Your First MAID Content Pack¶
Step 1: Map Bundle Structure to ContentPack¶
# Ranvier bundle structure:
bundles/my-game/
├── manifest.yml
├── areas/
│ └── town/
│ ├── rooms.yml
│ ├── npcs.yml
│ └── items.yml
├── commands/
│ └── look.js
├── behaviors/
│ └── guard.js
└── events/
└── combat.js
# MAID content pack structure:
packages/my-game/
├── src/my_game/
│ ├── pack.py # ContentPack (replaces manifest.yml)
│ ├── components.py # Data definitions (replaces attributes)
│ ├── systems.py # Logic (replaces behaviors + events)
│ ├── commands.py # Commands (replaces commands/)
│ └── data/
│ └── areas/
│ └── town/
│ └── rooms.yml # YAML content (similar!)
└── tests/
Step 2: Convert Behaviors to Components + Systems¶
// Ranvier: behaviors/guard.js
module.exports = {
listeners: {
playerEnter: state => function (player) {
Broadcast.sayAt(player, 'The guard watches you carefully.');
},
playerLeave: state => function (player) {
Broadcast.sayAt(player, 'The guard relaxes.');
}
}
};
# MAID: Split into component (data) and system (logic)
class GuardComponent(Component):
"""Marks an NPC as a guard with awareness behavior."""
alert_level: str = "normal"
class GuardSystem(System):
priority: ClassVar[int] = 70
async def startup(self) -> None:
self.events.subscribe(RoomEnterEvent, self.on_enter)
self.events.subscribe(RoomLeaveEvent, self.on_leave)
async def on_enter(self, event: RoomEnterEvent) -> None:
entity = self.entities.get(event.entity_id)
if not entity or not entity.has(PlayerComponent):
return
for guard in self.world.entities_in_room(event.room_id):
if guard.has(GuardComponent):
await self.events.emit(MessageEvent(
sender_id=guard.id,
target_ids=[event.entity_id],
channel="room",
message="The guard watches you carefully.",
))
async def on_leave(self, event: RoomLeaveEvent) -> None:
entity = self.entities.get(event.entity_id)
if not entity or not entity.has(PlayerComponent):
return
for guard in self.world.entities_in_room(event.room_id):
if guard.has(GuardComponent):
await self.events.emit(MessageEvent(
sender_id=guard.id,
target_ids=[event.entity_id],
channel="room",
message="The guard relaxes.",
))
Step 3: Convert Commands¶
// Ranvier: commands/score.js
module.exports = {
usage: 'score',
command: state => (args, player) => {
const health = player.getAttribute('health');
const maxHealth = player.getMaxAttribute('health');
Broadcast.sayAt(player, `Health: ${health}/${maxHealth}`);
Broadcast.sayAt(player, `Level: ${player.level}`);
}
};
# MAID: commands.py
async def cmd_score(ctx: CommandContext) -> bool:
player = ctx.world.get_entity(ctx.player_id)
if not player:
return False
health = player.get(HealthComponent)
stats = player.get(CharacterStatsComponent)
await ctx.session.send(
f"Health: {health.current}/{health.maximum}\n"
f"Level: {stats.level}"
)
return True
Step 4: Create the ContentPack¶
class MyGamePack(BaseContentPack):
@property
def manifest(self) -> ContentPackManifest:
return ContentPackManifest(
name="my-game",
version="1.0.0",
description="My migrated Ranvier game",
)
def get_dependencies(self) -> list[str]:
return ["stdlib"]
def get_systems(self, world: World) -> list[System]:
return [GuardSystem(world)]
def register_commands(self, registry: LayeredCommandRegistry) -> None:
registry.register("score", cmd_score, pack_name="my-game",
description="Show your stats")
async def on_load(self, engine: GameEngine) -> None:
# Load YAML areas, create initial entities, etc.
pass
Migration Checklist¶
- [ ] Map bundle structure → ContentPack directory layout
- [ ] Convert
manifest.yml→ContentPackManifestclass - [ ] Split Behaviors → Components (data) + Systems (logic)
- [ ] Convert entity event listeners →
EventBus.subscribe()subscriptions - [ ] Translate command classes → async handler functions
- [ ] Convert attribute definitions → typed Component fields
- [ ] Migrate YAML area files → MAID YAML format (minor syntax changes)
- [ ] Replace
Broadcast.sayAt()→await session.send() - [ ] Replace
requiredRole→ lock expressions - [ ] Add Python type hints to all functions
- [ ] Bundle everything into 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