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:

An Entity is a thing that has various Components attached to it.

Entities might look like this:

    Entity 0 
        Health
        Stamina
        AIState 
    
    Entity 1
        Health
        Stamina
        Inventory

    Entity 2
        Health

Components are just a chunk of data, in Rust we'll use regular structs as our components.

For example a Health component might look like this:

struct Health(i32);

Systems 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 Vecs, 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 Systems 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_components Vec and the name_components 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:

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.

A person sitting on a dock in front of a beautiful landscape
You thinking about the ECS pattern

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 traits 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 Vecs 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 Any 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_vecs 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?

A path winding along the top of a mountain.

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:

Others are more difficult:

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.