Skip to content
/ pyriak Public

An implementation of Entity Component System architecture for Python.

License

Notifications You must be signed in to change notification settings

aatle/pyriak

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

pyriak

PyPI version License Checked with mypy Linting: Ruff

Pyriak is a lightweight Python implementation of Entity Component System (ECS) architecture.

Installation

pip install pyriak

Introduction to ECS

ECS (entity component system) architecture is an alternative paradigm to OOP (object-oriented programming), emphasizing composition and data-oriented design over traditional OOP concepts. This can help with structuring complex programs, especially in game development.

Objects

These are the three standard parts of most ECS designs:
Entity - A general-purpose object of the program, represented as a collection of components
Component - A data object representing one characteristic of an entity, e.g. position
System - Manipulates specific components of entities to do the functionality of one behavior, e.g. rendering

pyriak includes two more things as part of its design:
State - A data object for an aspect of the whole program; a global component
Event - A signal object for communicating among systems and controlling the program's flow

Additionally, there are helper container classes to manage these objects.
Manager - A collection of either entities, systems, or states that exposes operations to manipulate its elements
Event queue - A global queue of events shared among all systems
Space - Represents a standalone program, encapsulating its data and behavior. Holds the managers and event queue

pyriak implementations

The following are how the above terms are implemented in pyriak.

  • entity: Entity class, containing a unique entity ID and a set of components referenced by class
  • system: usually a module object with event handlers and functions defined on it, static and holds no data
  • component, event, state: user-defined classes containing mostly data and little behavior
    • akin to a struct in other languages. The dataclasses module and other similar utilities are useful for defining these
  • managers:
    • EntityManager: a set of entities referenced by their ID, with querying operations available
    • SystemManager: a set of hashable system objects, can process events by invoking the relevant event handlers
    • StateManager: a set of states referenced by their class, akin to an entity
  • space: Space class, with attributes .entities, .systems, and .states for the managers, and .event_queue
  • event queue: a collections.deque by default, attached to the space

Usage

In your main module, create a Space instance.
With no arguments to Space(), the managers and the event queue are created automatically.

# main.py
from pyriak import Space

space = Space()

Now, create new modules for some systems. Then import and add them to the space.
It's important to not forget to add systems, as otherwise their event handlers will never be invoked.

# main.py
from pyriak import Space

from . import game_loop, physics, render

space = Space()
space.systems.add(game_loop, physics, render)

In another module, declare some events.

# events.py
class UpdateGame:
    def __init__(self, dt: float) -> None:
        self.dt = dt

class RenderGame:
    pass

class StartGame:
    pass

...

In each system, add event handlers using the @bind(event_type, priority) decorator.
The handler callback takes arguments space and event.

# game_loop.py
from pyriak import Space, bind

from . import events

@bind(events.StartGame, 0)
def run_game_loop(space: Space, event: events.StartGame) -> None:
    while True:
        pass

Events can be either processed or posted, using the space.
Processing an event invokes all event handlers in the space with a matching event type, sorted by priority.

Posting an event puts it in the space's event queue to later be processed.
From anywhere in the program, space.pump() takes out events from the queue and processes them, in a loop.
space.pump() runs until the event queue is empty. Alternatively, space.pump(n) runs for n iterations.
Note that more events may be added to the event queue while space.pump() is running.

# game_loop.py
...

@bind(events.StartGame, 0)
def run_game_loop(space: Space, event: events.StartGame) -> None:
    while True:
        space.post(events.UpdateGame())  # Add event to event queue
        space.pump()  # Process from event queue until all queued events have been processed
        space.post(events.RenderGame())
        space.pump()

States are useful for holding data for systems since systems shouldn't store any data.
For example, a Time state could store the time.
Then, a game_time system can update the Time state. But first, the state must be added to the space, using space.states.add(*states).
The best place to do this is in the optional, special _added_(space) callback on the system, invoked when the system is added to the manager.

# game_time.py
from pyriak import Space, bind

from . import events, states

def _added_(space: Space) -> None:
    time = states.Time()
    # Add the Time state to the space
    space.states.add(time)

To access a state from the StateManager (or a component from an entity), use its type like a mapping key on the manager.

# game_time.py
...

@bind(events.UpdateGame, 100)
def update_time(space: Space, event: events.UpdateGame) -> None:
    # Get the Time state
    time = space.states[states.Time]
    # Update the Time state
    time.elapsed += event.dt
    time.frame_count += 1

def _removed_(space: Space) -> None:
    # Remove the Time state when the system is removed from the space
    del space.states[states.Time]

Now it's time for entities.
Define component classes for aspects of the objects that will be in your program.
Some classes don't even need to hold data: it's presence on the entity serves as a marker, or 'tag'.

# components.py
@dataclass
class Position:
    x: float
    y: float

class Player:
    pass

...

Entities can be created with the Entity(components) constructor. They must be added to the space with space.entities.add(*entities).
However, it is preferable to use space.entities.create(*components), which adds it to the space automatically.

# world.py
from pyriak import Entity, Space

from .components import *

def _added_(space: Space) -> None:
    enemy = Entity([Position(50.0, 0.0), Health(40)])
    space.entities.add(enemy)
    player = space.entities.create(Position(0.0, 0.0), Health(100), Player())

Systems operate on their specific components in bulk.
To access batches of components, use the space.query(*component_types) method, which takes any number of component types as arguments.
This will select all entities in the space that contain every component type passed in, and return an query result object.
This object has methods such as .zip(), which gives an iterator of the tuple of components for each entity. E.g.,

list(space.query(Spam, Eggs, Foo)) --> [  # for every entity with all three components
    (Spam(5), Eggs("a"), Foo()),  # components from first entity
    (Spam(1), Eggs("b"), Foo()),  # components from second entity
    ...
]

Used in an event handler, it would look something like this:

# physics.py
...

@bind(events.UpdateGame, 500)
def update_physics(space: Space, event: events.UpdateGame) -> None:
    for position, velocity in space.query(
        components.Position, components.Velocity
    ).zip():
        position.x += velocity.x * event.dt
        position.y += velocity.y * event.dt

Those are all of the core features of pyriak.

When to use pyriak?

It may seem like a tedious and convoluted way of doing things, with all of the declarations and split code.
However, for a larger, more complicated project, it is much more flexible and scalable.

In an OOP game, a common problem is that a base class (e.g. GameObject) may become bloated with optional features, as subclasses share some behavior but not all. Inheritance is often fragile or inflexible. High coupling and low cohesion can become difficult to avoid.

In a game made with pyriak, coupling is very low because systems only interact with exactly what data they require, and cohesion is high because systems, components, states, and events are small and focused.
The separation of data from logic also comes with its own benefits.

pyriak focuses on development speed, ease of use, and structure rather than performance, aligning with the principles of python. Unlike many ECS implementations, it does not offer performance gains because data locality is nonexistent in pure python.

In short, this package is mainly intended for complex, interconnected programs, especially games. For small programs with simple mechanics or not many moving parts, ECS is probably overkill and can be slower to write.

Installing from source

Pyriak has no package dependencies, and its source is entirely python. The source can be installed and used without any building or set-up.

pip install -U git+https://github.com/aatle/pyriak.git

Help

Currently, all available resources are in the pyriak GitHub repo. Create an issue if there are any concerns or problems.
There is no external documentation; see docstrings for information. In the future, an example program may be available.

About

An implementation of Entity Component System architecture for Python.

Topics

Resources

License

Stars

Watchers

Forks

Languages