Skip to content

Entity Component System (ECS)

MAID uses an Entity Component System architecture for managing game objects.

What is ECS?

ECS is a data-oriented design pattern that separates:

  • Entities - Unique identifiers for game objects (players, NPCs, items, rooms)
  • Components - Pure data containers attached to entities
  • Systems - Logic that operates on entities with specific components

Benefits

  1. Composition over Inheritance - Build complex objects by combining simple components
  2. Cache-Friendly - Components of the same type are stored together
  3. Decoupled Logic - Systems are independent and can be tested in isolation
  4. Hot-Swappable - Add/remove components at runtime
  5. Flexible - The same component can be used by different entity types

When to Use Components vs Inheritance

Use Components when:

  • Multiple entity types share the same data/behavior (e.g., both players and NPCs have health)
  • You need to add/remove capabilities at runtime (e.g., add flying to a player with a spell)
  • The feature is optional for an entity type (e.g., not all items are containers)

Use Inheritance when:

  • There's truly shared implementation code (but consider mixins instead)
  • You need Python's type system for static analysis
  • The relationship is genuinely "is-a" not "has-a"
# Prefer composition (components)
player.add(HealthComponent(100, 100))
player.add(InventoryComponent(capacity=20))
player.add(PositionComponent(room_id))

# Over inheritance
class Player(HasHealth, HasInventory, HasPosition):  # Avoid this
    pass

Entity Lifecycle

Creating Entities

# Create through the EntityManager for proper tracking
entity = world.entities.create()

# Or create with a specific ID
from uuid import UUID
entity = world.entities.create(entity_id=UUID("..."))

Adding Components

from maid_stdlib.components import HealthComponent, PositionComponent

# Add multiple components
entity.add(HealthComponent(current=100, maximum=100))
entity.add(PositionComponent(room_id=room.id))
entity.add_tag("player")

# Check for components
if entity.has(HealthComponent):
    health = entity.get(HealthComponent)
    health.current -= damage

Querying Entities

# Get all entities with specific components
for entity in world.entities.with_components(HealthComponent, PositionComponent):
    health = entity.get(HealthComponent)
    pos = entity.get(PositionComponent)
    # Process entity...

# Query by tag
for entity in world.entities.with_tag("npc"):
    # Process NPCs...

# Combined queries
for entity in world.entities.with_tags("enemy", "boss"):
    # Process boss enemies...

Removing Components and Entities

# Remove a component
entity.remove(HealthComponent)

# Destroy an entity (removes from EntityManager)
world.entities.destroy(entity.id)

Systems

Systems contain the game logic that operates on entities:

from maid_engine.core.ecs import System

class RegenerationSystem(System):
    # Lower priority = runs earlier in the tick
    priority = 50

    async def update(self, delta: float) -> None:
        """Called every tick. delta is time since last tick."""
        for entity in self.entities.with_components(HealthComponent):
            health = entity.get(HealthComponent)
            if health.current < health.maximum:
                # Regenerate 1 HP per second
                health.current = min(
                    health.current + delta,
                    health.maximum
                )

    async def startup(self) -> None:
        """Called when the system is registered."""
        # Subscribe to events, load resources, etc.
        pass

    async def shutdown(self) -> None:
        """Called when the system is unregistered."""
        # Clean up resources
        pass

System Priority

Systems run in priority order each tick:

class InputSystem(System):
    priority = 10  # Runs first - process player input

class MovementSystem(System):
    priority = 20  # Runs second - apply movement

class CombatSystem(System):
    priority = 30  # Runs third - resolve combat

class RenderSystem(System):
    priority = 100  # Runs last - send output to players

Lifecycle Events

The ECS emits events automatically when entities and components change:

Operation Event Emitted
Entity.add(component) ComponentAddedEvent
Entity.remove(component_type) ComponentRemovedEvent
EntityManager.create() EntityCreatedEvent
EntityManager.destroy(id) EntityDestroyedEvent
Entity.add_tag(tag) TagAddedEvent
Entity.remove_tag(tag) TagRemovedEvent

These events enable reactive patterns — for example, the persistence system listens for ComponentAddedEvent to begin tracking new components.

Dirty Tracking

The Component base class includes a __setattr__ hook that marks the owning entity as dirty when any field is modified. The persistence layer uses this to efficiently save only changed entities each tick.

Component Registry

The ComponentRegistry maps type name strings to component classes, enabling serialization and deserialization of entities:

from maid_engine.core.ecs import ComponentRegistry

registry = ComponentRegistry()
registry.register("health", HealthComponent)

# Deserialize from stored data
component_cls = registry.get("health")
component = component_cls(**saved_data)

Content packs register their component types via the register_component_types(registry) method.

Component Design Guidelines

  1. Components should be pure data - No methods that modify other entities
  2. Keep components small and focused - One responsibility per component
  3. Use Pydantic models for validation - Automatic type checking and serialization
from maid_engine.core.ecs import Component

class HealthComponent(Component):
    """Health data for entities that can take damage."""
    current: int
    maximum: int
    regeneration_rate: float = 0.0

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

    @property
    def percentage(self) -> float:
        return (self.current / self.maximum) * 100 if self.maximum > 0 else 0

Further Reading