mecs
is an implementation of the Entity Component System (ECS) paradigm for Python 3, with a focus on interface minimalism and performance.
Inspired by Esper and Sean Fisk's ecs.
For a full list of changes see CHANGELOG.md.
-
v1.2.1 - Improve performance
-
v1.2.0 - Add support for manipulating multiple components at once
The methods
scene.new()
,scene.set()
,scene.has()
, andscene.remove()
(whereset()
replacesadd()
) now support multiple components/component types. The appropriate methods have also been modified in theCommandBuffer
.scene.get()
now has a conterpartscene.collect()
which supports multiple component types. Minor changes include better exception messages andscene.buffer()
being deprecated in favour ofCommandBuffer(scene)
. -
v1.1.0 - Add command buffer
When using
scene.select()
, manipulation of entities can now be recorded using theCommandBuffer
instance returned byscene.buffer()
, and played back at a later time. This avoids unexpected behavior that would occur when using the scene instance directly. -
v1.0.0 - First release
The base functionality is implemented. Note that at this stage it is not safe to add or remove components while iterating over their entities. This will be fixed in a future release.
mecs
is implemented in a single file with no dependencies. Simply copy mecs.py
to your project folder and import mecs
.
The Entity Component System (ECS) paradigm consists of three different concepts, namely entities, components and systems. These should be understood as follows:
- Entities are unique identifiers, labeling a set of components as belonging to a logical group.
- Components are plain data and implement no logic. They define the behavior of entities.
- Systems are logic that operates on entities and their components. They enforce the appropriate behavior of entities with certain component sets and are also able to change their behavior by adding, removing and mutating components.
For more information about the ECS paradigm, visit the Wikipedia article or Sander Mertens' ecs-faq.
For the management of entities, components and systems, mecs
provides the Scene
class. Only entities within the same scene may interact with one another. You can create a new scene with
scene = Scene()
Entities are nothing more than unique (integer) identifiers. To get hold of a previously unused entity id use
eid = scene.new()
Components can be instances of any class and mecs
does not provide a base class for them. For example a Position
component containing x
and y
coordinates could look like this:
class Position():
def __init__(self, x, y):
self.x, self.y = x, y
Other examples would be a similar Velocity
component or a Renderable
component:
class Velocity():
def __init__(self, vx, vy):
self.vx, self.vy = vx, vy
class Renderable():
def __init__(self, textureId):
self.textureId = textureId
Components are distinguished by their component type. To get the type of a component use the build-in type()
:
position = Position(15, 8)
type(position)
# => <class '__main__.Position'>
velocity = Velocity(8, 15)
type(velocity)
# => <class '__main__.Velocity'>
The Scene
class provides the following methods for interacting with entities and components. Note that the entity id used in these methods must be valid, i.e. must be returned from scene.new()
. Using an invalid entity id results in a KeyError
.
Returns a valid entity id to be used in other methods. It is also possible to directly set components of the new entity by supplying them to this method.
# create a new empty entity
eid = scene.new()
# => 0
# create a new entity with a Position, Velocity and Renderable component
anotherEid = scene.new(Position(15, 8), Velocity(8, 15), Renderable(7))
# => 1
Set components of an entity, which can either result in adding a component or overwriting an existing component. Note that an entity is only allowed to have one component of each type.
# Add a new Position component
scene.set(eid, Position(1, 2))
# Overwrite the Position component
scene.set(eid, Position(3, 4))
# Add a Velocity component and overwrite the Position component again
scene.set(eid, Velocity(0, -5), Position(5, 6))
Setting components of the same type in a single call to set()
is illegal and results in a ValueError
. Note that the same component instance can be added to multiple entities, making them share the component data.
This method returns True
if the entity does have components of all the specified types, False
otherwise.
# check for Position component (the entity has one)
scene.has(eid, Position)
# => True
# check for Position and Velocity (the entity has both)
scene.has(eid, Position, Velocity)
# => True
# check for Position and Renderable (the entity has a Position but is lacking the Renderable component)
scene.has(eid, Position, Renderable)
# => False
Returns the entities component of the specified type, allowing the view or edit the component data.
# move the entity by 10 units on the x-axis
position = scene.get(eid, Position)
position.x += 10
# stop the entity by setting its velocity to zero
velocity = scene.get(eid, Velocity)
velocity.vx, velocity.vy = 0, 0
Raises ValueError
if the entity is missing a component of the specified type.
Returns a list of the entities components of the specified types.
# repeat the example from above
position, velocity = scene.collect(eid, Position, Velocity)
position.x += 10
velocity.vx, velocity.vy = 0, 0
Raises ValueError
if the entity is missing one or more components of the specified types.
Removes the components of the entity that are of the specified types.
# remove the Position and the Velocity component
scene.remove(eid, Position, Velocity)
Raises ValueError
if the entity is missing one or more components of the specified types.
Removes all components of the entity.
scene.set(eid, Position(0, 0))
scene.set(eid, Velocity(0, 0))
scene.free(eid)
scene.has(eid, Position) or scene.has(eid, Velocity) or scene.has(eid, Renderable)
# => False
Note that this does not make the entity id invalid. In fact, there is no way to invalidate a once valid id. In particular, there is no method to check if an entity is still 'alive'. If you need such behavior, consider attaching an Alive
component (that has no further data) to every entity that needs it and use scene.has(eid, Alive)
to determine if the entity is alive.
8. Viewing the archetype of an entity and all of its components using scene.archetype(eid)
and scene.components(eid)
.
The archetype of an entity is the tuple of all component types that are attached to it.
scene.set(eid, Position(32, 64))
scene.set(eid, Velocity(8, 16))
scene.archetype(eid)
# => (<class '__main__.Position'>, <class '__main__.Velocity'>)
scene.components(eid)
# => (<__main__.Position object at 0x000001EF0358D370>, <__main__.Velocity object at 0x000001EF035B47C0>)
The result of scene.archetype(eid)
is sorted, so comparisons of the form scene.archetype(eid1) == scene.archetype(eid2)
are safe, but hardly necessary.
The result of this method is a generator object yielding tuples of the form (eid, (compA, compB, ...))
where compA
, compB
belong to the entity with entity id eid
and have the requested types. Optionally, an iterable (such as a list or tuple) may be passed to the exclude
argument, in which case all entities having one or more component types listed in exclude
will not be yielded by the method.
# adjust positions based on velocity
dt = current_deltatime()
for eid, (pos, vel) in scene.select(Position, Velocity):
pos.x += vel.vx * dt
pos.y += vel.vy * dt
Iterating over entities that have a certain set of components is one of the most important tasks in the ECS paradigm. Usually, this is done by systems to efficiently apply their logic to the appropriate entities. For more examples, see the section about systems.
Methods such as scene.new()
, scene.set()
, scene.remove()
, or scene.free()
alter the structure of the underlying database of the scene. This makes them not save to use while iterating over the result of scene.select()
. Using them in this context will not raise any exceptions, but will often lead to unexpected behaviour.
To resolve this issue, mecs
provides the CommandBuffer
class, which implements CommandBuffer.new(*comps)
, CommandBuffer.set(eid, *comps)
, CommandBuffer.remove(eid, *comptypes)
, and CommandBuffer.free(eid)
. Any calls to these methods will be recorded, and when it is save to do so, can be played back using CommandBuffer.flush()
. Alternatively, the command buffer can be used as a context manager, which is strongly recommended.
# remove all entities from the scene that are not withing the screen bounds
with CommandBuffer(scene) as buffer:
for eid, (pos,) in scene.select(Position):
if pos.x < 0 or pos.x > screen_width or pos.y < 0 or pos.y > screen_height:
buffer.free(eid)
As with components, mecs
does not provide a base class for systems. Instead, a system should implement any of the three callback methods (onStart()
, onUpdate()
, and onStop()
) and must be passed to the corresponding method of the Scene
class.
Any instance of any class that implements a method with the signature onStart(scene, **kwargs)
may be used as an input to this method.
The scene iterates through all systems in the order they are passed and calls their respective onStart()
methods, passing itself using the scene
argument. Additionally, any kwargs
will also be passed on.
class RenderSystem():
def onStart(self, scene, resolution=(600, 480), **kwargs):
self.graphics = init_graphics_devices(resolution)
self.textures = load_textures("./resources/textures")
renderSystem = RenderSystem()
startSystems = [renderSystem, AnotherInitSystem()]
scene.start(*startSystems, resolution=(1280, 720))
This method should not be called multiple times. Instead, all necessary systems should be instantiated first, followed by a single call to scene.start()
.
Any instance of any class that implements a method with the signature onUpdate(scene, **kwargs)
may be used as an input to this method.
The scene iterates through all systems in the order they are passed and calls their respective onUpdate()
methods, passing itself using the scene
argument. Additionally, any kwargs
will also be passed on.
class RenderSystem():
def onUpdate(self, scene, **kwargs):
for eid, (pos, rend) in scene.select(Position, Renderable):
texture = self.textures[rend.textureId]
self.graphics.render(pos.x, pos.y, texture))
class MovementSystem():
def onUpdate(self, scene, dt=1, **kwargs):
for eid, (pos, vel) in scene.select(Position, Velocity):
pos.x += vel.vx * dt
pos.y += vel.vy * dt
updateSystems = [MovementSystem(), renderSystem]
scene.update(*updateSystems, dt=current_deltatime())
To avoid any unnecessary overhead, call this method only once per update circle, passing all necessary systems as arguments.
Any instance of any class that implements a method with the signature onStop(scene, **kwargs)
may be used as an input to this method.
The scene iterates through all systems in the order they are passed and calls their respective onStop()
methods, passing itself using the scene
argument. Additionally, any kwargs
will also be passed on.
class RenderSystem():
def onStop(self, scene, **kwargs):
stop_graphics_devices(self.graphics)
unload_textures(self.textures)
stopSystems = [renderSystem, AnotherDestroySystem()]
scene.stop(*stopSystems)
As with scene.start()
this method should not be called multiple times, but instead once with all the necessary systems.
When trying to write the main loop of your program you may use this pattern.
# Your system instances go here.
systems = []
startSystems = [s for s in systems if hasattr(s, 'onStart')]
updateSystems = [s for s in systems if hasattr(s, 'onUpdate')]
stopSystems = [s for s in systems if hasattr(s, 'onStop')]
print("[Press Ctrl+C to stop]")
try:
scene = Scene()
scene.start(*startSystems)
while True:
deltaTime = current_deltatime()
scene.update(*updateSystems, dt=deltaTime)
except KeyboardInterrupt:
pass
finally:
scene.stop(*stopSystems)