Pascal Hertleif I've been involved in the Rust ecosystem since 2014, contributing to numerous projects, mainly focused on developer tooling and command line applications.

Rust Bevy Entity Component System

9 min read 2799

Rust Bevy Entity Component System

Editor’s note: This post includes additions from Alice Cecile, a contributor at Bevy. Help and review on the Bevy Discord by Joy and Logic was much appreciated.

Bevy is a game engine written in Rust that is known for featuring a very ergonomic Entity Component System.

In the ECS pattern, entities are unique things that are made up of components, like objects in a game world. Systems process these entities and control the application’s behavior. What makes Bevy’s API so elegant is that users can write regular functions in Rust, and Bevy will know how to call them by their type signature, dispatching the correct data.

There is already a good amount of documentation available on how to use the ECS pattern to build your own game, like in the Unofficial Bevy Cheat Book. Instead, in this article, we’ll explain how to implement the ECS pattern in Bevy itself. To do so, we’ll build a small, Bevy-like API from scratch that accepts arbitrary system functions.

This pattern is very generic, and you can apply it to your own Rust projects. To illustrate this, we’ll go into more detail in the last section of the article on how the Axum web framework uses this pattern for its route handler methods.

If you’re familiar with Rust and interested in type system tricks, then this article is for you. Before we begin, I’d recommend checking out my previous article on the implementation of Bevy’s labels. Let’s get started!

Table of contents

Bevy’s system functions like a user-facing API

First off, let’s learn how to use Bevy’s API so that we can work backwards from it, recreating it ourselves. The code below shows a small Bevy app with an example system:

use bevy::prelude::*;

fn main() {
    App::new()
        .add_plugins(DefaultPlugins) // includes rendering and keyboard input
        .add_system(move_player) // this is ours
        // in a real game you'd add more systems to e.g. spawn a player
        .run();
}

#[derive(Component)]
struct Player;

/// Move player when user presses space
fn move_player(
    // Fetches a resource registered with the `App`
    keyboard: Res<Input<KeyCode>>,
    // Queries the ECS for entities
    mut player: Query<(&mut Transform,), With<Player>>,
) {
    if !keyboard.just_pressed(KeyCode::Space) { return; }

    if let Ok(player) = player.get_single_mut() {
        // destructure the `(&mut Transform,)` type from above to access transform
        let (mut player_position,) = player;
        player_position.translation.x += 1.0;
    }
}

In the code above, we can pass a regular Rust function to add_system, and Bevy knows what to do with it. Even better, we can use our function parameters to tell Bevy which components we want to query. In our case, we want the Transform from every entity that also has the custom Player component. Behind the scenes, Bevy even infers which systems can run in parallel based on the function signature.

add_system method

Bevy has a lot of API surface. After all, it is a full game engine with a scheduling system, a 2D and 3D renderer, and more, in addition to its Entity Component System. In this article, we’ll ignore most of this, instead focusing on just adding functions as systems and calling them.

Following Bevy’s example, we’ll call the item we add the systems to, App, and give it two methods, new and add_system:

struct App {
    systems: Vec<System>,
}

impl App {
    fn new() -> App {
        App { systems: Vec::new() }
    }

    fn add_system(&mut self, system: System) {
        self.systems.push(system);
    }
}

struct System; // What is this?

However, this leads to the first problem. What is a system? In Bevy, we can just call the method with a function that has some useful arguments, but how can we do that in our own code?

Add functions as systems

One of the main abstractions in Rust are traits, which are similar to interfaces or type classes in other languages. We can define a trait and then implement it for arbitrary types so the trait’s methods become available on these types. Let’s create a System trait that allows us to run arbitrary systems:

trait System {
    fn run(&mut self);
}

Now, we have a trait for our systems, but to implement it in our functions, we need to use a few additional features of the type systems.

Rust uses traits for abstracting over behavior, and functions implement some traits, like FnMut, automatically. We can implement traits for all types that fulfill a constraint:

Let’s use the code below:

impl<F> System for F where F: Fn() -> () {
    fn run(&mut self) {
        self(); // Yup, we're calling ourselves here
    }
}

If you’re not used to Rust, this code might look quite unreadable. That’s okay, this is not something you see in an everyday Rust codebase.

The first line implements the system trait for all types that are functions with arguments that return something. In the following line, the run function takes the item itself and, since that is a function, calls it.

Although this works, it is quite useless. You can only call a function without arguments. But, before we go deeper into this example, let’s fix it up so we’re able to run it.

Interlude: Running an example

Our definition of App above was just a quick draft; for it to use our new System trait, we need to make it a bit more complex.



Since System is now a trait and not a type, we can’t directly store it anymore. We can’t even know what size the System is because it could be anything! Instead, we need to put it behind a pointer, or, as Rust calls it, put it in a Box. Instead of storing the concrete thing that implements System, you just store a pointer.

Such is a trick of the Rust type system: you can use trait objects to store arbitrary items that implement a specific trait.

