Skip to main content

Component Systems

Building Flexible Game Objects with Components

Component systems revolutionize how we build game objects! Instead of rigid inheritance hierarchies, we compose objects from reusable components. This Entity-Component-System (ECS) pattern makes games more flexible, maintainable, and performant! 🧩🎮

Understanding Component Systems

🧱 The LEGO Analogy

Think of components like LEGO blocks:

graph TD A["Entity-Component-System"] --> B["Entity"] A --> C["Components"] A --> D["Systems"] B --> E["Unique ID"] B --> F["Component List"] C --> G["Transform"] C --> H["Health"] C --> I["Renderer"] C --> J["Physics"] C --> K["AI"] D --> L["Movement System"] D --> M["Render System"] D --> N["Combat System"] D --> O["Physics System"]
Three-band ECS diagram. Top: a palette of six components (Transform, Velocity, Sprite, Health, Input, AI) shown as labeled pills. Middle: three entity cards composed from those components — Player has Transform, Velocity, Sprite, Health, Input; Enemy has Transform, Velocity, Sprite, Health, AI; Particle has only Transform, Velocity, Sprite. Bottom: two systems — MovementSystem requires Transform and Velocity, CombatSystem requires Health. Dashed slate arrows fan from MovementSystem up to all three entity cards. Dashed amber arrows fan from CombatSystem up to Player and Enemy only. A truncated dashed line ending in an X marker labeled (no Health) sits between CombatSystem and Particle, showing that CombatSystem skips Particle because Particle lacks the required Health component.
Composition over inheritance: each entity is a bag of components pulled from the same palette. Systems filter entities by required components — MovementSystem applies to anything with Transform + Velocity (all three here), while CombatSystem needs Health and so quietly skips Particle. Adding a new entity type means picking components, not editing a class hierarchy.

Interactive ECS Visualizer

Create entities by combining components!

Available Components:

Entities: 0 | FPS: 60

Active Systems:

Basic Component System Implementation

import pygame
from typing import Dict, List, Type, Optional
from dataclasses import dataclass
from abc import ABC, abstractmethod

# Component base class
class Component:
    """Base class for all components"""
    pass

# Example Components
@dataclass
class TransformComponent(Component):
    """Position and rotation component"""
    x: float = 0
    y: float = 0
    rotation: float = 0
    scale: float = 1

@dataclass
class VelocityComponent(Component):
    """Velocity component for movement"""
    vx: float = 0
    vy: float = 0
    angular_velocity: float = 0

@dataclass
class SpriteComponent(Component):
    """Visual representation component"""
    texture: pygame.Surface = None
    color: tuple = (255, 255, 255)
    layer: int = 0

@dataclass
class HealthComponent(Component):
    """Health component for damageable entities"""
    max_health: int = 100
    current_health: int = 100
    
    def take_damage(self, amount: int) -> None:
        self.current_health = max(0, self.current_health - amount)
    
    def heal(self, amount: int) -> None:
        self.current_health = min(self.max_health, self.current_health + amount)
    
    def is_alive(self) -> bool:
        return self.current_health > 0

# Entity class
class Entity:
    """Entity is just a container for components"""
    def __init__(self, entity_id: int, name: str = "") -> None:
        self.id = entity_id
        self.name = name
        self.components: Dict[Type[Component], Component] = {}
        self.active: bool = True
        
    def add_component(self, component: Component) -> 'Entity':
        """Add a component to this entity"""
        self.components[type(component)] = component
        return self
    
    def get_component(self, component_type: Type[Component]) -> Optional[Component]:
        """Get a component of specified type"""
        return self.components.get(component_type)
    
    def has_component(self, component_type: Type[Component]) -> bool:
        """Check if entity has a component"""
        return component_type in self.components
    
    def remove_component(self, component_type: Type[Component]) -> None:
        """Remove a component from this entity"""
        if component_type in self.components:
            del self.components[component_type]

# System base class
class System(ABC):
    """Base class for all systems"""
    def __init__(self) -> None:
        self.required_components: List[Type[Component]] = []
        self.priority: int = 0  # Lower values update first
    
    def get_entities(self, world: 'World') -> List[Entity]:
        """Get all entities that have required components"""
        return [entity for entity in world.entities.values()
                if entity.active and all(
                    entity.has_component(comp_type) 
                    for comp_type in self.required_components
                )]
    
    @abstractmethod
    def update(self, world: 'World', dt: float) -> None:
        """Update system logic"""
        pass

