Skip to main content

Lesson 1: Entity-Component-System

  • Module 1: Engine Architecture
  • Lesson 1 of 27
  • ⏱️ About 2 h (instruction + lab)

Big games have hundreds of kinds of things in them, and a class hierarchy that tries to describe them all eventually collapses under its own weight. In this lesson you build an entity-component-system (ECS), the architecture many engines use to keep game objects flexible, and use it to run an arena where new kinds of entities appear without a single new class.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Explain why deep inheritance hierarchies break down as a game grows, and how composition avoids the "class explosion".
  • Build a World that stores components by type and answers "which entities have all of these components?" queries.
  • Write systems that hold all the behavior, run in a deliberate order, and use dt in seconds.
  • Compose a new kind of entity from existing components, and add or remove a component at runtime to change what it does.
  • Debug the classic ECS problems: destroying entities mid-loop, missing targets and systems in the wrong order.

Project: Component Arena, a pygame-ce arena where a player, chasing enemies and particles are all built from the same small set of components.

In This Lesson

🌳 Why Inheritance Runs Out of Road

Picture the enemy roster of a growing game. You start with a tidy tree: Entity → Enemy → Tank and Scout. Then design asks for a flying tank. Then a stealth scout. Then a flying stealth scout, a stealth tank, and a turret that is an enemy but never moves. Each feature is a yes-or-no choice, so two features already give four combinations per base type, and three give eight.

ApproachAdding "Flying" and "Cloak" to Tank and ScoutWhat goes wrong
Deeper inheritanceFlyingTank, StealthTank, FlyingStealthTank, and the same three for ScoutOne new class per combination; shared code gets copied or tangled in multiple inheritance
Flags on a base classself.flying = False, self.cloaked = False on every entityEvery entity carries every feature's data, and the base class grows forever
CompositionA Flying component and a Cloak component, attached to whatever needs themNothing: any entity can have either, both or neither

You met composition in Intermediate Python as "has-a instead of is-a". An entity-component-system takes that idea all the way: a game object is nothing but the set of components it has, and behavior lives in systems that look for particular sets.

Think of LEGO. The baseplate (the entity) does nothing by itself. The bricks (components) are standard shapes you can put on any baseplate. The instructions (systems) say what happens to every model that has, say, wheels and an axle, whatever else is on it.

💡 Why this matters

Engines such as Unity's DOTS and Bevy build their object model on ECS, and many others build objects by composition even when they are not a full ECS (Unity's classic GameObjects carry components; Godot builds objects from child nodes). Once you think in components, you can read the architecture of most modern engines, and your own games stop needing a new class every time a designer has an idea.

🧩 Entities, Components and Systems

The three pieces each have exactly one job:

  • Entity: an identity and nothing else. In this lesson it is literally an int, such as 7.
  • Component: a small bundle of data about one aspect of an entity: where it is (Transform), how fast it moves (Velocity), how hurt it is (Health). Components have no per-frame logic.
  • System: all the behavior. Each frame, a system asks the world for every entity that has a particular set of components and updates them. MovementSystem wants Transform + Velocity; it does not care whether the entity is a player, a bullet or a leaf.
Three bands. Top: a palette of components (Transform, Velocity, Sprite, Health, Input, AI). Middle: three entities built from them; Player and Enemy both have Health, Particle has only Transform, Velocity and Sprite. Bottom: MovementSystem requires Transform and Velocity and reaches all three entities; CombatSystem requires Health and skips Particle.
Entities are bags of components. MovementSystem matches all three because they all have Transform and Velocity; CombatSystem needs Health, so it skips the Particle.

