Tainted\\Coders

Bevy Systems

Bevy version: 0.14Last updated:

Systems are rust functions that take special parameters that implement SystemParam. We specify the system parameters we want and Bevy uses some type magic to figure out how to provide them.

Rust has no variadics, so this is achieved via macros that implement traits for functions with a number of supported arguments (in bevy up to 15).

A rust function (or lambda) can be automatically converted to a System via the IntoSystem trait:

fn hello_world() {
  println!("Hello, world!");
}

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

Resources, commands, and 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 App runs your systems, it automatically figures out the right way to call your function given your specific parameters.

Bevy also uses the data access information of our types to determine which systems run in parallel.

Scheduling systems

When systems are added to our App they are added to a particular Schedule. These schedules contain the rules of when each system should run over the course of each frame.

By defaut, Bevy is trying to schedule all systems that don't need mutable access to the same data to run in parallel. This is all in an effort to speed up our game.

To schedule a system we call add_systems and specify the schedule and the system(s) we want to run:

fn main() {
  App::new()
    .add_plugins(DefaultPlugins)
    .add_systems(Update, hello_world);
}

Each call to add_systems can take a tuple of systems:

fn main() {
  App::new()
    .add_plugins(DefaultPlugins)
    .add_systems(Update, (defend, attack));
}

Or, if we need fine grain control over ordering, we can use Bevy's built-in methods like before and after:

fn main() {
  App::new()
    .add_plugins(DefaultPlugins)
    .add_systems(Update, (defend, attack.after(defend)));
}

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.

Ordering can be controlled with:

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:

  1. 'w for data stored in the World
  2. 's for data stored in a parameter's local state

Some common SystemParam are:

System parameterDescription
ResA reference to a resource
ResMutA mutable reference to a resource
LocalA local system variable that persists between invocations of the system
DeferredA param that stores a buffer which gets applied to a World during an apply_system_buffers call
NonSend/NonSendMutA shared borrow of a non Send resource, systems taking these are forced onto the main thread to avoid sending these resources between threads
SystemChangeTickReads 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.
QueryA query for resources, components or entities
CommandsThe main interface for scheduling commands to run
EventReaderAn interface for reading events of a particular type
EventWriterAn interface for writing events of a particular type
&WorldA reference to the current World
ArchetypesMetadata about archetypes
BundlesMetadata about bundles
ComponentsMetadata about components
EntitiesMetadata 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...
  }
}

Custom system parameters

We can create our own system parameters by deriving the trait on a struct. The only thing we have to be careful of is the two lifetimes mentioned earlier.

// 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

Systems can be locally stateful by using the Local<T> system parameter:

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!();
}

The local counter variable will keep its state between invocations of the function.

Combining systems

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

This should be used in combination with ParamSet to avoid SystemParam collisions.

fn main() {
  App::new()
    .add_plugins(DefaultPlugins)
    .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),
      ),
    );
}