# Movement System
class MovementSystem(System):
    """System that handles entity movement"""
    def __init__(self) -> None:
        super().__init__()
        self.required_components = [TransformComponent, VelocityComponent]
        
    def update(self, world: 'World', dt: float) -> None:
        entities = self.get_entities(world)
        
        for entity in entities:
            transform = entity.get_component(TransformComponent)
            velocity = entity.get_component(VelocityComponent)
            
            # Update position based on velocity
            transform.x += velocity.vx * dt
            transform.y += velocity.vy * dt
            transform.rotation += velocity.angular_velocity * dt

Advanced ECS Architecture

# World/Manager class
class World:
    """ECS World that manages entities and systems"""
    def __init__(self) -> None:
        self.entities: Dict[int, Entity] = {}
        self.systems: List[System] = []
        self.next_entity_id: int = 0
        self.component_pools: Dict[Type[Component], List[Component]] = {}
        
    def create_entity(self, name: str = "") -> Entity:
        """Create a new entity"""
        entity = Entity(self.next_entity_id, name)
        self.entities[self.next_entity_id] = entity
        self.next_entity_id += 1
        return entity
    
    def destroy_entity(self, entity_id: int) -> None:
        """Destroy an entity"""
        if entity_id in self.entities:
            # Return components to pools for reuse
            entity = self.entities[entity_id]
            for component in entity.components.values():
                self.return_to_pool(component)
            del self.entities[entity_id]
    
    def add_system(self, system: System) -> None:
        """Add a system to the world"""
        self.systems.append(system)
        self.systems.sort(key=lambda s: s.priority)
    
    def update(self, dt: float) -> None:
        """Update all systems"""
        for system in self.systems:
            system.update(self, dt)

# Entity Factory for common entity types
class EntityFactory:
    """Factory for creating common entity configurations"""
    
    @staticmethod
    def create_player(world: World, x: float, y: float) -> Entity:
        """Create a player entity"""
        player = world.create_entity("Player")
        player.add_component(TransformComponent(x, y))
        player.add_component(VelocityComponent())
        player.add_component(SpriteComponent(color=(0, 255, 0)))
        player.add_component(HealthComponent(100))
        player.add_component(ColliderComponent(32, 32, collision_layer=0))
        player.add_component(InputComponent())
        return player
    
    @staticmethod
    def create_enemy(world: World, x: float, y: float, enemy_type: str = "basic") -> Entity:
        """Create an enemy entity"""
        enemy = world.create_entity(f"Enemy_{enemy_type}")
        enemy.add_component(TransformComponent(x, y))
        enemy.add_component(VelocityComponent())
        enemy.add_component(ColliderComponent(24, 24, collision_layer=1))
        
        if enemy_type == "basic":
            enemy.add_component(HealthComponent(50))
            enemy.add_component(SpriteComponent(color=(255, 0, 0)))
            enemy.add_component(AIComponent("patrol"))
        elif enemy_type == "tank":
            enemy.add_component(HealthComponent(200))
            enemy.add_component(SpriteComponent(color=(128, 0, 0)))
            enemy.add_component(AIComponent("guard"))
        elif enemy_type == "fast":
            enemy.add_component(HealthComponent(30))
            enemy.add_component(SpriteComponent(color=(255, 128, 0)))
            enemy.add_component(AIComponent("chase"))
            velocity = enemy.get_component(VelocityComponent)
            velocity.vx = 200  # Fast movement
        
        return enemy

Best Practices

⚡ Component System Tips

Key Takeaways

🏋️‍♂️ Practice Exercise

🏋️‍♂️ Exercise 1: Composable Entity Builder + System Filter + Live Component Toggle in One Pygame Window

Objective: Build a single-process pygame demo (~85 lines) that makes the three ECS pillars visible on one screen: (a) systems filter entities at runtime via a class-level required list so a brand-new entity type with the right component bag automatically becomes a system citizen with zero system-code change; (b) Player / Enemy / Particle differ only by which components they hold (composition over inheritance); (c) components are pure data — Health exposes take_damage / heal / is_alive mutators but the per-tick damage step lives in CombatSystem.run(), not in Health.update().

