Tainted \\ Coders

Building Bevy

Last updated:

This article is a personal rewriting with some substantial addition of an original tutorial.

Bevy is highly ergonomic, and coming from a language like Ruby, its interesting to explore how domain specific languages are created in more structured compile time languages like Rust.

Dependency injection

When an app is created its provided with a bunch of System which are called automatically.

These systems have many SystemParam which are what we specify in the parameters of our systems. The SystemParam each system needs are provided by the App. The App is injecting these dependencies to each System.

Dependency injection is what allows you to specify your systems as plain rust functions and declare the parameters you want injected in:

// Here we declared a `query` which is injected in by our App during a game tick
fn movement_system(query: Query<(&mut Position, &Velocity)>) {
    for (mut position, velocity) in query.iter_mut() {
        // ...
    }
}

Starting from scratch

A scheduler stores systems. We can think about them like:

struct Scheduler {
    systems: Vec<StoredSystem>,
    resources: HashMap<TypeId, Box<dyn Any>>,
}

struct StoredSystem;

One of the best features of Bevy is the fact that systems are plain rust functions:

fn hello_world_system() {
    println!("Hello world");
}

fn main() {
    App::new()
        .add_systems(Update, hello_world_system)
        .run();
}

We can imagine a simplified type signature of .add_system would be:

fn add_system<S: System>(fn: S) -> App {}

The scheduler will take these systems and try and run them in parallel to reduce the time each tick of the game takes. It does this by allowing systems that share readonly references of components to execute at the same time on different threads.

To simplify our initial scope we won’t do any borrowing types just yet. Instead we will start with just the 'static lifetime for all parameters of our systems.

Systems will be any function that takes parameters with a 'static lifetime and returns (). They will need to implement the FnMut trait as our systems will need to be passed into our application and called multiple times.

When we implment something like FnMut on a struct we are saying the System can be treated as a mutable function or closure.

A simple example of implementing FnMut would look like:

// Define a struct that implements `FnMut`
struct Counter {
    count: u32,
}

impl FnMut<()> for Counter {
    // Define the `call_mut` method required by `FnMut`
    extern "rust-call" fn call_mut(&mut self, _: ()) -> u32 {
        self.count += 1;
        self.count
    }
}

fn main() {
    let mut counter = Counter { count: 0 };

    // Call the `Counter` instance as if it were a function
    let result = counter(());

    println!("Result: {}", result); // Output: Result: 1
}

Which turns Counter the struct into something callable. Exactly the kind of behaviour we would want from our System.

A problem arises early though, rust does not allow variadic generics (generics that take a variable number of arguments). So we would have to create implementations for each number of generic arguments we would want to take:

trait System<Input> {}

// An implementation of systems that take no parameters:
impl<F: FnMut()> System<()> for F {}

// Here we implement our system with a single parameter:
impl<F: FnMut(T1), T1: 'static> System<(T1,)> for F {}

To do this in a way where we can define a single source of truth we will use a macro:

