Skip to content

Entity Component System Overview

MAID uses an Entity Component System (ECS) architecture for game objects. ECS separates data (components) from behavior (systems), creating a flexible, composable, and performant game architecture.

What is ECS?

ECS is an architectural pattern consisting of three core concepts:

  • Entities: Unique identifiers that group components together
  • Components: Pure data containers with no behavior
  • Systems: Logic that operates on entities with specific components
+------------+     +------------+     +------------+
|   Entity   |     |   Entity   |     |   Entity   |
|   (UUID)   |     |   (UUID)   |     |   (UUID)   |
+-----+------+     +-----+------+     +-----+------+
      |                 |                 |
      v                 v                 v
+------------+   +------------+   +------------+
| Position   |   | Position   |   | Position   |
| Health     |   | Health     |   | AI         |
| Player     |   | NPC        |   | Inventory  |
+------------+   +------------+   +------------+

              +-----------------+
              |  Combat System  |
              |  (processes     |
              |  Position +     |
              |  Health)        |
              +-----------------+

Why ECS?

Composition Over Inheritance

Instead of complex class hierarchies:

# Traditional OOP hierarchy
class GameObject: ...
class Character(GameObject): ...
class Player(Character): ...
class NPC(Character): ...
class Monster(NPC): ...
class FriendlyNPC(NPC): ...

ECS uses composition:

# ECS composition
player = entity.add(PositionComponent, HealthComponent, PlayerComponent)
npc = entity.add(PositionComponent, HealthComponent, NPCComponent, DialogueComponent)
monster = entity.add(PositionComponent, HealthComponent, AIComponent, CombatComponent)

Flexibility

Add or remove capabilities at runtime:

# Make a player temporarily invincible
player.remove(HealthComponent)

# Give an NPC the ability to trade
npc.add(MerchantComponent)

# Transform a monster into a friendly creature
monster.remove(HostileComponent)
monster.add(FriendlyComponent)

Performance

Systems can efficiently process entities with specific components:

# Only iterates entities that have BOTH components
for entity in manager.with_components(PositionComponent, MovementComponent):
    # Process movement

Entities

Creating Entities

from maid_engine.core.ecs import EntityManager

manager = EntityManager()

# Create a new entity
entity = manager.create()
print(entity.id)  # UUID

# Create with specific ID
from uuid import uuid4
specific_id = uuid4()
entity = manager.create(entity_id=specific_id)

Entity Properties

# Unique identifier
entity.id  # UUID

# Tags for categorization
entity.tags  # set[str]

# Timestamps
entity.created_at  # datetime
entity.updated_at  # datetime

Managing Components

from maid_stdlib.components import HealthComponent, PositionComponent

# Add components (returns self for chaining)
entity.add(PositionComponent(room_id=room_id))
entity.add(HealthComponent(current=100, maximum=100))

# Or chain
entity.add(PositionComponent(room_id=room_id)).add(HealthComponent(current=100, maximum=100))

# Check for components
entity.has(HealthComponent)  # True
entity.has(HealthComponent, PositionComponent)  # True (has both)
entity.has_any(HealthComponent, ManaComponent)  # True (has at least one)

# Get components
health = entity.get(HealthComponent)  # Raises KeyError if missing
health = entity.try_get(HealthComponent)  # Returns None if missing

# Remove components
removed = entity.remove(HealthComponent)  # Returns the component or None

Tags

Tags provide quick categorization without components:

# Add tags
entity.add_tag("player")
entity.add_tag("online")

# Check tags
entity.has_tag("player")  # True

# Remove tags
entity.remove_tag("online")  # Returns True if was present

# Access all tags
print(entity.tags)  # {"player"}

Serialization

# Convert to dictionary
data = entity.to_dict()
# {
#     "id": "uuid-string",
#     "tags": ["player"],
#     "components": {
#         "HealthComponent": {"current": 100, "maximum": 100, ...},
#         "PositionComponent": {"room_id": "...", "x": 0, "y": 0, "z": 0},
#     },
#     "created_at": "2024-01-15T...",
#     "updated_at": "2024-01-15T...",
# }

Entity Manager

The EntityManager handles entity lifecycle and provides efficient queries.

Basic Operations

manager = EntityManager()

# Create
entity = manager.create()

# Get by ID
entity = manager.get(entity_id)  # Returns None if not found
entity = manager.get_or_raise(entity_id)  # Raises KeyError if not found

# Destroy
manager.destroy(entity_id)  # Returns True if existed

# Count
count = manager.count()
count = len(manager)

# Check existence
exists = entity_id in manager

Querying Entities

# All entities
for entity in manager.all():
    process(entity)

# By components
for entity in manager.with_components(PositionComponent):
    # Has PositionComponent
    pass

for entity in manager.with_components(PositionComponent, HealthComponent):
    # Has BOTH components
    pass

# By tag
for entity in manager.with_tag("player"):
    # Has "player" tag
    pass

for entity in manager.with_tags("player", "online"):
    # Has BOTH tags
    pass

Query Efficiency

The EntityManager maintains indexes for efficient queries:

# O(1) lookup by ID
entity = manager.get(entity_id)

# Efficient component queries using set intersection
# Starts with smallest component set, then intersects
for entity in manager.with_components(RareComponent, CommonComponent):
    # Iterates only entities with RareComponent, then filters

Components

Defining Components

Components inherit from Component and are pure data:

from maid_engine.core.ecs import Component
from uuid import UUID


class PositionComponent(Component):
    """Tracks where an entity is located."""

    room_id: UUID
    x: int = 0
    y: int = 0
    z: int = 0


