Tutorial#

This tutorial will walk you through building a simple game with PyECS.

Creating Components#

Components in PyECS are simple Python classes that store data. They don’t need to inherit from any base class. We recommend using dataclasses for cleaner syntax:

from dataclasses import dataclass

@dataclass
class Position:
    x: float
    y: float

@dataclass
class Velocity:
    x: float
    y: float

@dataclass
class Health:
    current: int
    max_health: int

@dataclass
class PlayerTag:
    pass

Tip: You can make components immutable by using frozen=True:

@dataclass(frozen=True)
class Position:
    x: float
    y: float

# This creates immutable components that cannot be modified after creation
# pos = Position(10, 20)
# pos.x = 30  # ❌ Raises FrozenInstanceError

See Also: Architecture & Internals - World.add_component

Creating Systems#

Systems contain the game logic. The update method receives the world object:

from pyecs.processing.System import System
from pyecs.querying.Query import Query

class MovementSystem(System):
    def update(self, world, dt: float) -> None:
        query = Query().with_components(Position, Velocity)
        entities = query.execute(world)

        for entity in entities:
            pos = world.get_component(entity, Position)
            vel = world.get_component(entity, Velocity)
            pos.x += vel.x * dt
            pos.y += vel.y * dt

class HealthSystem(System):
    def update(self, world, dt: float) -> None:
        query = Query().with_components(Health)
        entities = query.execute(world)

        for entity in entities:
            health = world.get_component(entity, Health)
            if health.current <= 0:
                world.destroy_entity(entity)

See Also: Architecture & Internals - Query.execute, Query.with_components, World.get_component, World.destroy_entity

Running the Game Loop#

Here’s a complete example that ties everything together:

from pyecs import ECSWorld
import time

world = ECSWorld()

movement_system = MovementSystem()
health_system = HealthSystem()

world.add_system(movement_system)
world.add_system(health_system)

player = world.create_entity()
world.add_component(player, Position(0, 0))
world.add_component(player, Velocity(10, 5))
world.add_component(player, Health(100, 100))
world.add_component(player, PlayerTag())

for i in range(5):
    enemy = world.create_entity()
    world.add_component(enemy, Position(i * 10, 20))
    world.add_component(enemy, Velocity(-5, 0))
    world.add_component(enemy, Health(50, 50))

last_time = time.time()

for _ in range(100):
    current_time = time.time()
    dt = current_time - last_time
    last_time = current_time

    world.update(dt=dt)

    player_pos = world.get_component(player, Position)
    print(f"Player at: ({player_pos.x:.2f}, {player_pos.y:.2f})")

    time.sleep(0.016)

See Also: Architecture & Internals - World.add_system, World.create_entity, World.add_component, World.update, SystemManager.update_all

Understanding Archetypes#

PyECS uses an archetype-based storage system. An archetype is a unique combination of component types that entities can have:

  • When you add components to an entity, it moves to the archetype matching its component set

  • Entities with the same set of components are stored together for efficient iteration

  • The Query system uses archetypes internally for efficient entity filtering

entity1 = world.create_entity()
world.add_component(entity1, Position(0, 0))

world.add_component(entity1, Velocity(1, 1))

entity2 = world.create_entity()
world.add_component(entity2, Position(5, 5))
world.add_component(entity2, Velocity(2, 2))

query = Query().with_components(Position, Velocity)
entities = query.execute(world)
print(f"Found {len(entities)} entities with Position and Velocity")

See Also: Architecture & Internals - Query.execute, Archetype Operations

Working with Immutable Components#

While mutable components work well for most use cases, immutable components can provide additional safety and predictability:

from dataclasses import dataclass, replace

@dataclass(frozen=True)
class Position:
    x: float
    y: float

    def moved_by(self, dx: float, dy: float) -> 'Position':
        """Returns a new Position moved by the given deltas."""
        return Position(self.x + dx, self.y + dy)

# Using immutable components in a system
class ImmutableMovementSystem:
    def update(self, world, dt: float) -> None:
        query = Query().with_components(Position, Velocity)
        entities = query.execute(world)

        for entity in entities:
            pos = world.get_component(entity, Position)
            vel = world.get_component(entity, Velocity)

            # Create a new position instead of modifying
            new_pos = pos.moved_by(vel.x * dt, vel.y * dt)

            # Replace the component
            world.remove_component(entity, Position)
            world.add_component(entity, new_pos)

Benefits of Immutable Components:

  • Thread Safety: Immutable objects can be safely shared between threads

  • Predictability: Components can’t be accidentally modified elsewhere

  • Debugging: Easier to track when and how values change

  • Hashability: Can be used as dictionary keys or in sets

Using dataclass replace():

# The replace() function creates a new instance with some fields updated
old_pos = Position(10, 20)
new_pos = replace(old_pos, x=15)  # Position(x=15, y=20)

Choose between mutable and immutable components based on your needs. Mutable components are simpler for frequent updates, while immutable components provide better safety guarantees.