Tutorial¶
What is an ECS?¶
ECS is an architecture aimed at simplifying the development and maintenance of complex video games. Ideas that have shaped WECS are that… * state should be separated from the logic working on it, * state objects (often also called ‘game objects’ for lack of a better term) should be extended by using composition instead of classical patterns of inheritance, and be extendable and restrictable at runtime, * logic is applied in a round-robin fashion; In the context of games that likely means “each piece of logic is applied once per frame, in a predetermined order”, * logic is applied to objects where it is applicable, as determined by their current type, * different parts of logic communicate with each other via the state changes that they cause.
In the context of ECS, state objects are called Entities, and
critically, they do not by themselves store any actual state. Instead,
they are collections for Components, which contain the state. An
Entity without Components is a game object with no state, and
thus no logic is working on it.
Components have a type, and contain fields of data. They closely
correspond to Python dataclasses. They offer no functionality, just pure
state.
Pieces of logic are called Systems. Each System has Filters,
which are simple pattern matching functions that test whether an
Entity should be processed by the System, and how, based on what
types of Components the Entity has. (Note that the idea of there
being multiple Filters per System seems so far to be specific
to WECS.)
The World is the container for Entities and Systems. It
provides funtionality to advance the state one time step by running all
Systems in order.
FIXME: We really need a graphic here, or at least some table.
World¶
from wecs.core import World
world = World()
world.update()
If you’re using the Panda3D boilerplate, a World will be provided as
base.ecs_world. There’s no need to call .update(), as
Systems get wrapped into tasks.
Components¶
Components are no more complicated than shown in the Hello World example. Consider them dataclasses; Under the hood, they (currently) are.
from wecs.core import Component
@Component()
class MyComponent:
pass
Entities¶
# Creating / destroying entities
entity = world.create_entity() # Creating an entity
entity = world.create_entity( # Add components during creation
ComponentA(),
ComponentB(),
)
world.destroy_entity(entity) # Destroy entity
# Working with components
entity[Component] = Component() # Add component
component = entity[Component] # Get component
Component in entity # Check for presence
del entity[Component] # Remove component
The actions of adding and removing Components to and from
Entities is deferred; That is, it is not being executed at the time
that it is commanded. While rarely relevant, it should be kept in mind.
Details on it can be found below under Systems (FIXME: Link to
section). The only thing necessary to keep in mind for now: Component
additions and removals do not happen immediately.
Deferral happens so that you can manipulate Entities within a
System’s update() function, but maintain its set of components
for other code in that function which may expect it to be in the state
that it was in when the update() began. For example, removing a
Component immediately may lead to a state where it does not satisfy
a Filter anymore, but since it was in it when the update began, it
will still be processed later on in the update(). By deferring the
removal, simpler and less bug-prone code can be written.
Deferred updates are executed (“flushed”) by calling
world._flush_component_updates(). It is rarely necessary to do that
flush yourself; It is automatically done before each
System.update(), and also each world.add_system(). The only case
where it is useful is when you have code external to WECS (other than
initial setup) that manipulates an Entity’s component set, and
then has other code that tries to access the Entity in its new
state. If you ever come across such a use case, a “Why not just make
those Systems?” may be in order.
References¶
When keeping references to Entities in Components (or anywhere
in your software, for that matter), a situation may occur where the
referenced Entity may be unexpectedly deleted. While that would
remove it from the World, it could code-wise still be interacted
with as if nothing had happened. In many cases, you might consider that
“premature” deletion of the Entity to be a bug; That Entity
should not have been deleted without involving the Component that
references it.
In other cases, e.g. a role-playing game where any game world object may
magically be removed from existence at any time, dangling references can
be embraced as a self-healing mechanism. To do so, keep a reference to
Entity._uid, and use that to World.get_entity(uid). If the
Entity has been deleted, a wecs.core.NoSuchUID will be raised.
Systems¶
from wecs.core import System
from wecs.core import and_filter, or_filter
class MySystem(System):
entity_filters = {
'just_a': ComponentA,
'complex': and_filter(
ComponentA,
or_filter(ComponentB, ComponentC),
)
}
def enter_filter_just_a(self, entity):
pass
def exit_filter_just_a(self, entity):
pass
def enter_filter_complex(self, entity):
pass
def exit_filter_complex(self, entity):
pass
def update(self, entities_by_filter):
# We'll get something like:
# {'just_a': set([entity_1, entity_2]),
# 'complex': set([entity_1]),
# }
pass
Systems process all Entities to which they are relevant (as
defined by their Filters).
When the World runs a flush (for example directly before running a
System, adding Components to Entities and removing them,
these Entities will be tested against all Filters of all
Systems to see whether they enter or exit the set of Entities
that a given Filter tests for. If an Entity newly matches a
Filter, the System’s
enter_filter_<filter_name>(self, entity) will be called with that
Entity as argument. If it conversely no longer matches,
exit_filter_<filter_name>(self, entity) will be called instead.
When the World runs the actual update, the System’s
update(self, entities_by_filter) function gets called, receiving a
dictionary of all entities in each filter.
Let’s deep-dive into the flush for a moment. The World has an
addition_pool and a removal_pool to track which Entities
have pending additions or removals of Components. When flushing, the
World flushes the removal_pool repeatedly until it is empty,
then the addition_pool once. This is repeated in a loop until both
pools are empty; This way, additions and removals occurring during the
flushes are also flushed. Removals happen until the Entities have
reached a minimalistic state, then aditions happen.
In a removal flush, the post-removal state of each Entity in the
removal pool is determined, and tested by each System. The
System determines which Filters the Entity drops out of (it
previously matched, but no longer does so), then calls the corresponding
exit_filter_<filter_name> functions in the reverse of the order
that the Filters are specified in in System.entity_filters. At
this time, the Components to be removed are still present, so that
they can be torn down easily. Only once all exits on all Entities
have been processed are the Components actually removed.
The addition flush does the same in reverse. First, all deferred
Component additions are performed. Then the same Filter testing
happens, this time calling exit_filter_<filter_name> in the order
that the filters were specified in.
Should your requirements for the order of calls to entry and exit
functions be even more complex, there’s still a way. “Call all
enter/exit functions in forward/reverse order” is just the
default behavior implemented by
System.enter_filters(self, filters, entity) and
System.exit_filters(self, filters, entity), so you can override it.
filters is the list of names of Filters to be entered/exited.
Summary: WECS core¶
World- has a set of
Entities - has a set of
Systems - causes
Systemsto process their relevantEntitiesin an appropriate running order
- has a set of
Entities- have a set of
Components - are, with regard to how they are processed, type- and stateless
- have a set of
Components- are the state of an
Entity - have a type
- are the state of an
Systems- have filters which have
- a name identifying them
- a function testing for the presence of component types
- process
EntitieswhenComponentsare added to theEntityso that it now satisfies a filter;System.enter_filter_<filter_name>(entity)is called with theEntity’s post-addition state,Componentsare removed from theEntityso that it now does not satisfy a filter anymore;System.exit_filter_<filter_name>(entity)with theEntity’s post-removal state,- the
Systemis added to or removed from theWorld; It will callenter/exit_filter_<filter_name>accordingly, System.update, its recurring game logic, is being run, caused byworld.update().
- have filters which have
A game is set up by… * creating Entities in the World, and
giving them the Components that describe their properties, * adding
a list of Systems which describe how components’ states should
change over time; This is the content of your main loop.
Now when running the main loop, each System will fetch all
Entities that have sufficient Components to satisfy one or more
of its filters, then update them. This may involve updating
Components that aren’t on any of the System’s filters, and
which may even be on any Entity in the World.
Aspects¶
While not part of the core, Aspects also deserve a mention here,
since they simplify creating and modifying the Component sets of
Etities.
from wecs.core import Aspect
from wecs.core import factory
base_aspect = Aspect(BaseComponent)
derived_aspect_a = Aspect(base_aspect, ComponentA)
derived_aspect_b = Aspect(
base_aspect,
overrides={
base_aspect: dict(
a_field=5,
another_field=factory(SomeFactoryClassOfFunction),
)
},
)
entity = world.create_entity()
world.create_entity(derived_aspect_a())
base_aspect in entity # True
derived_aspect_a.remove(entity)
An Aspect is a set of Component types, and the default values
for them (which may differ from the component’s usual default values).
When creating an Aspect, both Component and Aspects are
pooled to form the new Aspect. Should any component be present
multiple times, the creation will fail.
Calling an Aspect returns a set of Component instances, so that
they can be added during Entity creation.
overrides can be added to Aspects, and also be passed as an
argument when creating Component instances:
my_aspect.add(entity, overrides=dict(...))
components = my_aspect(overrides=dict(...))
Overrides passed when creating Component instances override
those given during the creation of the Aspect, which in turn
override those given to any Aspect that this one is building on.