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:
- Entities: The base plate (just an ID/container)
- Components: Individual LEGO pieces (position, health, sprite)
- Systems: Instructions for how pieces work together
- Composition: Combine blocks to build anything
- Reusability: Same blocks can build different things
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
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
- Single Responsibility: Each component holds one type of data
- No Logic in Components: Components are data, systems have logic
- Composition Over Inheritance: Build entities by combining components
- System Order Matters: Use priorities to control update order
- Cache Component Queries: Store frequently used entity queries
- Object Pooling: Reuse components and entities for performance
- Event Systems: Use events for system communication
- Data-Oriented Design: Group similar components for cache efficiency
Key Takeaways
- 🧩 ECS separates data (Components) from logic (Systems)
- 📦 Entities are just IDs with component collections
- 🔄 Systems process entities with specific components
- 🎯 Composition creates flexible game objects
- ⚡ Performance benefits from data-oriented design
- 🔧 Easy to add new features without changing existing code
- 🐛 Debugging is easier with isolated systems
🏋️♂️ 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:
- Define five
@dataclasscomponents —Transform(x, y),Velocity(vx, vy),Sprite(color, size),Health(hp, max_hp),AI(behavior)— with no per-tickupdate()methods.Healthmay carrytake_damage()/heal()/is_alive()as state mutators, but the per-frame damage application stays in the system, not the component. - Define an
Entityclass as a thin container: an auto-incrementingid, aname, acomponentsdict keyed by component type, andadd(c)/remove(t)/has(t)/get(t)helpers. Entities hold no behavior beyond add/remove/has/get. - Define three systems with explicit class-level
requiredlists:MovementSystem.required = [Transform, Velocity],RenderSystem.required = [Transform, Sprite],CombatSystem.required = [Health]. Each system'srun(world, dt)iteratesworld.entities()and callsself.matches(e)— defined asall(e.has(t) for t in self.required)— before touching any components. - 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 tickCombatSystem.runapplies it to every entity matching[Health]— particles continue moving untouched, visibly proving the runtime filter. - Bind A to toggle the
AIcomponent on the most recent enemy: ifenemy.has(AI)callenemy.remove(AI), elseenemy.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. - HUD requirements: per-system line showing system name, current match count, and the
requiredcomponent-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 whiche.has(Health)is true — particles must visibly carry no bar. - 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!