Tutorial: Writing a tiny Entity Component System in Rust
The Entity Component System (or ECS) pattern is all the rage in the Rust game development community. In this short tutorial we're going to build our own.
This tutorial assumes a little familiarity with Rust, but you'll be able to follow along if you're familiar with similar languages. I'll explain concepts and syntax that are unique to Rust.
Our ECS will be very small and do very few things, but it will be an ECS!
First things first:
What is the ECS pattern?
ECS is a pattern to manage composable logic and shared behavior. The ECS pattern is often used in games.
There are three main things in the ECS pattern:
- Entities
- Components
- Systems
An Entity
is a thing that has various Component
s attached to it.
Entities might look like this:
Entity 0
Health
Stamina
AIState
Entity 1
Health
Stamina
Inventory
Entity 2
Health
Component
s are just a chunk of data, in Rust we'll use regular struct
s as our components.
For example a Health
component might look like this:
struct Health(i32);
System
s are logic or behavior that work by iterating over groups of components.
Here's pseudo-code for a system in Rust:
let entities =
/* An iterator of all entities with health and name components */;
for (health, name) in entities {
if health.0 < 0 {
println!("{} has perished!", name);
}
}
Let's begin!
Creating the World
First let's create the simplest thing we can call an ECS implementation.
For now we'll only support two components:
Health
and Name
struct Health(i32);
struct Name(&'static str);
We'll store all our ECS data in a struct
called World
:
struct World {
health_components: Vec<Option<Health>>,
name_components: Vec<Option<Name>>,
}
impl World {
fn new() -> Self {
Self {
health_components: Vec::new(),
name_components: Vec::new(),
}
}
For each new entity we'll push to both Vec
s, like this:
impl World {
/* ... */
fn new_entity(&mut self, health: Option<Health>, name: Option<Name>) {
self.health_components.push(health);
self.name_components.push(name);
}
}
With this design each Vec
will always have the same length. Each Vec
will hold either a None
value or a component for each Entity. To check if an Entity has a component is as simple as indexing into the appropriate Vec
and checking if the value is None
or not.
We can create our world and a few entities like so:
let mut world = World::new();
// Icarus's health is *not* looking good.
world.new_entity(Some(Health(-10)), Some(Name("Icarus")));
// Prometheus is very healthy.
world.new_entity(Some(Health(100)), Some(Name("Prometheus")));
// Note that Zeus does not have a `Health` component.
world.new_entity(None, Some(Name("Zeus")));
Iterating
Now we need a way to run System
s across our components.
Let's start with a fairly verbose approach that iterates the components of all entities with Health
and Name
components.
First we'll create an Iterator
that iterates the health_component
s Vec
and the name_component
s Vec
together.
let zip = world
.health_components
.iter()
.zip(world.name_components.iter());
Then let's filter out all the entries that don't have both components:
let with_health_and_name =
zip.filter_map(|(health, name): (&Option<Health>, &Option<Name>)| {
Some((health.as_ref()?, name.as_ref()?))
});
A few things are going on here that deserve explanation:
filter_map
creates anIterator
that skips any values that returnNone
otherwise it will return the contents of theSome()
.as_ref
remaps our&Option<Health>
value beOption<&Health>
.- And the
?
at the end ofhealth.as_ref()?
says "returnNone
if this value isNone
, otherwise return the inner value."
The end result is an Iterator
that iterates only the components of entities that have both components, and then returns references to those components.
Let's use it!
for (health, name) in with_health_and_name {
if health.0 < 0 {
println!("{} has perished!", name.0);
} else {
println!("{} is still healthy", name.0);
}
}
The above code will print out the following:
Icarus has perished!
Prometheus is still healthy
Note that Zeus's components were skipped over entirely, because he doesn't even have a Health Component
.
Click here to edit and run the code from your browser
This is a good checkpoint to pause and reflect.
I hope you can see how this pattern is powerful. By mixing and matching components we can change the code that runs for each Entity.
I'm sure you've noticed our ECS implementation is flawed in a number of ways.
Let's make it better! First let's see if we can support more than two types of components.
Dynamic Components
We're going to be changing pretty much everything, so let's put aside our old code and start fresh.
We need a new architecture for our World
struct
that works for however many component types we want, without manually adding them to the World
struct like we were before.
Since we don't know what types of components we'll be using at compile-time we need to make things more dynamic by using Rust's dyn
feature.
Let's change our World
to look like this:
struct World {
// We'll use `entities_count` to assign each Entity a unique ID.
entities_count: usize,
component_vecs: Vec<Box<dyn ComponentVec>>,
}
impl World {
fn new() -> Self {
Self {
entities_count: 0,
component_vecs: Vec::new(),
}
}
}
Instead of using a bunch of Vec<Option<ComponentType>>
s like we did before we'll store multiple Vec<Option<ComponentType>>
s all within the component_vecs
field.
However since we don't know the exact types of each Vec
anymore we need to use a trait
to defines the shared functions each component_vec
will have.
Rust's trait
s are the way to declare behavior that different types have in common.
In our case we will declare a trait
called ComponentVec
:
trait ComponentVec {
fn push_none(&mut self);
/* we'll add more functions here in a moment */
}
By declaring the push_none
function in the ComponentVec
trait we're stating that everything that implements ComponentVec
needs a function called push_none
.
Let's define that for a Vec<Option<T>>
impl<T> ComponentVec for Vec<Option<T>> {
fn push_none(&mut self) {
self.push(None)
}
}
Now we can add new Vec
s to the World
with the following code:
component_vecs.push(Box::new(Vec::<Option<ComponentType>>::new()))
... but we're not going to do that quite yet.
The Box
is necessary because Vec
requires each item to be the same size and Rust doesn't know the size of each unique Vec<Option<ComponentType>>
so it can't verify we're following the rules. Conveniently Box
is always the same size regardless of what it stores, yet it can store types of any size, so it solves our problem.
With our new trait ComponentVec
setup let's add a function to the World
to create a new Entity
``
fn new_entity(&mut self) -> usize {
let entity_id = self.entities_count;
for component_vec in self.component_vecs.iter_mut() {
component_vec.push_none();
}
self.entities_count += 1;
entity_id
}
We call push_none
on each component channel because our Entity
will be initialized without any components. And we return entity_id
so have an index to refer back to later.
Adding Components to Entities
Now let's add components to our entities.
Let's add a function to the World
:
fn add_component_to_entity<ComponentType: 'static>(
&mut self,
entity: usize,
component: ComponentType,
) {
/* do stuff */
}
This function accepts a component of any type and pushes it to appropriate component_vec
in the World
.
Let's begin by looping through each component_vec
and checking if it's the correct type.
To do that we'll need a way to convert a dyn ComponentVec
into the Vec<Option<ComponentType>>
we need, or fail if it can't be converted.
To accomplish this we'll use Rust's Any
trait. References like &T
or &mut T
can be converted to &Any
or &mut Any
respectively.
Any
provides functions that attempt to convert the Any
into another type.
We'll add a function to the ComponentVec
trait that specifies that a reference to a type implementing ComponentVec
must be able to convert to Any
. Then we'll use that Any
to attempt to convert into the Vec<Option<ComponentType>>
we actually need.
Let's amend our ComponentVec
declaration and our ComponentVec for Vec<Option<T>>
implementation to look like this:
trait ComponentVec {
fn as_any(&self) -> &dyn std::any::Any;
fn as_any_mut(&mut self) -> &mut dyn std::any::Any;
fn push_none(&mut self);
}
impl<T: 'static> ComponentVec for Vec<Option<T>> {
fn as_any(&self) -> &dyn std::any::Any {
self as &dyn std::any::Any
}
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
self as &mut dyn std::any::Any
}
fn push_none(&mut self) {
self.push(None)
}
}
Note that we defined both as_any
and as_any_mut
. This lets us choose if want to convert to a &mut Any
or a &Any
, which will come in handy later.
Finally we're ready to actually add a component to an Entity
fn add_component_to_entity<ComponentType: 'static>(
&mut self,
entity: usize,
component: ComponentType,
) {
for component_vec in self.component_vecs.iter_mut() {
if let Some(component_vec) = component_vec
.as_any_mut()
.downcast_mut::<Vec<Option<ComponentType>>>()
{
component_vec[entity] = Some(component);
return;
}
}
/* continued below */
}
First we iterate through all component_vec
s to find a matching one. We use our new as_any_mut()
function to convert the &mut dyn ComponentVec
into a &mut dyn Any
and then use &mut dyn Any
's downcast_mut
function to attempt to convert to a Vec<Option<ComponentType>>
of the ComponentType
we're adding.
If the conversion fails we keep searching for a matching ComponentVec
. If it succeeds we insert the component with component_vec[entity] = Some(component);
and return;
What if a component_vec
doesn't exist in the World
yet? Let's handle that case by creating a new component_vec
if there isn't a matching one:
/* continued from above */
// No matching component storage exists yet, so we have to make one.
let mut new_component_vec: Vec<Option<ComponentType>> =
Vec::with_capacity(self.entities_count);
// All existing entities don't have this component, so we give them `None`
for _ in 0..self.entities_count {
new_component_vec.push(None);
}
// Give this Entity the Component.
new_component_vec[entity] = Some(component);
self.component_vecs.push(Box::new(new_component_vec));
}
VoilĂ ! Now we can add components of any type to entities using generic code.
Witness our new power:
world.add_component_to_entity(entity0, Health(100));
world.add_component_to_entity(entity0, Name("Somebody"));
Incredible.
Iterating (again)
Now let's figure out how to iterate over the dynamic data in our World
.
Let's add a function to World
that finds and borrows the ComponentVec
that matches a type:
fn borrow_component_vec<ComponentType: 'static>(&self) -> Option<&Vec<Option<ComponentType>>> {
for component_vec in self.component_vecs.iter() {
if let Some(component_vec) = component_vec
.as_any()
.downcast_ref::<Vec<Option<ComponentType>>>()
{
return Some(component_vec);
}
}
None
}
If we fail to find a matching ComponentVec
we simply return None
.
We can iterate over our borrowed data like so:
let data = world.borrow_component_vec::<Health>().unwrap();
for health_component in data.iter().filter_map(|f| f.as_ref()) {
/* do something here */
}
And we can use the same filter_map
techniques from the earlier part of this tutorial to create an Iterator
over only the components of entities that have all the components we're looking for.
let zip = world
.borrow_component_vec::<Health>()
.unwrap()
.iter()
.zip(world.borrow_component_vec::<Name>().unwrap().iter());
// Same as before!
for (health, name) in zip.filter_map(|(health, name)| {
Some((health.as_ref()?, name.as_ref()?))
}) {
if health < 0 {
println!("{} has perished!", name);
}
}
I'm sure you're thinking "These weird iterator tricks are kinda confusing", and you'd be right. Typically ECS libraries construct these iterators for you so that you don't need to consider the details at all. That's outside the scope of this tutorial, but it's something to keep in mind.
Mutable borrows
Now we come to what Rust is famous for: the borrow-checker. You may have noticed that we can't actually edit any of our components as we iterate over them.
We could try to add a function with this definition:
fn borrow_component_vec<ComponentType: 'static>(&mut self) -> Option<&mut Vec<Option<ComponentType>>>
But unfortunately that means we can only borrow one ComponentVec
from the World
at a time, which is rather limited.
What we need is a check each time we try to use a ComponentVec
that asks "Is something already using this?"
Fortunately Rust has various containers to help accomplish that:
We'll use RefCell
.
RefCell
implements the functions borrow_mut
and borrow
that will fail if they cannot borrow an object, which is exactly what we need.
Let's go through our code and replace Vec<Option<T>>
with RefCell<Vec<Option<T>>>
and fix errors as we go.
Toss a use std::cell::RefCell;
on the top of the file so you don't have to write std::cell::RefCell
each time.
First let's amend our ComponentVec
trait to implement for RefCell<Vec<Option<T>>>
instead:
impl<T: 'static> ComponentVec for RefCell<Vec<Option<T>>> {
// Same as before
fn as_any(&self) -> &dyn std::any::Any {
self as &dyn std::any::Any
}
// Same as before
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
self as &mut dyn std::any::Any
}
fn push_none(&mut self) {
// `&mut self` already guarantees we have
// exclusive access to self so can use `get_mut` here
// which avoids any runtime checks.
self.get_mut().push(None)
}
}
Now let's look for anywhere we use downcast
and change it to RefCell<Vec<Option<T>>>
Our add_component_to_entity
function needs to be amended in a few places. The changed lines are commented:
fn add_component_to_entity<ComponentType: 'static>(
&mut self,
entity: usize,
component: ComponentType,
) {
for component_vec in self.component_vecs.iter_mut() {
// The `downcast_mut` type here is changed to `RefCell<Vec<Option<ComponentType>>`
if let Some(component_vec) = component_vec
.as_any_mut()
.downcast_mut::<RefCell<Vec<Option<ComponentType>>>>()
{
// add a `get_mut` here. Once again `get_mut` bypasses
// `RefCell`'s runtime checks if accessing through a `&mut` reference.
component_vec.get_mut()[entity] = Some(component);
return;
}
}
let mut new_component_vec: Vec<Option<ComponentType>> =
Vec::with_capacity(self.entities_count);
for _ in 0..self.entities_count {
new_component_vec.push(None);
}
new_component_vec[entity] = Some(component);
// Here we create a `RefCell` before inserting into `component_vecs`
self.component_vecs
.push(Box::new(RefCell::new(new_component_vec)));
}
And finally we'll change borrow_component_vec
:
// We've changed the return type to be a `RefMut`.
// That's what `RefCell` returns when `borow_mut` is used to borrow from the `RefCell`
// When `RefMut` is dropped the `RefCell` is alerted that it can be borrowed from again.
fn borrow_component_vec<ComponentType: 'static>(
&self,
) -> Option<RefMut<Vec<Option<ComponentType>>>> {
for component_vec in self.component_vecs.iter() {
if let Some(component_vec) = component_vec
.as_any()
.downcast_ref::<RefCell<Vec<Option<ComponentType>>>>()
{
// Here we use `borrow_mut`.
// If this `RefCell` is already borrowed from this will panic.
return Some(component_vec.borrow_mut());
}
}
None
}
Now we can borrow two component types from the World
and use our iterator magic to mutably iterate both components at once:
let mut healths = world.borrow_component_vec_mut::<Health>().unwrap();
let mut names = world.borrow_component_vec_mut::<Name>().unwrap();
let zip = healths.iter_mut().zip(names.iter_mut());
let iter = zip.filter_map(|(health, name)| Some((health.as_mut()?, name.as_mut()?)));
for (health, name) in iter
{
if name.0 == "Perseus" && health.0 <= 0 {
*health = Health(100);
}
}
If we attempt to borrow the same components twice we'll get a helpful runtime error:
let mut healths_ = world.borrow_component_vec_mut::<Health>().unwrap();
let mut healths_again = world.borrow_component_vec_mut::<Health>().unwrap();
// thread 'main' panicked at 'already borrowed: BorrowMutError', src/main.rs:86:43
Click here to view or play with the code we've written!
What's next?
Now we have the start of an ECS implementation in Rust. You made it!
I'm sure you've already thought of many ways our ECS implementation could be improved. It's missing many critical features, but even still we have the core ECS parts working.
Some improvements are easier:
- Removing components
- Removing entities
Others are more difficult:
- Simpler iterators
- More efficient iteration
One of the big issues with our ECS is when there are lots of entities with varied components our iterators will spend a lot of computation skipping over None
values.
A solution to that problem is an "Archetypal" ECS, which can be much faster to iterate at the expense of slower adds and removes.
If you'd like to learn more there are many ECS projects in the Rust ecosystem to learn from or contribute to:
I've also been working on a fledgling, not very tested, and rather messy ECS, but I think it's promising: kudo
For more Rust game resources and tutorials check out the Rust Gamedev website.
Lastly, consider joining the Rust Gamedev Discords if you need help or want to join a community of people making Rust awesome for games: https://arewegameyet.rs/#chat
Reach out on Twitter: @kettlecorn
Or send me an email if you want to get in touch.
Edit: 7/14/21/ Thanks Elia Perantoni for reporting a typo!