ECS#
App logic in xecs
is written using an entity component system (ECS).
ECS is a design pattern used in software development,
particularly in game development, to manage the behavior and data of entities
within a system efficiently. It’s a way to structure code and organize data
in a flexible and scalable manner. ECS separates the concerns of an
entity’s identity, its data, and its behavior into distinct components, making
it highly modular and conducive to parallel processing.
Here’s a breakdown of the core concepts in an ECS:
Entity: An entity is a general-purpose object or game entity. It’s essentially an identifier that can represent anything in your system. Entities don’t contain any data or logic themselves; they’re just used to group components together.
Component: A component is a self-contained unit of data that represents a specific aspect or attribute of an entity. For example, you might have components for position, velocity, health, rendering, and more. Each component typically contains only data and no behavior. In
xecs
we define components using a dataclass-like syntax:import xecs as xx class Person(xx.Component): stamina: xx.Int is_damaged: xx.Bool height: xx.Float
System: Systems are responsible for processing entities that have specific combinations of components and applying behaviors or functionality to them. Systems operate on entities that match a set of component requirements, making it easy to implement different behaviors independently and in parallel. For example, you might have a rendering system, a physics system, and an input system. In
xecs
systems are normal Python functions:def print_person_system( query: xx.Query[Person], ) -> None: person = query.result() print(person)
Your First System#
A simple system does not have to take any parameters:
def hello_world() -> None:
print("Hello world!")
We can create a working program by combining the above snippet with our basic boilerplate:
import xecs as xx
def hello_world() -> None:
print("Hello world!")
def main() -> None:
app = xx.RealTimeApp(num_entities=0)
app.add_system(hello_world)
app.update()
if __name__ == "__name__":
main()
If you copied the above code into a file called xecs_hello_world.py
,
you can run your code with:
python xecs_hello_world.py
The program will print:
Hello world!
Your First Components#
In ECS we model game objects, such as people, as entities.
An entity is essentially just a bundle of components. To start, we
create a Person
component:
class Person(xx.Component):
pass
Entities which represent a person will have this component. In our
example, we also want to keep track of how much health each person has.
If you’re not familiar with ECS you may be tempted to add a field to
the Person
component, such as health: xx.Int
. However,
other entities may have health too. By splitting up health into
a separate component, we can eventually write systems which operate
on any entity which has health. For example, a damage system will not
care if the entity receiving damage is a person or a cow. In any case,
here is our new component:
class Health(xx.Component):
value: xx.Int
Next, we add people into our World
using a
“startup system”. Startup systems are run once, before any other
system. We use Commands
to spawn entities into our
World
:
def spawn_people(
commands: xx.Commands,
) -> None:
commands.spawn((Person, Health), 5)
To show we’ve spawned our people, and their health, we can write a new system
which acts on all entities with a Person
and Health
component:
def report_person_health(
query: xx.Query[tuple[Person, Health]],
) -> None:
(person, health) = query.result()
print(person)
print(health)
The parameters of our system function determine what data
our system runs on. In this case we are getting all
entities with a Person
and Health
component. The
person
and health
variables are actually arrays
of all Person
and Health
components, which belong
to entities containing both.
Finally, let’s write our main
function again and
register our new systems:
def main() -> None:
app = xx.RealTimeApp(num_entities=5)
app.add_startup_system(spawn_people)
app.add_system(report_person_health)
app.add_pool(Person.create_pool(5))
app.add_pool(Health.create_pool(5))
app.update()
Notice we also called add_pool()
. In xecs
we
reserve memory ahead of time for our components. This means that as our app runs,
we can avoid unnecessary re-allocations.
The output of our program will be as follows:
<Person()>
<Health(
value=<xecs.Int32 [0, 0, 0, 0, 0]>,
)>
Initializing Components#
In the previous section we spawned a bunch of health components:
def spawn_people(
commands: xx.Commands,
) -> None:
commands.spawn((Person, Health), 5)
We also saw that when we printed out Health
component, the values
were set to 0. Let’s say our game requires full health to be a value of
100
, we can edit our function so that newly spawned components are
set to this value:
def spawn_people(
commands: xx.Commands,
world: xx.World,
) -> None:
personi, healthi = commands.spawn((Person, Health), 5)
health = world.get_view(Health, healthi)
health.value.fill(100)
There is a lot going on here so let’s take it step by step. First,
we added world: xx.World
to our parameter list, so that
our system has access to the our simulated World
. The
World
can be used by systems to access entities, resources
and even other systems. In our system we will use the World
to access the newly spawned Health
components, so that we can set their
value to 100
.
We also created the personi
and healthi
variables from the return
value of spawn()
. Recall that our components are held
in a pool we created in our main()
function. The
spawn()
command returns the indices of the components
we just spawned. We can retrieve the actual components by using
get_view()
.
The health
variable has type Health
and is an array of all
newly spawned health components. The value
attribute is of type
Int32
and holds all the health values. We call
fill()
to set all the selected values to 100
.
If we run our program again, our output will be:
<Person()>
<Health(
value=<xecs.Int32 [100, 100, 100, 100, 100]>,
)>
Doing Math#
Getting access to our components in a system is step one, but more often than not, we will want to perform some kind of numerical operation on our data. Let’s continue our example by adding a damage system. At each step it will remove one health point from our entities:
def damage_system(
query: xx.Query[tuple[Person, Health]],
) -> None:
person, health = query.result()
health.value -= 1
Recall that health
has type Health
and is actually an array
of all Health
components on entities which also have a Person
component. The value
attribute is of type Int32
.
It is an array holding all the integers representing the
health values. The primitive types in
xecs
such as Bool
, Int32
and Float32
are arrays holding a value for each
entity in the current view. Numerical types such as Int32
and Float32
provide element wise arithmetic operations, much
like NumPy.
Our values can be updated in-place using operators such as +=
,
-=
, *=
and so on. The right hand side of the operator can be
a single number, a list of numbers or a NumPy array. When using
list or array of numbers the operation is performed element-wise.
Operators such as +
, -
and *
do not update our
components in-place, instead they return a NumPy array of the
results. If we want to place the results back into our
components we can use fill()
. Finally,
if we want to use NumPy functions, we can convert our component
values into NumPy arrays with numpy()
.
Filtering Components#
For some systems we want to filter out entities based on the values of components. Take for example a healing system, which adds a health point to any entity with less than 50 health:
def healing_system(
query: xx.Query[tuple[Person, Health]],
) -> None:
person, health = query.result()
low_health = health[health.value < 50]
low_health.value += 1
In this system, health.value < 50
returns a boolean mask.
When the mask is used to index, as in health[...]
, a new
Health
component is returned, holding only entities where
the mask was True
.
Combining Entities#
As you can tell, xecs
focuses a lot on element-wise operations.
In fact, this is the primary tool it uses for its performance. As a result,
if you find yourself using a for-loop inside an xecs
system, chances are
something has gone wrong.
One common reason to reach for a for-loop is to go through all pairs of entities because we expect them to have some kind of interaction. Let’s write a new app. In this app we will:
Spawn some entities.
Assign them some positions.
Go through all pairs of entities.
If two entities are “close”, we will consider them to be neighbors and increase their neighbor count.
import xecs as xx
class Neighbors(xx.Component):
num_neighbors: xx.Int
def spawn_entities(
commands: xx.Commands,
world: xx.World,
) -> None:
_, transformi = commands.spawn((Neighbors, xx.Transform2), 5)
transform = world.get_view(xx.Transform2, transformi)
transform.translation.x.fill([1, 2, 3, 4, 5])
def count_neighbors(
query: xx.Query[tuple[Neighbors, xx.Transform2]],
) -> None:
(neighbors, transform1), (_, transform2) = query.product_2()
x_distance = abs(transform1.translation.x - transform2.translation.x)
neighbors[x_distance < 2].num_neighbors += 1
def print_neighbors(query: xx.Query[Neighbors]) -> None:
print(query.result())
def main() -> None:
app = xx.RealTimeApp(num_entities=5)
app.add_startup_system(spawn_entities)
app.add_system(count_neighbors)
app.add_system(print_neighbors)
app.add_pool(Neighbors.create_pool(5))
app.add_pool(xx.Transform2.create_pool(5))
app.update()
if __name__ == "__main__":
main()