class HealthComponent(Component):
    """Tracks entity health."""

    current: int
    maximum: int
    regeneration_rate: float = 1.0

    @property
    def percentage(self) -> float:
        """Health as percentage (0-100)."""
        if self.maximum <= 0:
            return 0.0
        return (self.current / self.maximum) * 100.0

    @property
    def is_alive(self) -> bool:
        """Whether entity is alive."""
        return self.current > 0

Component Features

Components are Pydantic models with:

  • Type validation
  • Default values
  • Computed properties
  • Serialization
class StatsComponent(Component):
    """Character statistics."""

    strength: int = 10
    dexterity: int = 10
    constitution: int = 10

    def get_modifier(self, stat: str) -> int:
        """D&D-style modifier: (stat - 10) // 2."""
        value = getattr(self, stat, 10)
        return (value - 10) // 2

Component Type Identifier

Each component has a type identifier:

class MyComponent(Component):
    pass

# Automatic from class name
MyComponent.get_type()  # "MyComponent"

# Or explicit
class MyComponent(Component):
    component_type = "custom_name"

Systems

Defining Systems

Systems contain game logic and run every tick:

from maid_engine.core.ecs import System


class MovementSystem(System):
    """Processes entity movement."""

    priority = 20  # Lower runs earlier

    async def update(self, delta: float) -> None:
        """Called every game tick."""
        for entity in self.entities.with_components(
            PositionComponent, MovementComponent
        ):
            pos = entity.get(PositionComponent)
            mov = entity.get(MovementComponent)
            # Process movement...

System Properties

class MySystem(System):
    async def update(self, delta: float) -> None:
        # Access the world
        world = self.world

        # Access entity manager
        entities = self.entities

        # Access event bus
        events = self.events

System Lifecycle

class DatabaseSystem(System):
    async def startup(self) -> None:
        """Called when system starts."""
        self.connection = await connect_database()

    async def shutdown(self) -> None:
        """Called when system stops."""
        await self.connection.close()

    async def update(self, delta: float) -> None:
        # Use self.connection...
        pass

System Priority

Systems run in priority order (lower first):

class InputSystem(System):
    priority = 10  # Runs first


class PhysicsSystem(System):
    priority = 50  # Runs in middle


class RenderSystem(System):
    priority = 100  # Runs last

Enabling/Disabling

class OptionalSystem(System):
    enabled = True  # Can be toggled

    async def update(self, delta: float) -> None:
        # Base class checks enabled, but explicit check is fine
        if not self.enabled:
            return
        # Process...

System Manager

Registering Systems

from maid_engine.core.ecs import SystemManager

manager = SystemManager(world)

# Register systems
manager.register(MovementSystem(world))
manager.register(CombatSystem(world))

# Systems are automatically sorted by priority

Managing Systems

# Get a system
combat = manager.get(CombatSystem)
combat = manager.get_or_raise(CombatSystem)

# Enable/disable
manager.enable(CombatSystem)
manager.disable(CombatSystem)

# Unregister
manager.unregister(CombatSystem)

# List all systems
for system in manager.systems:
    print(f"{system.__class__.__name__}: priority={system.priority}")

Running Systems

# Start all systems
await manager.startup()

# Update all systems (in priority order)
await manager.update(delta=0.25)  # 250ms

# Stop all systems
await manager.shutdown()

Best Practices

Keep Components Pure Data

# Good: Pure data
class HealthComponent(Component):
    current: int
    maximum: int


# Avoid: Logic in components
class HealthComponent(Component):
    current: int
    maximum: int

    def take_damage(self, amount: int) -> None:  # Move to system
        self.current -= amount

Use Systems for Logic

class DamageSystem(System):
    async def apply_damage(self, entity_id: UUID, amount: int) -> None:
        entity = self.entities.get(entity_id)
        if entity and entity.has(HealthComponent):
            health = entity.get(HealthComponent)
            health.current = max(0, health.current - amount)

            if health.current <= 0:
                await self.events.emit(EntityDeathEvent(entity_id=entity_id))

Design for Composition

# Good: Composable components
player = entity.add(PositionComponent(...))
player.add(HealthComponent(...))
player.add(InventoryComponent(...))
player.add(PlayerComponent(...))

# Need to add magic later?
player.add(ManaComponent(...))

Query Efficiently

# Good: Query once, process results
entities = list(self.entities.with_components(HealthComponent))
for entity in entities:
    # Process

# Avoid: Nested queries
for entity in self.entities.with_components(PositionComponent):
    for other in self.entities.with_components(PositionComponent):  # Expensive
        # Check distance

Use Tags for Categories

# Good: Use tags for boolean categories
entity.add_tag("hostile")
entity.add_tag("boss")

# Query by tag
for boss in self.entities.with_tag("boss"):
    # Special boss processing

# Avoid: Component for simple flag
class HostileComponent(Component):
    is_hostile: bool = True  # Just use a tag

Integration with Events

Systems communicate through events:

class CombatSystem(System):
    async def startup(self) -> None:
        self.events.subscribe(AttackEvent, self._handle_attack)

    async def _handle_attack(self, event: AttackEvent) -> None:
        # Process attack...
        await self.events.emit(DamageDealtEvent(...))

Tick Information

Access tick information in systems:

from maid_engine.core.ecs import TickInfo


class MySystem(System):
    async def update(self, delta: float) -> None:
        # delta is time since last tick in seconds
        # Typical values: 0.25 (4 ticks/sec), 0.1 (10 ticks/sec)

        # For time-based calculations
        distance = speed * delta  # Movement per tick
        healing = regen_rate * delta  # Healing per tick

Next Steps