First, our app needs to store a list of boxes that contain things that are a System. In practice, it looks like the code below:

struct App {
    systems: Vec<Box<dyn System>>,
}

Now, our add_system method also needs to accept anything that implements the System trait, putting it into that list. Now, the argument type is generic. We use S as a placeholder for anything that implements System, and since Rust wants us to make sure that it is valid for the entirety of the program, we are also asked to add 'static.

While we’re at it, let’s add a method to actually run the app:

impl App {
    fn new() -> App { // same as before
        App { systems: Vec::new() }
    }

    fn add_system<S: System + 'static>(mut self, system: S) -> Self {
        self.systems.push(Box::new(system));
        self
    }

    fn run(&mut self) {
        for system in &mut self.systems {
            system.run();
        }
    }
}

With this, we can now write a small example as follows:

fn main() {
    App::new()
        .add_system(example_system)
        .run();
}

fn example_system() {
    println!("foo");
}

You can play around with the full code so far. Now, let’s go back and revisit the problem of more complex system functions.

System functions with parameters

Let’s make the following function a valid System:

fn another_example_system(q: Query<Position>) {}

// Use this to fetch entities
struct Query<T> { output: T }

// The position of an entity in 2D space
struct Position { x: f32, y: f32 }

The seemingly easy option would be to add another implementation for System to add functions with one parameter. But, sadly, the Rust compiler will tell us that there’s two issues:

  1. If we add an implementation for a concrete function signature, the two implementations would conflict. Press run to see the error
  2. If we made the accepted function generic, it would be an unconstrained type parameter

We’ll need to approach this differently. Let’s first introduce a trait for the parameters we accept:

trait SystemParam {}

impl<T> SystemParam for Query<T> {}

To distinguish the different System implementations, we can add type parameters, which become part of its signature:

trait System<Params> {
    fn run(&mut self);
}

impl<F> System<()> for F where F: Fn() -> () {
    //         ^^ this is "unit", a tuple with no items
    fn run(&mut self) {
        self();
    }
}

impl<F, P1: SystemParam> System<(P1,)> for F where F: Fn(P1) -> () {
    //                             ^ this comma makes this a tuple with one item
    fn run(&mut self) {
        eprintln!("totally calling a function here");
    }
}

But now, the issue becomes that in all the places where we accept System, we need to add this type parameter. And, even worse, when we try to store the Box<dyn System>, we’d have to add one there, too:

error[E0107]: missing generics for trait `System`
  --> src/main.rs:23:26
   |
23 |     systems: Vec<Box<dyn System>>,
   |                          ^^^^^^ expected 1 generic argument
…
error[E0107]: missing generics for trait `System`
  --> src/main.rs:31:42
   |
31 |     fn add_system(mut self, system: impl System + 'static) -> Self {
   |                                          ^^^^^^ expected 1 generic argument
…

If you make all instances System<()> and comment out the .add_system(another_example_system), our code will compile.

Storing generic systems

Now, our challenge is to achieve the following criteria:

  1. We need to have a generic trait that knows its parameters
  2. We need to store generic systems in a list
  3. We need to be able to call these systems when iterating over them

This is a good place to look at Bevy’s code. Functions don’t implement System, but instead SystemParamFunction. In addition, add_system doesn’t take an impl System, but an impl IntoSystemDescriptor, which in turn uses a IntoSystem trait.

FunctionSystem, a struct, will implement System.

Let’s take inspiration from that and make our System trait simple again. Our code from earlier continues on as a new trait called SystemParamFunction. We’ll also introduce an IntoSystem trait, which our add_system function will accept:

trait IntoSystem<Params> {
    type Output: System;

    fn into_system(self) -> Self::Output;
}

We use an associated type to define what kind of System type this conversion will output.

This conversion trait still outputs a concrete system, but what is that? Here comes the magic. We add a FunctionSystem struct that will implement System, and we’ll add an IntoSystem implementation that creates it:

/// A wrapper around functions that are systems
struct FunctionSystem<F, Params: SystemParam> {
    /// The system function
    system: F,
    // TODO: Do stuff with params
    params: core::marker::PhantomData<Params>,
}

/// Convert any function with only system params into a system
impl<F, Params: SystemParam + 'static> IntoSystem<Params> for F
where
    F: SystemParamFunction<Params> + 'static,
{
    type System = FunctionSystem<F, Params>;

    fn into_system(self) -> Self::System {
        FunctionSystem {
            system: self,
            params: PhantomData,
        }
    }
}

/// Function with only system params
trait SystemParamFunction<Params: SystemParam>: 'static {
    fn run(&mut self);
}

SystemParamFunction is the generic trait we called System in the last chapter. As you can see, we’re not doing anything with the function parameters yet. We’ll just keep them around so everything is generic and then store them in the PhantomData type.

To fulfill the constraint from IntoSystem that its output has to be a System, we now implement the trait on our new type:

/// Make our function wrapper be a System
impl<F, Params: SystemParam> System for FunctionSystem<F, Params>
where
    F: SystemParamFunction<Params> + 'static,
{
    fn run(&mut self) {
        SystemParamFunction::run(&mut self.system);
    }
}

With that, we’re almost ready! Let’s update our add_system function, and then we can see how this all works:

impl App {
    fn add_system<F: IntoSystem<Params>, Params: SystemParam>(mut self, function: F) -> Self {
        self.systems.push(Box::new(function.into_system()));
        self
    }
}

Our function now accepts everything that implements IntoSystem with a type parameter that is a SystemParam.

To accept systems with more than one parameter, we can implement SystemParam on tuples of items that are system parameters themselves:

impl SystemParam for () {} // sure, a tuple with no elements counts
impl<T1: SystemParam> SystemParam for (T1,) {} // remember the comma!
impl<T1: SystemParam, T2: SystemParam> SystemParam for (T1, T2) {} // A real two-ple

But, what do we store now? Actually, we’ll do the same thing we did earlier:

struct App {
    systems: Vec<Box<dyn System>>,
}

Let’s explore why our code works.

Boxing up our generics

The trick is that we’re now storing a generic FunctionSystem as a trait object, meaning our Box<dyn System> is a fat pointer. It points to both the FunctionSystem in memory as well as a lookup table of everything related to the System trait for this instance of the type.

When using generic functions and data types, the compiler will monomorphize them to generate code for the types that are actually used. Therefore, if you use the same generic function with three different concrete types, it will be compiled three times.

Now, we’ve met all three of our criteria. We’ve implemented our trait for generic functions, we store a generic System box, and we still call run on it.

Fetching parameters

Sadly, our code doesn’t work just yet. We have no way of fetching the parameters and calling the system functions with them. But that’s okay. In the implementations for run, we can just print a line instead of calling the function. This way, we can prove that it compiles and runs something.


More great articles from LogRocket:


The result would look somewhat like the code below:

fn main() {
    App::new()
        .add_system(example_system)
        .add_system(another_example_system)
        .add_system(complex_example_system)
        .run();
}

fn example_system() {
    println!("foo");
}

fn another_example_system(_q: Query<&Position>) {
    println!("bar");
}

fn complex_example_system(_q: Query<&Position>, _r: ()) {
    println!("baz");
}


   Compiling playground v0.0.1 (/playground)
    Finished dev [unoptimized + debuginfo] target(s) in 0.64s
     Running `target/debug/playground`
foo
TODO: fetching params
TODO: fetching params

You can find the full code for this tutorial here. Press play, and you’ll see the output above and more. Feel free to play around with it, try some combinations of systems, and maybe add some other things!

Same pattern, different framework: Extractors in Axum

We’ve now seen how Bevy can accept quite a wide range of functions as systems. But as teased in the intro, other libraries and frameworks also use this pattern.

One example is the Axum web framework, which allows you to define handler functions for specific routes. The code below shows an example from their documentation:

async fn create_user(Json(payload): Json<CreateUser>) { todo!() }

let app = Router::new().route("/users", post(create_user));

There is a post function that accepts functions, even async ones, where all parameters are extractors, like a Json type here. As you can see, this is a bit more tricky than what we’ve seen Bevy do so far. Axum has to take into account the return type and how it can be converted, as well as supporting async functions, i.e., those that return futures.

However, the general principle is the same. The Handler trait is implemented for functions:

The Handler trait gets wrapped in a MethodRouter struct stored in a HashMap on the router. When called, FromRequest is used to extract the values of the parameters so the underlying function can be called with them. This is a spoiler for how Bevy works too! For more on how extractors in Axum work, I recommend this talk by David Pedersen.

Conclusion

In this article, we took a look at Bevy, a game engine written in Rust. We explored its ECS pattern, becoming familiar with its API and running through an example. Finally, we took a brief look at the ECS pattern in the Axum web framework, considering how it differs from Bevy.

If you want to learn more about Bevy, I recommend checking out the SystemParamFetch trait to explore fetching the parameters from a World. I hope you enjoyed this article, and be sure to leave a comment if you run into any questions or issues. Happy coding!

LogRocket: Full visibility into production Rust apps

Debugging Rust applications can be difficult, especially when users experience issues that are difficult to reproduce. If you’re interested in monitoring and tracking performance of your Rust apps, automatically surfacing errors, and tracking slow network requests and load time, try LogRocket.

LogRocket is like a DVR for web and mobile apps, recording literally everything that happens on your Rust app. Instead of guessing why problems happen, you can aggregate and report on what state your application was in when an issue occurred. LogRocket also monitors your app’s performance, reporting metrics like client CPU load, client memory usage, and more.

Modernize how you debug your Rust apps — .

Pascal Hertleif I've been involved in the Rust ecosystem since 2014, contributing to numerous projects, mainly focused on developer tooling and command line applications.

Leave a Reply