Play with it before you code it. Pick components, spawn entities, and watch the system counts at the bottom change. Try an entity with a Sprite but no Velocity (it never moves), or Health but no Sprite (it exists and can be damaged, but you can't see it).

Next entity has:

✅ Growth Mindset: "This Is More Code, Not Less," For Now

Almost everyone's first reaction to ECS is that three classes and a query to move a ball is overkill. For one ball, it is. The payoff arrives on the day you add your tenth kind of entity and write zero new classes. If the pieces feel scattered right now, that is normal: you haven't yet built enough entity types to feel the benefit. Keep a list as you work through the exercise of every new behavior you get "for free", and watch it grow.

🗃️ Building the World

Components are perfect @dataclass material: they are data, with a readable repr for free. Positions use pygame.Vector2 so they stay floats:

from dataclasses import dataclass, field

import pygame


@dataclass
class Transform:
    pos: pygame.Vector2


@dataclass
class Velocity:
    vel: pygame.Vector2 = field(default_factory=pygame.Vector2)   # px/s; a new Vector2 each time


@dataclass
class Health:
    max_hp: float
    hp: float = field(init=False)       # not a constructor argument...

    def __post_init__(self):
        self.hp = self.max_hp           # ...every new entity starts at full health

Two details matter. field(default_factory=pygame.Vector2) gives every Velocity its own vector; a plain default would be one shared object. And Health(40) now starts at 40 of 40. A version that took max_hp and hp as two separate arguments with defaults of 100 would let Health(40) start at 100 of 40, which is a bug that shows up as enemies that take too many hits to kill.

The World owns every component. It keeps one dictionary per component type, mapping entity id to component. Looking up "all entities with Transform and Velocity" becomes: take the smaller of the two dictionaries and keep the ids that are also in the other one.

class World:
    def __init__(self):
        self.next_id = 0
        self.stores = {}                    # component type -> {entity id: component}
        self.alive = set()

    def create(self, *components):
        eid = self.next_id
        self.next_id += 1
        self.alive.add(eid)
        for component in components:
            self.add(eid, component)
        return eid

    def add(self, eid, component):
        self.stores.setdefault(type(component), {})[eid] = component

    def remove(self, eid, ctype):
        self.stores.get(ctype, {}).pop(eid, None)

    def get(self, eid, ctype):
        return self.stores.get(ctype, {}).get(eid)

    def query(self, *ctypes):
        """Yield (eid, component, ...) for every entity that has ALL of ctypes."""
        stores = [self.stores.get(t, {}) for t in ctypes]
        smallest = min(stores, key=len)
        for eid in list(smallest):          # a copy, so systems may add or remove safely
            if all(eid in store for store in stores):
                yield (eid, *(store[eid] for store in stores))

Because query yields the components in the order you asked for them, a system can unpack them directly: for eid, transform, velocity in world.query(Transform, Velocity):. Making an entity is just listing its components:

player = world.create(Transform(pygame.Vector2(450, 280)), Velocity(),
                      Sprite((74, 222, 128), 14), Health(100), PlayerInput())
spark = world.create(Transform(pygame.Vector2(450, 80)),
                     Velocity(pygame.Vector2(150, 0)), Sprite((250, 204, 21), 3), Lifetime(0.6))

There is no Player class and no Spark class. A spark is "something with a Transform, a Velocity, a Sprite and a Lifetime", and every system that cares about any of those will treat it correctly.

⚙️ Systems and Their Order

A system declares the components it needs and loops over the matching entities. Keeping the list in a class attribute documents the system's contract and lets a HUD count its matches:

class System:
    required = ()

    def matches(self, world):
        return sum(1 for _ in world.query(*self.required))


class MovementSystem(System):
    required = (Transform, Velocity)

    def update(self, world, dt):
        for _, transform, velocity in world.query(*self.required):
            transform.pos += velocity.vel * dt          # px/s times seconds


class AISystem(System):
    required = (AI, Transform, Velocity)

    def update(self, world, dt):
        for _, ai, transform, velocity in world.query(*self.required):
            target = world.get(ai.target, Transform)
            if target is None:                          # the target was destroyed
                velocity.vel = pygame.Vector2()
                continue
            to_target = target.pos - transform.pos
            if to_target.length_squared() > 0:          # normalize() fails on a zero vector
                velocity.vel = to_target.normalize() * ai.speed

Notice what AISystem does not do: it doesn't move anything. It only sets a velocity, and MovementSystem moves everything that has one. That split is why order matters. Each frame the systems run in a fixed list, and each one sees the results of the ones before it:

graph LR A["Input<br/>sets player velocity"] --> B["AI<br/>sets enemy velocity"] B --> C["Movement<br/>pos += vel * dt"] C --> D["Combat<br/>damage, deaths"] D --> E["Lifetime<br/>expire particles"] E --> F["Render<br/>draw"] F --> G["world.flush()<br/>remove the dead"]

If Render ran before Movement, everything would be drawn one frame behind where it really is. If AI ran after Movement, enemies would react to where the player was. Neither crashes, which is exactly why order bugs are sneaky.

Here is a complete, runnable ECS in about 90 lines. Twelve balls bounce, forty stars sit still, and one comet flies straight off the screen, all from four components and three systems:

import random
from dataclasses import dataclass

import pygame

WIDTH, HEIGHT = 800, 450


@dataclass
class Transform:
    pos: pygame.Vector2


@dataclass
class Velocity:
    vel: pygame.Vector2


@dataclass
class Sprite:
    color: tuple
    radius: float


@dataclass
class Bouncy:
    pass                                    # a "tag" component: no data, just a label


class World:
    def __init__(self):
        self.next_id = 0
        self.stores = {}                    # component type -> {entity id: component}

    def create(self, *components):
        eid = self.next_id
        self.next_id += 1
        for c in components:
            self.stores.setdefault(type(c), {})[eid] = c
        return eid

    def query(self, *ctypes):
        stores = [self.stores.get(t, {}) for t in ctypes]
        for eid in list(min(stores, key=len)):
            if all(eid in s for s in stores):
                yield (eid, *(s[eid] for s in stores))


def movement_system(world, dt):
    for _, t, v in world.query(Transform, Velocity):
        t.pos += v.vel * dt


def bounce_system(world, dt):
    for _, t, v, s, _ in world.query(Transform, Velocity, Sprite, Bouncy):
        if t.pos.x < s.radius:
            t.pos.x, v.vel.x = s.radius, abs(v.vel.x)
        elif t.pos.x > WIDTH - s.radius:
            t.pos.x, v.vel.x = WIDTH - s.radius, -abs(v.vel.x)
        if t.pos.y < s.radius:
            t.pos.y, v.vel.y = s.radius, abs(v.vel.y)
        elif t.pos.y > HEIGHT - s.radius:
            t.pos.y, v.vel.y = HEIGHT - s.radius, -abs(v.vel.y)


def render_system(world, screen):
    for _, t, s in world.query(Transform, Sprite):
        pygame.draw.circle(screen, s.color, t.pos, s.radius)


pygame.init()
screen = pygame.display.set_mode((WIDTH, HEIGHT))
pygame.display.set_caption("Minimal ECS")
clock = pygame.time.Clock()
rng = random.Random(1)
world = World()

for _ in range(12):                         # balls: move, bounce, draw
    world.create(Transform(pygame.Vector2(rng.uniform(50, 750), rng.uniform(50, 400))),
                 Velocity(pygame.Vector2(rng.uniform(-200, 200), rng.uniform(-200, 200))),
                 Sprite((96, 165, 250), 12), Bouncy())
for _ in range(40):                         # stars: draw only, never move
    world.create(Transform(pygame.Vector2(rng.uniform(0, 800), rng.uniform(0, 450))),
                 Sprite((71, 85, 105), 2))
world.create(Transform(pygame.Vector2(0, 225)),  # a comet: moves but never bounces
             Velocity(pygame.Vector2(120, 0)), Sprite((250, 204, 21), 6))

running = True
while running:
    dt = clock.tick(60) / 1000
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            running = False
    movement_system(world, dt)
    bounce_system(world, dt)
    screen.fill((15, 23, 42))
    render_system(world, screen)
    pygame.display.flip()

pygame.quit()

🔮 Predict, then run

The comet has Velocity but no Bouncy. Which systems touch it, and what will you see? Run it and check. Then add Bouncy() to the comet's component list, and nothing else, and run it again.

💥 Destroying Safely and Other Gotchas

Destroy later, not now. A combat system that deletes an entity's components while another loop is walking those same dictionaries risks RuntimeError: dictionary changed size during iteration, or a later system in the same frame finding half an entity. The fix is a deferred destroy: systems only mark entities, and the world removes them once all systems have run.

def destroy(self, eid):
    self.doomed.add(eid)                # just remember it


def flush(self):                        # call once per frame, after every system
    for eid in self.doomed:
        for store in self.stores.values():
            store.pop(eid, None)
        self.alive.discard(eid)
    self.doomed.clear()

References go stale. An enemy's AI component stores its target as an entity id. When the player dies, that id no longer has a Transform, so world.get() returns None. Check for that with is not None and stop the enemy, rather than skipping it (a skipped enemy keeps its old velocity and drifts forever). Make is not None a habit instead of if target:, too: here get() returns a Transform, which is always truthy, but the day you fetch a position directly, if pos: treats Vector2(0, 0) (a player in the top-left corner) as missing, because a zero vector is falsy.

Keep state out of systems. If CombatSystem kept its own list of "enemies I know about", it would drift out of sync with the world. Systems may keep settings (a damage amount waiting to be applied, a reference to the screen), but entity data belongs in components.

Adding and removing components is the whole point. Remove AI from an enemy and it stops chasing on the very next frame; add it back and it resumes. Stun effects, power-ups and "this crate is now on fire" all become one add or remove call.

✅ Growth Mindset: When Nothing Moves, Ask the World

The most common ECS bug is silence: you spawn an entity and nothing happens, with no error. That is not a sign you've misunderstood the whole idea; it means one system's query doesn't match one entity yet. Debug it the ECS way: print system.matches(world) for each system and the component types on the entity. The missing piece is almost always one component or one system left out of the list.

⚖️ When ECS Pays Off

ECS is a tool, not a religion. It shines when you have many kinds of entities that share behaviors in different combinations: action games, simulations, anything with a designer adding "one more type of thing" every week. It is overkill for a card game or a puzzle with three kinds of object, where a few plain classes are clearer.

A word of honesty about speed. You may read that ECS is "faster because of cache locality". That claim is about engines written in C, C++ or Rust that store each component type in one tightly packed array. The World in this lesson stores Python objects in dictionaries, so it gives you the organization benefits of ECS, not that memory-layout speedup. Don't assume it is faster or slower than plain classes: if speed matters, measure it on your own game before you believe either claim.

Good fit for ECSPlain classes are fine
Many entity types that mix and match behaviorsA handful of object types that rarely change
Behaviors switched on and off at runtime (stun, burning, flying)Objects whose behavior is fixed when they are created
Designers building new types from existing partsTurn-based logic with one main object, such as a board

🏋️ Practice Exercise: Component Arena

Objective: finish a small ECS so that a player, chasing enemies and particle bursts all run on one shared set of components and systems, with a HUD that shows how many entities each system matches.

Time: about 55 minutes. Starter file: ecs_arena_starter.py (your instructor has it). It runs, but the arena stays empty because query() finds nothing yet. Its numbered TODOs match these steps.

  1. Run the starter. Every system on the HUD reports 0 matches. (≈ 2 min)
  2. Fix Health.__post_init__ so a new entity starts at full health. (≈ 3 min)
  3. Write World.query(): loop over a copy of the smallest store and yield the entity id plus its components for ids found in every store. The player appears. (≈ 15 min)
  4. Make MovementSystem move entities by vel * dt. Arrow keys now work. (≈ 5 min)
  5. Write AISystem: point each enemy's velocity at its target, or stop it if the target is gone. Press 1 to spawn enemies. (≈ 15 min)
  6. Write LifetimeSystem so particles (2) count down and are destroyed. Then press H and A and watch the HUD's match counts change. (≈ 15 min)

You are done when:

  • enemies chase the player, and the player loses health while one is touching it;
  • pressing H damages the player and enemies but never the particles (the HUD's CombatSystem count never includes them);
  • pressing A makes the newest enemy stop and the AISystem count drop by one, and pressing it again restores both;
  • killed enemies burst into particles that disappear after about half a second.
💡 Hint

For query(), build a list of stores with [self.stores.get(t, {}) for t in ctypes] first, then pick the smallest with min(stores, key=len). For AISystem, the target's position is world.get(ai.target, Transform); test it with is not None, and only call normalize() when length_squared() is greater than 0. If an entity doesn't react, print each system's matches(world).

✅ Example Solution

Your instructor's lab file also has a few lines marked lab runtime and and frame_budget() in the loop, so a checker can run it automatically. You don't need them.

"""Component Arena: Advanced Lesson 1 practice exercise (solution).

An entity-component-system in one file. Entities are plain ints, components
are dataclasses holding data only, and systems hold all the behavior.
    Arrow keys  move the player
    1           spawn an enemy that chases the player
    2           spawn a burst of particles (no Health, so combat skips them)
    H           deal 20 damage to everything that has Health
    A           add or remove the AI component on the newest enemy
The HUD counts how many entities each system matches, every frame.
Close the window to quit.
"""
import random
from dataclasses import dataclass, field

import pygame


WIDTH, HEIGHT = 900, 560
BG_COLOR = (15, 18, 28)
TEXT_COLOR = (220, 226, 236)


# --- components: data only ---------------------------------------------------------
@dataclass
class Transform:
    pos: pygame.Vector2


@dataclass
class Velocity:
    vel: pygame.Vector2 = field(default_factory=pygame.Vector2)


@dataclass
class Sprite:
    color: tuple
    radius: float


@dataclass
class Health:
    max_hp: float
    hp: float = field(init=False)

    def __post_init__(self):
        self.hp = self.max_hp               # a new entity starts at full health


@dataclass
class PlayerInput:
    speed: float = 240.0                    # px/s


@dataclass
class AI:
    target: int                             # the entity id to chase
    speed: float = 90.0                     # px/s


@dataclass
class ContactDamage:
    per_second: float                       # damage dealt while touching the player


@dataclass
class Lifetime:
    seconds: float


# --- the world: entity ids and one store per component type ---------------------------
class World:
    def __init__(self):
        self.next_id = 0
        self.stores = {}                    # component type -> {entity id: component}
        self.alive = set()
        self.doomed = set()

    def create(self, *components):
        eid = self.next_id
        self.next_id += 1
        self.alive.add(eid)
        for component in components:
            self.add(eid, component)
        return eid

    def add(self, eid, component):
        self.stores.setdefault(type(component), {})[eid] = component

    def remove(self, eid, ctype):
        self.stores.get(ctype, {}).pop(eid, None)

    def get(self, eid, ctype):
        return self.stores.get(ctype, {}).get(eid)

    def has(self, eid, ctype):
        return eid in self.stores.get(ctype, {})

    def query(self, *ctypes):
        """Yield (eid, component, ...) for every entity that has ALL of ctypes."""
        stores = [self.stores.get(t, {}) for t in ctypes]
        smallest = min(stores, key=len)
        for eid in list(smallest):          # a copy, so systems may add or remove safely
            if all(eid in store for store in stores):
                yield (eid, *(store[eid] for store in stores))

    def destroy(self, eid):
        self.doomed.add(eid)                # deferred: removed in flush(), after the systems

    def flush(self):
        for eid in self.doomed:
            for store in self.stores.values():
                store.pop(eid, None)
            self.alive.discard(eid)
        self.doomed.clear()


# --- systems: all the behavior ------------------------------------------------------
class System:
    required = ()

    def matches(self, world):
        return sum(1 for _ in world.query(*self.required))


class InputSystem(System):
    required = (PlayerInput, Velocity)

    def __init__(self, held):
        self.held = held                    # {key: 0 or 1}, updated from KEYDOWN/KEYUP

    def update(self, world, dt):
        direction = pygame.Vector2(self.held[pygame.K_RIGHT] - self.held[pygame.K_LEFT],
                                   self.held[pygame.K_DOWN] - self.held[pygame.K_UP])
        if direction.length_squared() > 0:
            direction = direction.normalize()
        for _, player, velocity in world.query(*self.required):
            velocity.vel = direction * player.speed


class AISystem(System):
    required = (AI, Transform, Velocity)

    def update(self, world, dt):
        for _, ai, transform, velocity in world.query(*self.required):
            target = world.get(ai.target, Transform)
            if target is None:              # the target was destroyed
                velocity.vel = pygame.Vector2()
                continue
            to_target = target.pos - transform.pos
            if to_target.length_squared() > 0:
                velocity.vel = to_target.normalize() * ai.speed


class MovementSystem(System):
    required = (Transform, Velocity)

    def update(self, world, dt):
        for _, transform, velocity in world.query(*self.required):
            transform.pos += velocity.vel * dt
            transform.pos.x = max(0, min(WIDTH, transform.pos.x))
            transform.pos.y = max(0, min(HEIGHT, transform.pos.y))


class ContactDamageSystem(System):
    required = (ContactDamage, Transform, Sprite)

    def update(self, world, dt):
        players = list(world.query(PlayerInput, Transform, Sprite, Health))
        for _, damage, transform, sprite in world.query(*self.required):
            for _, _, p_transform, p_sprite, health in players:
                if transform.pos.distance_to(p_transform.pos) < sprite.radius + p_sprite.radius:
                    health.hp -= damage.per_second * dt


class CombatSystem(System):
    required = (Health, Transform)

    def __init__(self, rng):
        self.rng = rng
        self.pending = 0                    # damage queued by the H key

    def update(self, world, dt):
        for eid, health, transform in world.query(*self.required):
            health.hp -= self.pending
            if health.hp <= 0:
                spawn_burst(world, self.rng, transform.pos, 10)
                world.destroy(eid)
        self.pending = 0


class LifetimeSystem(System):
    required = (Lifetime,)

    def update(self, world, dt):
        for eid, life in world.query(*self.required):
            life.seconds -= dt
            if life.seconds <= 0:
                world.destroy(eid)


class RenderSystem(System):
    required = (Transform, Sprite)

    def __init__(self, screen):
        self.screen = screen

    def update(self, world, dt):
        for eid, transform, sprite in world.query(*self.required):
            pygame.draw.circle(self.screen, sprite.color, transform.pos, sprite.radius)
            health = world.get(eid, Health)
            if health is not None:          # only entities with Health get a bar
                width = 2 * sprite.radius * max(0.0, health.hp / health.max_hp)
                pygame.draw.rect(self.screen, (239, 68, 68),
                                 (transform.pos.x - sprite.radius, transform.pos.y - sprite.radius - 7,
                                  width, 3))


# --- factories: an entity type is just a list of components ---------------------------
def spawn_player(world):
    return world.create(Transform(pygame.Vector2(WIDTH / 2, HEIGHT / 2)), Velocity(),
                        Sprite((74, 222, 128), 14), Health(100), PlayerInput())


def spawn_enemy(world, rng, target):
    pos = pygame.Vector2(rng.choice([30, WIDTH - 30]), rng.uniform(60, HEIGHT - 30))
    return world.create(Transform(pos), Velocity(), Sprite((248, 113, 113), 11), Health(40),
                        AI(target), ContactDamage(15))


def spawn_burst(world, rng, pos, count):
    for _ in range(count):
        vel = pygame.Vector2(rng.uniform(60, 180), 0).rotate(rng.uniform(0, 360))
        world.create(Transform(pygame.Vector2(pos)), Velocity(vel), Sprite((250, 204, 21), 3),
                     Lifetime(rng.uniform(0.4, 0.9)))


def main():
    pygame.init()
    screen = pygame.display.set_mode((WIDTH, HEIGHT))
    pygame.display.set_caption("Component Arena")
    clock = pygame.time.Clock()
    font = pygame.font.Font(None, 22)
    rng = random.Random(11)

    held = {pygame.K_LEFT: 0, pygame.K_RIGHT: 0, pygame.K_UP: 0, pygame.K_DOWN: 0}
    world = World()
    combat = CombatSystem(rng)
    systems = [InputSystem(held), AISystem(), MovementSystem(), ContactDamageSystem(),
               combat, LifetimeSystem(), RenderSystem(screen)]      # order matters
    player = spawn_player(world)
    enemies = []

    running = True
    while running:
        dt = clock.tick(60) / 1000
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                running = False
            elif event.type == pygame.KEYUP and event.key in held:
                held[event.key] = 0
            elif event.type == pygame.KEYDOWN:
                if event.key in held:
                    held[event.key] = 1
                elif event.key == pygame.K_1:
                    enemies.append(spawn_enemy(world, rng, player))
                elif event.key == pygame.K_2:
                    spawn_burst(world, rng, pygame.Vector2(WIDTH / 2, 80), 24)
                elif event.key == pygame.K_h:
                    combat.pending += 20
                elif event.key == pygame.K_a:
                    newest = next((e for e in reversed(enemies) if e in world.alive), None)
                    if newest is not None and world.has(newest, AI):
                        world.remove(newest, AI)
                    elif newest is not None:
                        world.add(newest, AI(player))

        screen.fill(BG_COLOR)
        for system in systems:
            system.update(world, dt)
        world.flush()

        y = 8
        for system in systems:
            names = ", ".join(t.__name__ for t in system.required)
            line = f"{type(system).__name__}: {system.matches(world)} match ({names})"
            screen.blit(font.render(line, True, TEXT_COLOR), (10, y))
            y += 18
        screen.blit(font.render(f"entities alive: {len(world.alive)}", True, TEXT_COLOR), (10, y + 4))
        pygame.display.flip()

    pygame.quit()
    player_health = world.get(player, Health)
    print(f"Entities alive: {len(world.alive)}")
    print("Player hp:", "gone" if player_health is None else round(player_health.hp))


if __name__ == "__main__":
    main()

📓 Learning Journal

Take five minutes to write in your learning journal (a notebook or a plain text file works). Jot down:

  • Key concepts you learned today
  • Techniques that clicked (and the ones that haven't, yet)
  • Questions or confusion to bring to the next session
  • Ideas to try in your own game
  • Progress and feelings: how did this lesson go for you?

✍️ This lesson's prompts:

  1. Pick a game you know well and list five kinds of things in it. Which components would each one have? Which components are shared by the most entity types?
  2. You added and removed the AI component while the game ran. Describe two other game mechanics (buffs, status effects, pickups) you could build the same way.
  3. Where would a system-order bug show up in your own game, and how would you notice it?

📝 Summary

Inheritance describes what an object is, and it strains as soon as features combine freely. An entity-component-system flips that around: an entity is an id, components are plain data attached to it, and systems hold every bit of behavior by querying for the components they need. You built a World that stores components per type, wrote systems that run in a deliberate order with dt in seconds, destroyed entities safely with a deferred flush, and changed an enemy's behavior at runtime by adding and removing one component.

🎓 Key Takeaways

  • Entities are ids, components are data, systems are behavior; keep them that way.
  • A query returns every entity that has all the requested components, so new entity types join existing systems automatically.
  • System order is part of your design: input and AI set velocities, movement applies them, render draws last.
  • Destroy entities with a deferred flush() after the systems run, and check stale references with is not None.
  • Adding or removing a component at runtime is how ECS switches behavior on and off.
  • A Python dict-based ECS buys organization, not guaranteed speed; measure before you claim either.

🔭 Looking Ahead

Your systems still reach into each other's data when something happens, such as a death that should play a sound and update a score. In the next lesson, Event Systems, you'll let systems announce what happened on an event bus so that nobody needs to know who is listening.

❓ Common Questions

Can a component have methods at all?

Small helpers that only touch the component's own data are fine, such as a fraction() on Health that returns hp / max_hp. What stays out is per-frame behavior that involves other entities or dt. If you are tempted to write update(self, dt) on a component, that code belongs in a system.

Why is the entity just an int instead of an object?

An int can't accidentally carry data or behavior, it is cheap to store in other components (like AI.target), and it survives saving and loading. Some ECS libraries wrap the id in a small class for type checking; the idea is the same.

Should I reuse the ids of destroyed entities, or pool components?

Not in this design. Reusing ids makes stale references dangerous: an enemy could start chasing whatever new entity got its old target's number. Pooling components only helps if measuring shows that creating objects is a real cost; you already know how to build a pool from Spatial Hashing & Object Pools if it ever is.

How do two systems share information, like "this enemy just died"?

Through components (a system can add a Dead tag that another system reacts to) or through messages: one system announces "this happened" and any system that cares reacts, without the two knowing about each other.

Are there ready-made ECS libraries for Python?

Yes: esper is a small, popular one you can install with pip. Writing your own first, as you did here, is the best way to understand what such a library does and when you need one.

My game's scenes from Game States & Scenes: where does the World go?

Give each gameplay scene its own World and system list. Entering the scene creates the world; leaving it drops it. Menus usually don't need an ECS at all.

🎯 Quick Quiz

Question 1: Your game needs tanks and scouts that can each be flying, cloaked, both or neither. What does an ECS add for that?

Question 2: A particle has Transform, Velocity and Sprite. Which query does it NOT match?

Question 3: Why does CombatSystem call world.destroy(eid) instead of deleting the components right away?

Question 4: The systems run in this order: Render, Input, Movement. What goes wrong?

Question 5: An enemy's AI.target is the player's id, and the player was destroyed. What should AISystem do?

🌟 Going Further

  • A new entity type, zero new classes: add a "healing totem" to the arena built only from existing components plus one new Heal component and a HealSystem that restores health to nearby entities.
  • Status effects: add a Stunned(seconds) component that AISystem respects, and a system that removes it when the time runs out.
  • Debug overlay: click an entity to print its component list, using world.stores to find everything attached to that id.
  • Read the source: the esper library implements the same ideas in a few hundred lines. Compare its get_components() with your query().
  • Coming up in Game Dev III: Advanced: Steering & Flocking and Behavior Trees give your AI component far smarter systems to plug into.