The teeniest, tiniest ECS system
Project description
tinyecs Tutorial
This tutorial is also available by running
pydoc tinyecs.tutorial
The demo developed within this document can be run upfront with
python -m tinyecs.tutorial
About
ECS stands for Entity Component System, and it is a programming paradigm that differs from the well known OO.
During my research I stumbled over
and after reading part 2 and 3, I decided to implement an ECS myself, well
aware that esper
is a solid and long existing implementation, but I wanted
to see how to implement it myself.
I'm not trying to sell you an ECS by explaining the problems of multiple inheritance in game programming. There are articles out there that do this in much detail. If you're here, you're already interested in the concept, so this will be a tutorial, implementing a bunch of bouncing sprites, which should show you all the API you need to know.
Setting up the environment
I assume, you have a current version of python up & running on your computer
already, and you have a basic understanding of installing packages with pip
.
I suggest to create a new project in a virtual environment, so your main install stays clean from random dependency packages. There are tutorials on the web and on youtube how to do that on your platform, if you're unfamiliar with the concept.
tinyecs
itself is just a library to manage abstract entities. It doesn't
rely on pygame-ce and could equally be used with other libs like pyglet
or
even console applications like roguelikes. It comes with a few preconfigured
components though, which were developed with pygame-ce, so this tutorial will
require to install that.
pip install pygame-ce
If you're runnning pygame already, I recommend to switch through new pygame
community edition, since most core developers migrated to this project. This
tutorial will work with both pygame-ce
and pygame
, since it doesn't make
use of any updated features.
Note: if anybody can tell me, how to depend on either pygame-ce or pygame,
but be happy with whatever is already there in pyproject.toml, please tell
me.
Next, of course you need tinyecs. You're probably reading this tutorial after cloning the project from github, or reading it directly on the site.
For completeness, the github project page is
https://github.com/dickerdackel/tinyecs
The latest development version (unstable) can be installed from there with
pip install git+https://github.com/dickerdackel/tinyecs
Or you can install the stable version directly from pypi
pip install tinyecs
The code
No, not yet. First...
...Some concepts
To ease the code, I usually import tinyecs as ecs
. If you like to import it
in its normal namespace, please adapt all examples here accordingly.
Entities
An entity is just an identifyer for a "thing" in your world. The examples
here will always call this eid
. It's role is about the same as an object in
game written in an object oriented style, e.g. a bonus coin.
In contrast to OO development, the entity is really just an ID. Calling
ecs.create_entity
will generate a random uuid4, but since entities are
actually keys into a dict internally, any hashable type will work too. It
makes e.g. sense for the player entity to be quickly accessible by using
'player' as ID.
Entities are created and removed with
eid = ecs.create_entity() # gives you a uuid4 ID
player = ecs.create_entity('player') # gives you 'player' as ID
ecs.remove_entity(eid)
If an entity is removed, all references to its components are also dropped, but... See "IMPORTANT" below at "Components"
Components
To give life to this entity, "components" can be attached to it. A component is in its most basic form any data record. Like a sprite, a position vector, a record containing statistics like hitpoints or armor.
Components are addressed by a tag which is called cid
(short for component
ID) during this tutorial. Note, that there is also a component ID for the
actual object, but I have yet to find a use for that, and since tag
is a
very general term, cid
stuck with me and I always use it for this purpose.
Components are added and removed to and from an entity like this:
ecs.add_component(eid, 'tag', object)
ecs.remove_component(eid, 'tag')
IMPORTANT:
An entity that e.g. has a sprite in a global sprite group cannot be released by the ECS, since it doesn't know anything about the interface of that sprite. The ECS and the sprite group are two different systems.
To deal with that issue, you can add a method shutdown_
to your component,
which will be called when the component is removed from the ECS.
# Remove a instance of a pygame.sprite.Sprite subclass
# from all sprite groups
def shutdown_(self):
self.kill()
Systems
Different from writing OO, the component itself doesn't have any code (in the
purest form of ECS), which is where systems
come into play.
A system is a function that works on a fixed set of components from a single
entity. system functions are not called directly, but with the help of
tinyecs' run_system
function.
Imagine a saucer entity, with the components sprite
, position
, and
momentum
. It should appear on the screen at position (50, 50) and should fly
diagonally across the screen until it disappears off-screen.
eid = ecs.create_entity()
ecs.add_component(eid, 'sprite', Sprite('saucer.jpg'))
ecs.add_component(eid, 'position', pygame.Vector2(50, 50))
ecs.add_component(eid, 'momentum', pygame.Vector2(1024, 768).normalized() * 100)
To apply the momentum to the position, a function momentum_system
is needed.
To apply the position to the rect of the sprite, a function sprite_system
will be used. To run e.g. the momentum_system
, put the following into the
game loop:
ecs.run_system(dt, momentum_system, 'momentum', 'position')
When the game loop passes this call, run_system
will find all entities that
have both components momentum
and position
and pass these together with
deltatime dt
and the entity ID into the momentum_system
function.
Writing the momentum_system
is easy:
def momentum_system(dt, eid, momentum, position):
position += momentum * dt
That's it. Now every time run system is called for the momentum_system
, all
objects with these two components will have their position updated.
Note:
If you need to put additional arguments from the game loop into the system,
use **kwargs
. run_system
will pass all cids
as *args
into your custom
function, and all additional keyword args from the run_system
call will
passed in after the components. The following will be explained in the actual
script later.
ecs.run_system(dt, deadzone_system, 'position', deadzone=WORLD)
So the system is basically the update(dt)
function in an OO driven game.
At this point you might get the feeling, that you will have a very large list
of systems in a big block in your game loop, and that's exactly right. You
either hate that, which is fine, so the option is either to go back to an OO
development model, or chose a more OO driven ECS implementation like e.g. the
long established esper
.
There are some alternatives still.
- you can register systems with the required cids at the start of your program and call a single function in your game loop:
ecs.run_all_systems(dt)
- To give you a more fine grained control over what systems run together, the
concept of
domains
was introduced. A system domain is simply a group of systems that are bundled under a common name. We won't make use of that in this tutorial, please consult the embedded docs for more detail.
pydoc tinyecs.add_system_to_domain
pydoc tinyecs.run_domain
pydoc tinyecs.remove_system_from_domain
- if you prefer to have normal classes as components and have the
functionality in there, just create a short system that calls the update
method of your component. You still need that block of
run_system
or above shortcuts in your game loop though.
def call_update_system(dt, eid, component):
component.update(dt)
Finally code!
We now write a simple demo of colorful flying boxes.
If the user holds the mouse, a bunch of rectangular sprites of random size are released at mouse position, drifting in random directions until the mouse is released again.
Boxes that drift off screen will be automatically removed from the system.
tinyecs comes with a sprite that has a shutdown method, and also systems that handle motion and screen boundaries, but we'll write these here ourselves, so you get a feel for how things work.
A basic pygame game loop
This is a basic pygame boilerplate game loop. It could be shortened, but this is a good start for stateless test scripts and experiments. It already has a sprite group added.
import pygame
TITLE = 'pygame minimal template'
SCREEN = pygame.Rect(0, 0, 1024, 768)
FPS = 60
DT_MAX = 3 / FPS
pygame.init()
pygame.display.set_caption(TITLE)
screen = pygame.display.set_mode(SCREEN.size)
clock = pygame.time.Clock()
group = pygame.sprite.Group()
running = True
while running:
dt = min(clock.tick(FPS) / 1000.0, DT_MAX)
for e in pygame.event.get():
match e.type:
case pygame.QUIT:
running = False
case pygame.KEYDOWN if e.key == pygame.K_ESCAPE:
running = False
screen.fill('black')
...
group.update(dt)
...
group.draw(screen)
pygame.display.flip()
runtime = pygame.time.get_ticks()/1000
fps = clock.get_fps()
pygame.display.set_caption(f'{TITLE} - {runtime=:.2f} {fps=:.2f}')
pygame.quit()
The sprite class:
from random import random, choice
from pygame.colordict import THECOLORS
class DemoSprite(pygame.sprite.Sprite):
def __init__(self, *groups):
# Make sure, the sprite is properly initialized for sprite groups
super().__init__(*groups)
# Size is random between 8 and 32 pixels in both dimensions
w, h = random() * 24 + 8, random() * 24 + 8
# Just set up a basic pygame sprite instance
self.image = pygame.Surface((w, h))
self.image.fill(choice(list(THECOLORS)))
# Note that we don't set the position!
self.rect = self.image.get_rect()
def shutdown_(self):
print(f'{self} removed from sprite groups')
self.kill()
Creating an entity
We define a function that creates the full entity. If you're coming from OO,
this mostly resembles the __init__
of an entity class.
from pygame import Vector2
def create_box_entity(position):
# Give sprites a random speed between 0 and +/-50px/s
dx, dy = random() * 100 - 50, random() * 100 - 50
e = ecs.create_entity()
ecs.add_component(e, 'position', Vector2(position))
ecs.add_component(e, 'momentum', Vector2(dx, dy))
ecs.add_component(e, 'sprite', DemoSprite)
This function will be called if the mouse button is pressed to generate a spray of new sprites at the given mouse position.
The systems
We already wrote the momentum_system
above, but here again for completeness:
# Make the world rect a bit larger than the screen, so sprites don't suddenly
# disappear at the screen edge. Note: rect.scale_by is a pygame-ce
# addition.
WORLD = SCREEN.scale_by(1.25)
def momentum_system(dt, eid, momentum, position):
"""Add a delta time scaled momentum to the position."""
position += momentum * dt
def sprite_position_system(dt, eid, sprite, position):
"""Apply the position to the rect of the sprite for the sprite group"""
sprite.rect.center = position
def deadzone_system(dt, eid, position, *, world):
"""Kill sprites that move off screen"""
if world.collidepoint(position):
return
ecs.remove_entity(eid)
Releasing entities on click
The lines marked with >
are additions to the game loop template all above.
> emitting = False
running = True
while running:
dt = min(clock.tick(FPS) / 1000.0, DT_MAX)
for e in pygame.event.get():
match e.type:
case pygame.QUIT:
running = False
> case pygame.MOUSEBUTTONDOWN if e.button == 1:
> emitting = True
> case pygame.MOUSEBUTTONUP if e.button == 1:
> emitting = False
case pygame.KEYDOWN if e.key == pygame.K_ESCAPE:
running = False
> if emitting:
> for _ in range(10):
> create_box_entity(pygame.mouse.get_pos())
screen.fill('black')
Running the systems
> ecs.run_system(dt, momentum_system, 'momentum', 'position')
> ecs.run_system(dt, deadzone_system, 'position', world=WORLD)
> ecs.run_system(dt, sprite_position_system, 'sprite', 'position')
screen.fill('black')
> # group.update(dt) # Not needed
group.draw(screen)
pygame.display.flip()
That's it.
Here's the full script with the functions, classes, imports above also merged
in. Additionally, a sprite counter was added to the title bar and the print
from the shutdown_
of the sprite class was commented out.
import pygame
import tinyecs as ecs
from random import random, choice
from pygame import Vector2
from pygame.colordict import THECOLORS
TITLE = 'pygame minimal template'
SCREEN = pygame.Rect(0, 0, 1024, 768)
FPS = 60
DT_MAX = 3 / FPS
# Make the world rect a bit larger than the screen, so sprites don't suddenly
# disappear at the screen edge. Note: rect.scale_by is a pygame-ce
# addition.
WORLD = SCREEN.scale_by(1.25)
class DemoSprite(pygame.sprite.Sprite):
def __init__(self, *groups):
# Make sure, the sprite is properly initialized for sprite groups
super().__init__(*groups)
# Size is random between 8 and 32 pixels in both dimensions
w, h = random() * 24 + 8, random() * 24 + 8
# Just set up a basic pygame sprite instance
self.image = pygame.Surface((w, h))
self.image.fill(choice(list(THECOLORS)))
# Note that we don't set the position!
self.rect = self.image.get_rect()
def shutdown_(self):
# print(f'{self} removed from sprite groups')
self.kill()
def create_box_entity(position, sprite_group):
# Give sprites a random speed between 0 and +/-50px/s
dx, dy = random() * 100 - 50, random() * 100 - 50
e = ecs.create_entity()
ecs.add_component(e, 'position', Vector2(position))
ecs.add_component(e, 'momentum', Vector2(dx, dy))
ecs.add_component(e, 'sprite', DemoSprite(sprite_group))
def momentum_system(dt, eid, momentum, position):
"""Add a delta time scaled momentum to the position."""
position += momentum * dt
def sprite_position_system(dt, eid, sprite, position):
"""Apply the position to the rect of the sprite for the sprite group"""
sprite.rect.center = position
def deadzone_system(dt, eid, position, *, world):
"""Kill sprites that move off screen"""
if world.collidepoint(position):
return
ecs.remove_entity(eid)
pygame.init()
pygame.display.set_caption(TITLE)
screen = pygame.display.set_mode(SCREEN.size)
clock = pygame.time.Clock()
group = pygame.sprite.Group()
emitting = False
running = True
while running:
dt = min(clock.tick(FPS) / 1000.0, DT_MAX)
for e in pygame.event.get():
match e.type:
case pygame.QUIT:
running = False
case pygame.MOUSEBUTTONDOWN if e.button == 1:
emitting = True
case pygame.MOUSEBUTTONUP if e.button == 1:
emitting = False
case pygame.KEYDOWN if e.key == pygame.K_ESCAPE:
running = False
if emitting:
for _ in range(10):
create_box_entity(pygame.mouse.get_pos(), group)
ecs.run_system(dt, momentum_system, 'momentum', 'position')
ecs.run_system(dt, deadzone_system, 'position', world=WORLD)
ecs.run_system(dt, sprite_position_system, 'sprite', 'position')
screen.fill('black')
group.draw(screen)
pygame.display.flip()
runtime = pygame.time.get_ticks() / 1000
fps = clock.get_fps()
sprites = len(group)
pygame.display.set_caption(f'{TITLE} - {runtime=:.2f} {fps=:.2f} {sprites=}')
pygame.quit()
Available components and systems
Now that we've written 3 basic systems ourself, let's have a look at the pygame(-ce) components that are currently included with tinyecs.
Please note that while tinyecs should be API stable by now, the bundled
components in tinyecs.compsys
are not. I'm still trying to get a feel for
some of the features I want and how I want to access them.
Components and systems are only listed here. Please check the embedded docs for details and look at the code to decide if you want to make use of these systems or if you'd rather roll your own.
I usually import these together with tinyecs like this:
import tinyecs as ecs
import tinyecs.compsys as ecsc
class ESprite(pygame.sprite.Sprite)
A sprite class that already has a shutdown_
method
class EVSprite(pygame.sprite.Sprite)
A sprite class where the image attribute is a property. You can pass an
image_factory
function to the init that will generate images when the
group.draw
functions runs over it.
class RSAImage
A Rotated, Scaled and Alpha transparent image.
UNSTABLE! DON'T RELY ON THIS YET!
def dead_system(dt, eid, dead)
Sometimes it is useful to not remove a sprite immediately from the system. Instead, you can add a component tagged e.g. 'dead', and later reap all entities marked with that tag.
def deadzone_system(dt, eid, world, position, *, container)
Basically the function created in this tutorial, with one addition.
Not every sprite is run through this system, only sprites that have a world
component. That way, e.g. enemy sprites waiting off screen to be activated
will not get removed.
world
can be anything, I usually make it a boolean, but the existence of
that component alone is sufficient.
ecs.add_component(e, 'world', True)
def extension_system(dt, eid, extension)
Will be removed. tinyecs installs pgcooldown as a requirement and that comes
with the CronD
class which does the same and more.
def force_system(dt, eid, force, momentum)
Applies (adds) a constant force to a momentum.
def lifetime_system(dt, eid, lifetime)
Kills the entity once lifetime has run out. Expects lifetime
to be an
instance of pgcooldown.Cooldown
def momentum_system(dt, eid, momentum, position)
The same as we wrote in the tutorial above.
def mouse_system(dt, eid, mouse, position)
Update a position component with the position of the mouse cursor.
def scale_system(dt, eid, scale, momentum) / def friction_system(dt, eid, scale, momentum)
Apply friction to a momentum.
In contrast to a force_system, which adds a directional vector to the momentum, the friction scales the momentum by a factor. It can also be greater 1.
friction_system
is an alias to scale_system
.
def sprite_system(dt, eid, sprite, position)
The same as we wrote above, apply the position to sprite.rect.center
.
def wsad_system(dt, eid, wsad, position)
An example system to control a playear with the wsad
keys. This was just a
proof of concept, but I'm currently using it, so perhaps it will stay.
Project details
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
File details
Details for the file tinyecs-0.2.9.tar.gz
.
File metadata
- Download URL: tinyecs-0.2.9.tar.gz
- Upload date:
- Size: 33.1 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/4.0.2 CPython/3.12.0
File hashes
Algorithm | Hash digest | |
---|---|---|
SHA256 | f2b8c98f03c7b219d15793c395b10e19fcbc8326c419a01fbaa485d9f82dae68 |
|
MD5 | 770b2e46ee90d90cd1add20ed4ce6aee |
|
BLAKE2b-256 | 5eaccbfc79d8f9baefb88d311f87663f249955272614b1944a4dd6c8aba70df6 |
File details
Details for the file tinyecs-0.2.9-py3-none-any.whl
.
File metadata
- Download URL: tinyecs-0.2.9-py3-none-any.whl
- Upload date:
- Size: 32.3 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/4.0.2 CPython/3.12.0
File hashes
Algorithm | Hash digest | |
---|---|---|
SHA256 | ede8a8f5b4e80f9849d32d5f9faf63fe71386345fc3cebcaae5f8e8696a2f949 |
|
MD5 | c2a255e6e1446f2c28473072a5bd222c |
|
BLAKE2b-256 | e5f5473d1b3eff6a98706ddb0f19c40bd59199f58ce4a333e2189b0f10f6710e |