Tainted \\ Coders

Bevy Systems

Last updated:

Bevy systems are often quoted as being “simple rust functions”. This magic is achieved via lots and lots of type magic. Basically, implementing traits over functions.

Rust has no variadics, so this is achieved via macros that generate separate impl code for functions with a number of supported arguments (in bevy up to 15). And then each of the arguments also implements traits.

A system is usually written as a normal function (or lambda), which is automatically converted into a System. Here is an example of doing it manually using IntoSystem on our function:

fn sys(query: Query<&A>) {
    for a in &query {
        println!("{a:?}");
    }
}

let mut system = IntoSystem::into_system(sys);

The system can then be called on a particular World. Resources, Commands, Queries, all describe ways of fetching data to and from the ECS. So your parameters are a declarative way of interacting with your game World.

When your World runs your system it automatically figures out the right way to call your function given your specific parameters by turning your Fn into a System.

let mut world = World::new();
world.spawn(A);

system.initialize(&mut world);
system.run((), &mut world);

Or we can set our system to run with a Schedule on many World’s:

let mut schedule = Schedule::default();
schedule.add_systems(system);;
schedule.run(world);

System functions can have parameters, through which one can query and mutate Bevy ECS state.

Bevy uses function parameter types to determine data that needs to be sent to the system. It also uses data access information to determine which systems run in parallel.

We can see how it does so by manually creating our SystemState:

// Work directly on the `World`
let mut world = World::new();
world.init_resource::<Events<MyEvent>>();

// Construct a `SystemState` struct, passing in a tuple of `SystemParam`
// as if you were writing an ordinary system.
let mut system_state: SystemState<(
    EventWriter<MyEvent>,
    Option<ResMut<MyResource>>,
    Query<&MyComponent>,
)> = SystemState::new(&mut world);

// Use system_state.get_mut(&mut world) and unpack your system parameters into variables!
// system_state.get(&world) provides read-only versions of your system parameters instead.
let (event_writer, maybe_resource, query) = system_state.get_mut(&mut world);

// If you are using [`Commands`], you can choose when you want to apply them to the world.
// You need to manually call `.apply(world)` on the [`SystemState`] to apply them.

We can conveniently add systems to our app definition:

App::new()
    .add_systems(Update, (
        (attack, defend).in_set(Combat).before(check_health)
        check_health,
        (handle_death, respawn).after(check_health)
    ));

Instead of a nested tuple with before and after we can use chain to flatten our structure and run everything in the defined order:

App::new()
    .add_systems(
        Update,
        (
            (attack, defend).in_set(Combat)
            check_health,
            (handle_death, respawn)
        ).chain();
)

Before 0.11, there was 6 ways to initialize our systems which caused a lot of confusion. But its since been condensed into the nice ergonomics of a single add_systems call.

One thing to note is that just having parameters and using them conditionally won’t block parallel execution:

fn system_a(mut commands: Commands) {
    if(random_bool()) {
        commands.spawn_empty();
    }
}

fn system_b(mut commands: Commands) {
    if(random_bool()) {
        commands.spawn_empty();
    }
}

There is a chance these two systems run in parallel. The overhead of the mutable borrow depends on whether or not we call it.

Ordering

By default systems run in parallel with each other and their order is non-deterministic.

Normal systems cannot safely access the World instance directly because they run in parallel. Our World contains all of our components, so mutating arbitrary parts of it in parallel is not thread safe.

Systems are added to a Schedule which are then run and data is fetched from the World automatically according to the system’s SystemParam’s.

Ordering can be controlled with:

  • The core sets like Update, PostUpdate, FixedUpdate, etc..
  • by calling the .before(this_system) or .after(that_system) methods when adding them to your schedule
  • by adding them to a SystemSet, and then using .configure_set(ThisSet.before(ThatSet)) syntax to configure many systems at once
  • through the use of .add_systems(Update, (system_a, system_b, system_c).chain())
  • by calling .in_schedule
  • by calling .on_startup

System params and param sets

System params will automatically fetch data from a World without interacting directly with the World struct.

Systems take SystemParam trait parameters. SystemParam structs have two lifetimes: 'w for data stored in the World and 's for data stored in a parameter’s state.

Some common SystemParam are:

Res A reference to a resource
ResMut A mutable reference to a resource
Local A local system variable that persists between invocations of the system
Deferred A param that stores a buffer which gets applied to a World during an apply_system_buffers call
NonSend / NonSendMut A shared borrow of a non Send resource, systems taking these are forced onto the main thread to avoid sending these resources between threads
SystemChangeTick Reads the previous and current change ticks containing a last_run and this_run each holding a Tick which can be used to check the time the system has been run at
Query A query for resources or components
Commands The main interface for scheduling commands to run
EventReader An interface for reading events of a particular type
EventWriter An interface for writing events of a particular type
&World A reference to the current World
Archetypes Metadata about archetypes
Bundles Metadata about bundles
Components Metadata about components
Entities Metadata about entities

A ParamSet is a collection of potentially conflicting SystemParam’s. It allows systems to safely access and interact with up to 8 mutually exclusive params. For example: two queries that reference the same mutable data or an event reader and writer of the same type.

We can access the params of a ParamSet with p0, p1, etc according to the order they were defined in the type.

A ParamSet can take any SystemParam.

ParamSet can be used when mutably accessing the same component twice in one system:

// This will panic at runtime when the system gets initialized.
fn bad_system(
    mut enemies: Query<&mut Health, With<Enemy>>,
    mut allies: Query<&mut Health, With<Ally>>,
) {
    // ...
}

Instead ParamSet leverages the borrow checker to ensure that only one of the contained parameters are accessed at a given time.

fn good_system(
    mut set: ParamSet<(
        Query<&mut Health, With<Enemy>>,
        Query<&mut Health, With<Ally>>,
    )>
) {
    // This will access the first `SystemParam`.
    for mut health in set.p0().iter_mut() {
        // Do your fancy stuff here...
    }
    // The second `SystemParam`.
    // This would fail to compile if the previous parameter was still borrowed.
    for mut health in set.p1().iter_mut() {
        // Do even fancier stuff here...
    }
}

You can create your own system parameters:

// The [`SystemParam`] struct can contain any types that can also be included in a
// system function signature.
//
// In this example, it includes a query and a mutable resource.
#[derive(SystemParam)]
struct PlayerCounter<'w, 's> {
    players: Query<'w, 's, &'static Player>,
    count: ResMut<'w, PlayerCount>,
}

impl<'w, 's> PlayerCounter<'w, 's> {
    fn count(&mut self) {
        self.count.0 = self.players.iter().len();
    }
}

// The [`SystemParam`] can be used directly in a system argument.
fn count_players(mut counter: PlayerCounter) {
    counter.count();

    println!("{} players in the game", counter.count.0);
}

System state

They can be stateful by using the Local<T> as a type for an argument.

fn print_at_end_round(mut counter: Local<u32>) {
    *counter += 1;
    println!("In set 'Last' for the {}th time", *counter);
    // Print an empty line between rounds
    println!();
}

Combining systems

Higher order systems can even be composed of many other systems using the pipe method:

Should be used in combination with ParamSet to avoid SystemParam collisions.

.add_systems(
    Update,
    (
        parse_message_system.pipe(handler_system),
        data_pipe_system.pipe(info),
        parse_message_system.pipe(debug),
        warning_pipe_system.pipe(warn),
        parse_error_message_system.pipe(error),
        parse_message_system.pipe(ignore),
    )
)