The Rust features that make Bevy’s systems work
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 (e.g. objects in a game world) that are made up of components. Systems process these entities and control the behavior of the application. 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 on how to use this to build your own game (e.g. in here in the Unofficial Bevy Cheat Book). Instead, this post will explain how this is implemented in Bevy itself. To do so, we’re going to 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, the last section of this post goes into more detail on how the Axum web framework uses this pattern for its route handler methods.
This post is for you if you are interested in type system tricks and are familiar with Rust. You can see it as a follow-up to my previous post on the implementation of Bevy's labels.
Note: This post uses Bevy version 0.8.
The user-facing API of Bevy’s system functions
First off, let's look at how Bevy's API is used so that we can work backward from it to recreate it ourselves. Here's a small Bevy app with an example system:
What you can see here is that we can pass a regular Rust function to add_system and Bevy knows what to do with it. Even better, our function parameters are used to tell Bevy which components we want to query: We want the Transforms from all entities that also have the custom Player component. Behind the scenes, Bevy even infers which systems can run in parallel based on the function signature.
Let’s start humble: We just want add_system
Bevy has a lot of API surface; after all it is a full game engine with a scheduling system, 2D and 3D renderer, and many other things in addition to its entity-component-system. We’re gonna ignore most of this and instead just focus on one thing: We want to add functions as systems and call them.
Following Bevy’s example, we’re gonna call the item we add the systems to App, and give it just two methods, new, and add_system:
Oh, 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 do we do that in our own code?
Add functions as systems
One of the main abstractions in Rust is traits. They are similar to interfaces or type classes in other languages. We can define a trait and then implement it for arbitrary types so that the trait’s methods become available on these types. Let’s create a System trait that allows us to run arbitrary systems:
Now we have a trait for our systems, but to implement it on functions we need to use two additional features of the type systems.
Rust type system tricks: Rust uses “traits” for abstracting over behavior. Functions implement some traits like FnMut automatically. We can implement traits for all types that fulfill a “constraint”.
Let’s use this:
If you’re not used to Rust, this might look quite unreadable. That’s okay, this is not something you see in an everyday Rust code base. You can read the first line as “Implement the system trait for all types that are functions without arguments that return nothing” and the following as “the run function takes the item itself and — since that is a function — calls it”.
This works, but is quite useless — you can only call a function without arguments. But before we go deeper into this, let’s fix up this example and make it runnable.
Interlude: Runnable example
The definition of App above was just a quick draft. To make it 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. Why? Because we can’t even know the size of what a 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. This means that instead of storing the concrete thing that implements System, you just store a pointer.
Rust type system tricks: You can use “trait objects” to store arbitrary items that implement a specific trait.
First, our App now needs to store a list of boxes that contain things that are Systems. In practice it looks like this:
Our add_system method now also needs to accept anything that implements the System trait, and then put it into that list. The argument type is now generic: We use S as a placeholder for anything that implements System; and since Rust wants us to make sure that it is a thing valid for the entirety of the program, we are also asked to add 'static. And while we’re at it, let’s also add a method to actually run the app!
With this, we can now write a small example:
You can play with the full code so far here. Now, back to the problem of having more complex system functions.
System functions with parameters
Let’s make this function a valid System:
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:
- If we add an implementation for a concrete function signature, the two implementations would conflict (code here, press run to see the with error).
- If we made the function they accept generic, it would be an “unconstrained type parameter” (code here).
We’ll need to approach this differently.
Let’s first introduce a trait for the parameters we accept:
To distinguish the different System implementations, we can add type parameters, which become part of its signature:
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, we’d have to add one there, too:
(By the way: If you make all instances System<()> and comment out the .add_system(another_example_system), this compiles.)
Storing generic systems
Our challenge is now this — get all three:
- We need to have a generic trait that knows its parameters.
- We need to store generic systems in a list.
- We need to be able to call these systems when iterating over them.
This is a good place to look at Bevy’s code. When you start digging in, you’ll see:
- Functions do not implement System, but SystemParamFunction!
- add_system does not take an impl System, but an impl IntoSystemDescriptor. This in turn uses a IntoSystem trait.
- And actually, the thing that does implement System is FunctionSystem, a struct.
Let’s take inspiration from that and make our System trait simple again. The code from above gets to continue on as a new trait called SystemParamFunction. We’ll also introduce an IntoSystem trait which our add_system function will accept:
Rust type system tricks: 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 struct FunctionSystem that will implement System and we’ll add an IntoSystem implementation that creates it:
(As you can see, SystemParamFunction is the generic trait we called System in the last chapter.)
Note: 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:
Now we’re almost ready! Let’s update our add_system function and then we can see how this all works:
Our function now accepts everything that imple
Discussion in the ATmosphere