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¶
- Systems Guide - Deep dive into systems
- Components Reference - Built-in components
- Events Reference - Built-in events