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:
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;
}
}
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 Transform
s 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
:
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?
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 trait
s. 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:
trait System {
fn run(&mut self);
}
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:
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 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 System
s. In practice it looks like this:
struct App {
systems: Vec<Box<dyn System>>,
}
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!
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:
fn main() {
App::new()
.add_system(example_system)
.run();
}
fn example_system() {
println!("foo");
}
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
:
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:
- 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:
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
…
(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
, butSystemParamFunction
! add_system
does not take animpl System
, but animpl IntoSystemDescriptor
. This in turn uses aIntoSystem
trait.- And actually, the thing that does implement
System
isFunctionSystem
, 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:
trait IntoSystem<Params> {
type Output: System;
fn into_system(self) -> Self::Output;
}
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:
/// 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);
}
(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:
/// 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);
}
}
Now 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 themselves system parameters:
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 the same as earlier:
struct App {
systems: Vec<Box<dyn System>>,
}
But now it works! How?
Boxing up our generics
The trick is that we’re now storing a generic FunctionSystem
as a trait object. That means 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 System
trait for this instance of the type.
Rust type system tricks: When using generic functions and data types, the compiler will “monomorphize” them to generate code for the types that are actually used. That also means that if you use the same generic function with three different concrete types, it will be compiled three times.
This means that we have all three now: We have our trait implemented for generic functions, we store a generic System
box, and we still call run
on it.
Fetching parameters
Sadly, this 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.
The result would look somewhat like this:
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 from this post here — press play and you’ll see this output (and some more). Feel free to play with it, try some combinations of systems, and maybe add some other things!
We’ll end this post here. Maybe in a follow-up, we’ll talk all about fetching the parameters from a World
. For now, if you want to look at how Bevy does it, check out the SystemParamFetch
trait.
Bonus: 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. This is 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).
But the general principle is the same:
- The
Handler
trait is implemented for functions- whose parameters implement
FromRequest
and - whose return type implements
IntoResponse
.
- whose parameters implement
- It gets wrapped in a
MethodRouter
struct - and 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, have a look at this talk by David Pedersen.
This post was originally written by Pascal Hertleif. Help and review on the Bevy Discord by Joy and Logic was much appreciated.