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!
add_system
methodFirst 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
methodBevy 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?
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.
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.
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:
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.
Now, our challenge is to achieve the following criteria:
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.
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.
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.
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!
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:
FromRequest
IntoResponse
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.
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!
Debugging Rust applications can be difficult, especially when users experience issues that are hard to reproduce. If you’re interested in monitoring and tracking the 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 application. 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 — start monitoring for free.
Would you be interested in joining LogRocket's developer community?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowBuild scalable admin dashboards with Filament and Laravel using Form Builder, Notifications, and Actions for clean, interactive panels.
Break down the parts of a URL and explore APIs for working with them in JavaScript, parsing them, building query strings, checking their validity, etc.
In this guide, explore lazy loading and error loading as two techniques for fetching data in React apps.
Deno is a popular JavaScript runtime, and it recently launched version 2.0 with several new features, bug fixes, and improvements […]