macro_rules! impl_system {
    (
        $(
            $($params:ident),+
        )?
    ) => {
        impl<
            F: FnMut(
                $( $($params),+ )?
            ) 
            $(, $($params: 'static),+ )?
        > 
        System<( 
            $( $($params,)+ )? 
        )> for F {}
    }
}

impl_system!();
impl_system!(T1);
impl_system!(T1, T2);
impl_system!(T1, T2, T3);
impl_system!(T1, T2, T3, T4);

So we can choose how many arguments we want to support using macros to define the arity of our arguments.

For our scheduler to be able to call our systems generically we would need a common interface to invoke them.

How can we have one function signature that can call any of these systems? We need to expose some way to flatten our input.

If give every system one parameter that can satisfy all of their requirements we can pass it in to every system without caring about the differences in their types.

So lets define a .run method that takes a unified resources parameter:

trait System<Input> {
    fn run(&mut self, resources: &mut HashMap<TypeId, Box<dyn Any>>);
}

Which we can then add to our macro implementation:

macro_rules! impl_system {
    (
        $(
            $($params:ident),+
        )?
    ) => {
        #[allow(non_snake_case, unused)]
        impl<
            F: FnMut(
                $( $($params),+ )?
            )
            $(, $($params: 'static),+ )?
        > 
        System<(
            $( $($params,)+ )?
        )> for F {
            fn run(&mut self, resources: &mut HashMap<TypeId, Box<dyn Any>>) {
                $($(
                    let $params = resources.remove(&TypeId::of::<$params>()).unwrap();
                )+)?

                (self)(
                    $($(params),+)?
                );
            }
        }
    }
}

So now our systems can be called without knowing their specific parameter types. Although because of our .remove call on resources we can only call each system once.

We would also like to be able to to use Box<dyn System> instead of Box<dyn Any>. One reason for this is that the std::Any type has a 'static requirement. Moving to parallel systems this way would be impossible.

However any traits we dynamically dispatch to in a Box cannot themselves be generic. So we could not do Box<dyn System<Input>>.

Every time we use a generic argument in rust like System<(T1,)> the compiler generates code specialized to the concrete type parameters you called e.g: System<(i32,)>.

Each instantiation will have their functions generated as though you handwrote the different versions. The generic implementation would inline the methods into the newly generated concrete type.

We could instead reduce this monomorphization by treating the stored type opaquely using a technique called type erasure:

trait AnimalExt {
    fn eat(&self, food: String);
}

struct Dog;

impl AnimalExt for Dog {
    fn eat(&self, food: String) {
        println!("dog: {:?}", food);
    }
}

// Instead of a generic argument we are using a smart pointer to a function.
// This way we have erased the pointer to a concrete type and are instead
// storing type-erased smart pointers (on the heap instead of the stack)
struct AnyAnimal {
    eat: Box<Fn(String)>,
}

impl AnyAnimal {
    fn new<A>(animal: A) -> Self 
    where
        A: AnimalExt + 'static,
    {
        AnyAnimal {
            eat: Box::new(move |s| animal.eat(s))
        }
    }
}

// Here we implement the trait which invokes the type-erased pointer
impl AnimalExt for AnyAnimal {
    fn eat(&self, food: String) {
        (self.eat)(food);   // ok
        // self.eat(food) <- fatal runtime error: stack overflow 
    }
}

fn main() {
    let a = AnyAnimal::new(Dog);
    a.eat("aaa".to_string());
}

Source

The idea of using a wrapper struct like AnyAnimal from the example above will also prevent inlining of the implementations of AnimalExt on any generic arguments to AnyAnimal. Its also going to provide a type-safe wrapper around it.

So now you could hold Box<AnyAnimal> instead of using Box<dyn Any>.

We could try the same thing with our own systems:

trait System<Input> {
   fn run(&mut self, resources: &mut HashMap<TypeId, Box<dyn Any>>);
}
trait ErasedSystem {
    fn run(&mut self, resources: &mut HashMap<TypeId, Box<dyn Any>>);
}

impl<S: System<I>, I> ErasedSystem for S {
    fn run(&mut self, resources: &mut HashMap<TypeId, Box<dyn Any>>) {
        <Self as System<I>>::run(self);
    }
}

However we would get a type error:

error[E0207]: the type parameter I is not constrained by the impl trait,
self type, or predicates

In Rust, when defining a generic type or function, you need to specify the constraints on the generic type parameters. These constraints limit the types we can pass as a generic argument.

In our code above we are trying to implement the ErasedSystem trait for any type S that implements the System<I> trait where I is a generic type parameter. However because we don’t have any constraints on I the compiler cannot guarantee the implementation is valid for all possible types of I.

Because our systems can implement multiple traits such as FnMut(T) or FnMut(T, U) we would need to be specific about which one I could be.

Looking back at our original system definition:

trait System<Input> {}

// An implementation of systems that take no parameters:
impl<F: FnMut()> System<()> for F {}

// Here we implement our system with a single parameter:
impl<F: FnMut(T1), T1: 'static> System<(T1,)> for F {}

Remember that I is the Input of our system, which is a tuple of one or more types, representing the arguments our system will declare and want to be fed in.

While F can implement multiple FnMut traits, if we wrap F in a struct then that struct can “select” a specific implementation. In this way the compiler is not generating variations of the implementation depending on F but is instead dynamically invoking the argument F which would erase the type requirement from our System and move it into the new struct.

The implementation the struct chooses is whichever matches the struct’s generic parameters, which only one implementation can do.

struct FunctionSystem<Input, F> {
    f: F,
    // we need a marker because otherwise we're not using `Input`.
    // fn() -> Input is chosen because just using Input would not be `Send` + `Sync`,
    // but the fnptr is always `Send` + `Sync`.
    marker: PhantomData<fn() -> Input>,
}

Now let’s move System from being on the function itself to FunctionSystem:

trait System {
    fn run(&mut self, resources: &mut HashMap<TypeId, Box<dyn Any>>);
}

macro_rules! impl_system {
    (
        $( 
            $($params:ident),+
        )?
    ) => {
        #[allow(non_snake_case, unused)]
        impl<
            F: FnMut(
                $( $($params),+ )?
            ) 
            $(, $($params: 'static),+ )?
        > System for FunctionSystem<($( $($params,)+ )?), F> {
            fn run(&mut self, resources: &mut HashMap<TypeId, Box<dyn Any>>) {
                $($(
                    let $params = *resources.remove(&TypeId::of::<$params>()).unwrap().downcast::<$params>().unwrap();
                )+)?

                (self.f)(
                    $($($params),+)?
                );
            }
        }
    }
}

Now that System takes no associated types or generic parameters, we can box it easily:

trait System {}
type StoredSystem = Box<dyn System>;

We’ll also want to be able to convert FnMut(...) to a system easily instead of manually initializing our FunctionSystem struct each time:

trait IntoSystem<Input> {
    type System: System;

    // Wrap ourself into the type of System above
    fn into_system(self) -> Self::System;
}

// Example output:
// impl<F: FnMut(T1), T1> IntoSystem<(T1,)> for F {
//     type System = FunctionSystem<(T1,), Self>;

//     fn into_system(self) -> Self::System {
//         FunctionSystem {
//             f: self,
//             marker: Default::default(),
//         }
//     }
// }

macro_rules! impl_into_system {
    (
        $($(
                $params:ident
        ),+)?
    ) => {
        impl<F: FnMut($($($params),+)?) $(, $($params: 'static),+ )?> IntoSystem<( $($($params,)+)? )> for F {
            type System = FunctionSystem<( $($($params,)+)? ), Self>;

            fn into_system(self) -> Self::System {
                FunctionSystem {
                    f: self,
                    marker: Default::default(),
                }
            }
        }
    }
}

impl_into_system!();
impl_into_system!(T1);
impl_into_system!(T1, T2);
impl_into_system!(T1, T2, T3);
impl_into_system!(T1, T2, T3, T4);

Now we can start to define the public API of our scheduler to add these systems to our game loop:

struct Scheduler {
   systems: Vec<StoredSystem>,
   resources: HashMap<TypeId, Box<dyn Any>>,
}

trait IntoSystem<Input> {
   type System: System;

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

impl Scheduler {
    pub fn run(&mut self) {
        for system in self.systems.iter_mut() {
            system.run(&mut self.resources);
        }
    }

    pub fn add_system<I, S: System + 'static>(&mut self, system: impl IntoSystem<I, System = S>) {
        self.systems.push(Box::new(system.into_system()));
    }

    pub fn add_resource<R: 'static>(&mut self, res: R) {
        self.resources.insert(TypeId::of::<R>(), Box::new(res));
    }
}

This would let us define our first real working system:

fn main() {
    let mut scheduler = Scheduler {
        systems: vec![],
        resources: HashMap::default(),
    };

    scheduler.add_system(foo);
    scheduler.add_resource(12i32);

    scheduler.run();
}

fn foo(int: i32) {
    println!("int! {int}");
}

However we still cannot use borrowed types. As it stands resources are consumed every game tick. If we lifted our maximum limit on system parameters…

We could start by changing .run to return resource references:

let $params = resources.get(&TypeId::of::<$params>()).unwrap().downcast_ref::<$params>().unwrap();

// We would also need to change our impl on IntoSystem

//  adding & here v
impl<F: FnMut($($(& $params),+)?) $(, $($params: 'static),+ )?> IntoSystem<( $($($params,)+)? )> for F { ... }

But our function definition above would fail:

error[E0277]: the trait bound fn(i32) {foo}: IntoSystem<_> is not satisfied

Which we could fix this by using only readonly references according to our new IntoSystem definition:

fn foo(int: &i32) {
    println!("int! {int}");
}

But this would again break if we introduced mutable references. Manually implementing the possible combinations would be 3^n for every n parameters we would want to support. Even with macros this would become unreasonable (by increasing the compile time and size of the program).

Instead we could try to abstract over all possible system parameters:

trait SystemParam {
    fn retrieve(resources: &mut HashMap<TypeId, Box<dyn Any>>) -> Self;
}

struct Res<'a, T: 'static> {
    value: &'a T,
}

impl<'a, T: 'static> SystemParam for Res<'a, T> {
    fn retrieve(resources: &mut HashMap<TypeId, Box<dyn Any>>) -> Self {
        let value = resources.get(&TypeId::of::<T>()).unwrap().downcast_ref::<T>().unwrap();
        Res { value }
    }
}

struct ResMut<'a, T: 'static> {
    value: &'a mut T,
}

impl<'a, T: 'static> SystemParam for ResMut<'a, T> {
    fn retrieve(resources: &mut HashMap<TypeId, Box<dyn Any>>) -> Self {
        let value = resources.get_mut(&TypeId::of::<T>()).unwrap().downcast_mut::<T>().unwrap();
        ResMut { value }
    }
}

struct ResOwned<T: 'static> {
    value: T
}

impl<T: 'static> SystemParam for ResOwned<T> {
    fn retrieve(resources: &mut HashMap<TypeId, Box<dyn Any>>) -> Self {
        let value = *resources.remove(&TypeId::of::<T>()).unwrap().downcast::<T>().unwrap();
        ResOwned { value }
    }
}

Here we are using different types to implement our non-generic trait SystemParam.

However compiling and trying to use a borrowed reference will give us:

error: lifetime may not live long enough

We could introduce lifetimes to our implementations of SystemParam but that would change the signature to:

trait SystemParam<'a> {
    fn retrieve(resources: &'a mut HashMap<TypeId, Box<dyn Any>>) -> Self;
}

Which would then require lifetimes introduced for System, IntoSystem, StoredSystem and .add_system.

But because we are mutably borrowing resources multiple times for variants with > 1 parameters, we would get a borrowing error:

error[E0499]: cannot borrow *resources as mutable more than once at a time

And implementing something like:

impl<'a, T: 'static> SystemParam<'a> for Res<'a, T> {
    fn retrieve(resources: &'a HashMap<TypeId, Box<dyn Any>>) -> Self {
        let value = resources.get(&TypeId::of::<T>()).unwrap().downcast_ref::<T>().unwrap();
        Res { value }
    }
}

pub fn add_system<I, S: for<'a> System<'a> + 'static>(&mut self, system: impl for<'a> IntoSystem<'a, I, System = S>) {
    self.systems.push(Box::new(system.into_system()));
}

Would lead to an error of:

error: implementation of System is not general enough

Because our .add_system defines the system parameter as impl for<'a> IntoSystem<'a, I, System = S> the for<'a> implies that IntoSystem<'a, I, System = S> must outlive all lifetimes, including 'static'.

As of October, 2022 the Rust community is working on these kind of lifetime problems: https://blog.rust-lang.org/2022/10/28/gats-stabilization.html

Lets have a look at what Bevy does to get around these kinds of problems:

pub unsafe trait SystemParam: Sized {
    // Used to store data which persists across invocations of a system.
    type State: Send + Sync + 'static;

    // The item type returned when constructing this system param.
    // The value of this associated type should be `Self`, instantiated with new lifetimes.
    //
    // You could think of `SystemParam::Item<'w, 's>` as being an *operation* that changes the lifetimes bound to `Self`.
    type Item<'world, 'state>: SystemParam<State = Self::State>;

    // ...
}

So SystemParam is using a GAT called Item which is itself a SystemParam, but it has a different lifetime. That means we are taking the functions lifetime and giving it the new lifetime of the passed resource.

Generic associated types (GAT) allow you to have generics (type, lifetime, or const) on associated types.

So we can use the same kind of trick for our simplified version:

trait SystemParam {
    type Item<'new>;

    fn retrieve<'r>(resources: &'r HashMap<TypeId, Box<dyn Any>>) -> Self::Item<'r>;
}

impl<'res, T: 'static> SystemParam for Res<'res, T> {
    type Item<'new> = Res<'new, T>;

    fn retrieve<'r>(resources: &'r HashMap<TypeId, Box<dyn Any>>) -> Self::Item<'r> {
        Res { value: resources.get(&TypeId::of::<T>()).unwrap().downcast_ref().unwrap() }
    }
}