Instructions:

  1. Define five @dataclass components — Transform(x, y), Velocity(vx, vy), Sprite(color, size), Health(hp, max_hp), AI(behavior) — with no per-tick update() methods. Health may carry take_damage() / heal() / is_alive() as state mutators, but the per-frame damage application stays in the system, not the component.
  2. Define an Entity class as a thin container: an auto-incrementing id, a name, a components dict keyed by component type, and add(c) / remove(t) / has(t) / get(t) helpers. Entities hold no behavior beyond add/remove/has/get.
  3. Define three systems with explicit class-level required lists: MovementSystem.required = [Transform, Velocity], RenderSystem.required = [Transform, Sprite], CombatSystem.required = [Health]. Each system's run(world, dt) iterates world.entities() and calls self.matches(e) — defined as all(e.has(t) for t in self.required) — before touching any components.
  4. Bind keys: 1 spawns a Player (Transform + Velocity + Sprite + Health), 2 spawns an Enemy (Transform + Velocity + Sprite + Health + AI), 3 spawns a Particle (Transform + Velocity + Sprite — no Health). H queues 20 damage on CombatSystem.pending; on the next tick CombatSystem.run applies it to every entity matching [Health] — particles continue moving untouched, visibly proving the runtime filter.
  5. Bind A to toggle the AI component on the most recent enemy: if enemy.has(AI) call enemy.remove(AI), else enemy.add(AI('chase')). The HUD must recompute the per-system match counts every frame so removing AI flips the AI-relevant count between 1 and 0 with no other change — component composition drives system membership at runtime.
  6. HUD requirements: per-system line showing system name, current match count, and the required component-name list (e.g. MovementSystem: 3 matches required=['Transform', 'Velocity']); per-entity line showing entity name, id, and its component-bag (e.g. Particle #4: ['Transform', 'Velocity', 'Sprite']); a small horizontal HP bar drawn only on entities for which e.has(Health) is true — particles must visibly carry no bar.
  7. Verify all three pillars on screen in one run: spawn one of each entity type (3 entities, all moving rightward), press H three or four times to drain Player and Enemy HP to zero while the Particle keeps cruising with no HP bar (confirms runtime filter via Health-required); press A to remove AI from the enemy and watch the AI-system match count drop from 1 to 0 while every other system stays unchanged (confirms component-toggle drives system membership with no system code touched).
💡 Hint

The temptation is to give each component an update(self, dt) method and let entities loop over their own components calling component.update(dt). Resist it. The whole point of a system is that the per-tick loop body lives once, in the system, and applies to every matching entity — if you scatter update() across components you have reinvented inheritance with extra steps. Components are dataclasses that may carry small synchronous mutators (take_damage, heal) but never per-frame ticks; the per-frame work is the system's job.

For the runtime filter: the matches(e) helper using all(e.has(t) for t in self.required) is three lines but it is the entire ECS contract. Once it works, you can spawn any entity composed of any subset of components and the right systems automatically pick it up. That is the property the demo is meant to make visible — spawn a Particle (no Health) and watch CombatSystem's match count stay at zero for that entity while MovementSystem and RenderSystem happily count it. Press A on the enemy and the same filter recomputes the next frame with no system code touched.

✅ Example Solution
import pygame
from dataclasses import dataclass

# COMPONENTS (pure data; no per-tick update())
@dataclass
class Transform: x: float = 0; y: float = 0
@dataclass
class Velocity: vx: float = 0; vy: float = 0
@dataclass
class Sprite: color: tuple = (255, 255, 255); size: int = 16
@dataclass
class Health:
    hp: int = 100
    max_hp: int = 100
    def take_damage(self, n): self.hp = max(0, self.hp - n)
    def is_alive(self): return self.hp > 0
@dataclass
class AI: behavior: str = 'patrol'

class Entity:
    _next = 0
    def __init__(self, name=''):
        self.id = Entity._next; Entity._next += 1
        self.name = name; self.components = {}
    def add(self, c): self.components[type(c)] = c; return self
    def remove(self, t): self.components.pop(t, None)
    def has(self, t): return t in self.components
    def get(self, t): return self.components.get(t)

# SYSTEMS (logic; required declared at class scope, filtered at runtime)
class System:
    required = []
    def matches(self, e): return all(e.has(t) for t in self.required)

class MovementSystem(System):
    required = [Transform, Velocity]
    def run(self, world, dt):
        for e in world.entities():
            if self.matches(e):
                t, v = e.get(Transform), e.get(Velocity)
                t.x += v.vx * dt; t.y += v.vy * dt
                if t.x > 620: t.x = 20

class RenderSystem(System):
    required = [Transform, Sprite]
    def run(self, world, dt):
        for e in world.entities():
            if self.matches(e):
                t, s = e.get(Transform), e.get(Sprite)
                pygame.draw.rect(world.screen, s.color, (t.x, t.y, s.size, s.size))
                if e.has(Health):  # HP bar only when relevant
                    h = e.get(Health); w = int(s.size * h.hp / h.max_hp)
                    pygame.draw.rect(world.screen, (255, 60, 60), (t.x, t.y - 6, w, 3))

class CombatSystem(System):
    required = [Health]
    pending = 0
    def run(self, world, dt):
        if self.pending:
            for e in world.entities():
                if self.matches(e): e.get(Health).take_damage(self.pending)
            self.pending = 0

class World:
    def __init__(self, screen):
        self.screen = screen; self._e = {}; self.systems = []
    def add(self, e): self._e[e.id] = e; return e
    def entities(self): return self._e.values()
    def update(self, dt):
        for s in self.systems: s.run(self, dt)

pygame.init(); screen = pygame.display.set_mode((640, 480))
clock = pygame.time.Clock(); font = pygame.font.Font(None, 18)
world = World(screen); movement, render, combat = MovementSystem(), RenderSystem(), CombatSystem()
world.systems = [movement, render, combat]; enemy_ref = None

def make(name, *comps):
    e = Entity(name)
    for c in comps: e.add(c)
    return world.add(e)

running = True
while running:
    dt = clock.tick(60) / 1000.0
    for ev in pygame.event.get():
        if ev.type == pygame.QUIT: running = False
        elif ev.type == pygame.KEYDOWN:
            if ev.key == pygame.K_1:
                make('Player', Transform(20,200), Velocity(40,0), Sprite((0,255,0),24), Health(100,100))
            elif ev.key == pygame.K_2:
                enemy_ref = make('Enemy', Transform(20,280), Velocity(60,0), Sprite((255,0,0),20), Health(80,80), AI('patrol'))
            elif ev.key == pygame.K_3:
                make('Particle', Transform(20,360), Velocity(80,0), Sprite((255,255,0),6))
            elif ev.key == pygame.K_h: combat.pending = 20
            elif ev.key == pygame.K_a and enemy_ref:
                if enemy_ref.has(AI): enemy_ref.remove(AI)
                else: enemy_ref.add(AI('chase'))

    screen.fill((30, 30, 40))
    world.update(dt)

    # HUD: per-system match counts; per-entity component-bag
    y = 8
    for s in world.systems:
        n = sum(1 for e in world.entities() if s.matches(e))
        line = f"{type(s).__name__}: {n} matches  required={[t.__name__ for t in s.required]}"
        screen.blit(font.render(line, True, (200, 200, 200)), (10, y)); y += 18
    y += 8
    for e in world.entities():
        bag = [t.__name__ for t in e.components.keys()]
        screen.blit(font.render(f'{e.name} #{e.id}: {bag}', True, (180, 200, 220)), (10, y)); y += 16

    pygame.display.flip()
pygame.quit()

🎯 Quick Quiz

Question 1: In the demo, MovementSystem declares required = [Transform, Velocity] at class-definition time and then calls self.matches(e) on every entity each tick before reading components. Why this two-step pattern (design-time declaration + runtime filter) instead of a hardcoded list of entity types the system applies to, or wrapping each component read in try / except AttributeError?

Question 2: You are adding three enemy variants: a flying tank (gains a Flight component), a stealth scout (gains a Cloak component), and a flying-stealth scout (gains both). Why is the ECS approach — mix Flight and Cloak into the existing component bag — preferred over an inheritance chain like FlyingTank(Tank), StealthScout(Scout), FlyingStealthScout(...)?

Question 3: Health in the demo is a dataclass that exposes take_damage / heal / is_alive mutators, but it has no update(self, dt) method — the per-tick damage application lives in CombatSystem.run(). Python's @dataclass would let you put update() on Health; why is the per-tick logic deliberately kept out of the component class?

What's Next?

Now that you understand component systems, next we'll explore event systems - how to create decoupled communication between game systems!