Out of curiosity I’ve recently started following the development of Bevy, a game engine written in Rust. Today I want to talk about how Bevy uses Rust traits to let users very conveniently label elements.

Note: The implementation we arrive at is actually very generic – you can easily apply it to any other Rust project.

How to bevy

Bevy really wants you to use its entity-component-system (ECS) architecture to structure your games. What is boils down to is writing functions (“systems”) that use queries to fetch and update components and resources. You define what you app/game is by telling Bevy which systems exists and how they might be combined.

Aside: These “systems functions” are super interesting in themselves: They are just regular Rust functions with specific parameters and through type-system magic (read: traits) Bevy knows how to call them.

Here’s a simple Bevy 0.6 app:

use bevy::prelude::*;

fn main() {
    App::new()
        .add_plugins(DefaultPlugins)
        .add_system(clock)
        .run();
}

fn clock(time: Res<Time>) {
  println!("Started {}s ago", time.seconds_since_startup());
}

Spoiler: This will spam your terminal with how long the app has been running.

Defining system relationships using labels

Bevy has a very neat scheduler that is able to run all systems that operate on disjoint data in parallel. If you want to specify that some systems have to run before others, you have to annotate this.

Here’s another, slightly more complex example. Note that to not be immediately presented with a wall of text, we have changed the add_system to add_startup_system. This means the system is only run once, at start-up.

use bevy::prelude::*;

fn main() {
    App::new()
        .add_plugins(MinimalPlugins)
        .add_startup_system(setup_world.label("world"))
        .add_startup_system(spawn_player.after("world"))
        .run();
}

fn setup_world() {
    println!("one")
}
fn spawn_player() {
    println!("two")
}

The idea is that we first setup the world with its map and then spawn the component(s) that represent our player. If you run this, you will see two lines “one”, “two”, in that order.

The important two lines of code are the where we give our system the label("world"), and where the other system can refer to that label and declare it wants to run after("world").

Aside: How does this work internally? Well, long story short, that after method turns your system function into a ParallelSystemDescriptor with metadata that the scheduler can pick up and build a graph from.

Feel free to play with this! Change it to before("world"), change the order in which the systems are added, add more systems, etc.

Imagine this: It’s a bit later in the month and we have a whole game built using dozens of systems. But for some reason the player movement seems a bit broken, like it’s rendering one frame too late. What is the issue? After two hours and too much coffee we realize1 that we wrote .after("imput").

How can we make sure that a simple typo won’t break our game again?

But our example immediately starts printing a lot of other things, and I guess in this imaginary scenario drinking all this coffee didn’t make us more alert after all.

Get me out of this stringly-typed mess

So far we’ve used strings to define and refer to labels, but if you look at the definition of the label, before, and after methods here you will see they actually accept anything that implements SystemLabel.

If you go to Bevy’s API docs you can see SystemLabel is a trait and defined as

pub trait SystemLabel: 'static + DynHash + Debug + Send + Sync { }

Look at all these bounds! You might recognize a few from usual Rust code, but DynHash stands out as one trait defined in bevy::utils. We’ll come back to it later, and just treat it as the regular Hash trait for now.

SystemLabel also looks like an empty trait – but that’s actually an illusion. Its only item is hidden in the docs. We can assume that’s because its an implementation detail, and instead of implementing this trait manually, we are supposed to derive it. Indeed, there is a SystemLabel derive macro.

Okay, so to get a type to be a SystemLabel, it needs to implement Debug, Hash (the compiler will figure 'static + Send + Sync out for us). As you might know, to derive Hash, we also need to derive PartialEq + Eq. And by experimentation and reading compiler errors, we can see that the SystemLabel derive actually also adds a requirement on Clone.

In the end we arrive at something like this:

#[derive(Debug, Clone, PartialEq, Eq, Hash, SystemLabel)]
enum Setup {
    World,
}

Which we can use just like our string previously:

.add_startup_system(setup_world.label(Setup::World))
.add_startup_system(spawn_player.after(Setup::World))

Some notes on the magic

So what is the deal with that DynHash trait? If you look at the API docs, you can see that it requires an implementation of DynEq (also from Bevy), which in turn requires an implementation of Any.

Its methods are also kind of strange: Compared to standard library’s Hash trait, there are no generics, but a lot of dyn keywords. This looks to me that someone went out of their way to make an object-safe version of Hash.

The good news is: Users of the API don’t have to care: DynHash is implemented for all types that implement Hash and DynEq, and DynEq is in turn implemented for all types that implement Eq and Any,

Another thing that seems magical is that hidden trait method on SystemLabel, which is actually called dyn_clone. Similar to the other dynamic trait implementations, this allows cloning any SystemLabel type, even if all you have is a Box<dyn SystemLabel>.

Generic label types

Did you think we were done? Oh no! There one more thing: SystemLabel is not alone! There is also StageLabel, AmbiguitySetLabel, and RunCriteriaLabel.

These label types are pretty much all the same, but distinct traits in the type system. That means you will have to explicitly derive StageLabel if you want to use your type to refer to a stage; you can’t use a SystemLabel or any other label for that.

This is another safety guarantee: We already saw that you can mess up your stringy labels by making typos, but you can also type everything correctly and still refer to a “stage label” in place of a “system label”. If you use custom types instead of strings, however, the compiler will not let you confuse them.

In true Rust fashion all of these labels are implemented using macros. The macro is called define_label and it’s used here to create all the label traits for the scheduler.

The derive macros are a bit more manual, and they live in the bevy_ecs_macros crate here.

  1. To be fair, with the LogPlugin Bevy prints a warning on start-up about an unknown label.