macro_rules! impl_system {
    (
        $($params:ident),*
    ) => {
        #[allow(non_snake_case)]
        #[allow(unused)]
        impl<F, $($params: SystemParam),*> System for FunctionSystem<($($params,)*), F> 
            where
                for<'a, 'b> &'a mut F: 
                    FnMut( $($params),* ) + 
                    FnMut( $(<$params as SystemParam>::Item<'b>),* )
        {
            fn run(&mut self, resources: &mut HashMap<TypeId, Box<dyn Any>>) {
                // This call_inner is necessary to tell rust which function impl
                // to call
                fn call_inner<$($params),*>(
                    mut f: impl FnMut($($params),*),
                    $($params: $params),*
                ) {
                    f($($params),*)
                }

                $(
                    let $params = $params::retrieve(resources);
                )*

                call_inner(&mut self.f, $($params),*)
            }
        }
    }
}

macro_rules! impl_into_system {
    (
        $($params:ident),*
    ) => {
        impl<F, $($params: SystemParam),*> IntoSystem<($($params,)*)> for F 
            where
                for<'a, 'b> &'a mut F: 
                    FnMut( $($params),* ) + 
                    FnMut( $(<$params as SystemParam>::Item<'b>),* )
        {
            type System = FunctionSystem<($($params,)*), Self>;

            fn into_system(self) -> Self::System {
                FunctionSystem {
                    f: self,
                    marker: Default::default(),
                }
            }
        }
    }
}

Now that we have SystemParam in place, it’ll be easy to expand this to work with unlimited parameters.

We just need one crucial idea: what if a tuple of SystemParam is, itself, a SystemParam? Let’s implement:

impl<T1: SystemParam, T2: SystemParam> SystemParam for (T1, T2) {
    type Item<'new> = (T1::Item<'new>, T2::Item<'new>);

    fn retrieve<'r>(resources: &'r HashMap<TypeId, Box<dyn Any>>) -> Self::Item<'r> {
        (
            T1::retrieve(resources),
            T2::retrieve(resources),
        )
    }
}

fn foo(int: (Res<i32>, Res<u32>)) {
    println!("int! {} uint! {}", int.0.value, int.1.value);
}

Even though it looks like we have defined an implementation for a tuple of two SystemParam, the recursion actually allows us to have unlimited:

fn foo(int: (Res<One>, (Res<Two>, (Res<Three>, (Res<Four>))))) {
    // ...
}

It would however be a bit cumbersome to only allow nested tuples and a max of two positional arguments, so we should update our macro and choose exactly how many arguments to support (Bevy chose 15):

macro_rules! impl_system_param {
    (
        $($params:ident),*
    ) => {
        #[allow(unused)]
        impl<$($params: SystemParam),*> SystemParam for ($($params,)*) {
            type Item<'new> = ($($params::Item<'new>,)*);

            fn retrieve<'r>(resources: &'r HashMap<TypeId, Box<dyn Any>>) -> Self::Item<'r> {
                (
                    $($params::retrieve(resources),)*
                )
            }
        }
    }
}

impl_system_param!();
impl_system_param!(T1);
impl_system_param!(T1, T2);
impl_system_param!(T1, T2, T3);
// and so on

Read more