Tainted\\Coders

Bevy Apps

Bevy version: 0.19Last updated:

The App is the object we use to define our games. It is the public API we use to schedule our systems, configure plugins and define our game loop.

use bevy::prelude::*;

fn main() {
  App::new()
    .add_plugins(DefaultPlugins)
    .add_systems(Startup, setup_everything)
    .add_systems(Update, move_player)
    .run();
}

Each call to App returns itself so we can chain method calls together to write a declarative configuration of our game.

We always call run to start the main game loop after the rest of our game's definition. We also almost always add DefaultPlugins to our app which is a collection of core plugins that add things like rendering and game input.

The main game loop

Every App has a runner which advances the game loop by calling App::update.

Bevy knows which systems to run and when by advancing through each of the Schedule we have added to our Schedules resource.

Each Schedule is advanced by the App::runner. This runner can be overwritten to fine tune the behavior of our game loop.

All these schedules exist inside our World, each loop they run any systems that have been added to them when its their turn.

The 3 most important schedules we usually add to are:

  1. Startup which runs once at the start of the app
  2. Update which runs every frame
  3. FixedUpdate which runs after a fixed time interval has passed

The Main schedule defines the ordering of the schedules you will most likely be adding systems to.

With the Main schedule, during our first run the startup schedules will fire:

`StateTransition` ->
`PreStartup` ->
`Startup` ->
`PostStartup` ->

Then the rest of the schedules will run in a loop until we exit the app:

v-----------------------<
`First` ->              |
`PreUpdate` ->          |
`StateTransition` ->    |
`RunFixedMainLoop` ->   |
`Update` ->             |
`SpawnScene` ->
`PostUpdate` ->         |
`Last` -----------------^

The StateTransition schedule is only present when you use states. Bevy inserts it before PreStartup on the first run and between PreUpdate and RunFixedMainLoop every run after that.

The RunFixedMainLoop schedule runs the FixedMain schedules:

v-----------------------<
`FixedFirst` ->         |
`FixedPreUpdate` ->     |
`FixedUpdate` ->        |
`FixedPostUpdate` ->    |
`FixedLast` ------------^

These will run zero or more times depending on how much time has passed since the last time.

Defining an app

We define our app in main.rs which will be executed whenever we cargo run. Cargo compiles our code into a binary in the target/debug folder and runs it.

use bevy::prelude::*;

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

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

Each method for an App will return itself which lets us chain them together. The App has a very wide API and is responsible for calling the other parts of Bevy underneath.

Some commonly used methods on App are:

We added the DefaultPlugins which is a collection of core plugins that allow your game to render on a window provided by your operating system and other necessities of your game. Almost all games will want this unless you want the minimalism of selecting your own features from Bevy to keep your size smaller.

Plugins are how Bevy lets us split up features into discrete units we can add to our App. They take a &mut App and can initialize resources or schedule systems to add in their behavior.

Running apps

When we run an App we are actually calling its runner function which will start your loop and begin advancing through our schedules.

// https://github.com/bevyengine/bevy/blob/v0.19.0/crates/bevy_app/src/app.rs#L85
pub struct App {
  pub(crate) sub_apps: SubApps,
  pub(crate) runner: RunnerFn,
  fallback_error_handler: Option<ErrorHandler>,
}

The actual default runner is to run_once and then exit. Not very useful. However, after adding DefaultPlugins the default runner becomes an infinite loop.

With DefaultPlugins the runner will be the winit event loop which creates a window and processes events from the operating system.

If we really wanted to customize it we can do so through the ScheduleRunnerPlugin, but this only works if you're using MinimalPlugins instead of DefaultPlugins. The two runners are mutually exclusive: when bevy_window is enabled DefaultPlugins uses the winit event loop and leaves ScheduleRunnerPlugin out entirely.

Let's try making our app only run once:

use bevy::{app::ScheduleRunnerPlugin, prelude::*};

fn main() {
  App::new()
    // This app will run once because we changed the `RunnerFn`
    .add_plugins(MinimalPlugins.set(ScheduleRunnerPlugin::run_once()))
    .add_systems(Update, hello_world_system)
    .run();
}

fn hello_world_system() {
  info!("hello world");
}

Or we can force our app to run at a certain frequency:

use bevy::{app::ScheduleRunnerPlugin, prelude::*};
use std::time::Duration;

fn main() {
  App::new()
    .add_plugins(
      // This app will run 60 times per second
      MinimalPlugins.set(ScheduleRunnerPlugin::run_loop(
        Duration::from_secs_f64(1.0 / 60.0),
      )),
    )
    .add_systems(Update, hello_world_system)
    .run();
}

fn hello_world_system() {
  info!("hello world");
}

We can even provide our own custom runner function if the default doesn't suit our game loop.

For example, we can create a simple terminal based app that reads our input and prints it back to us:

use bevy::{app::PluginsState, prelude::*};

#[derive(Resource, Default)]
struct Input(String);

fn my_runner(mut app: App) -> AppExit {
  if app.plugins_state() != PluginsState::Cleaned {
    app.finish();
    app.cleanup();
  }

  println!("Type stuff into the console");
  for line in std::io::stdin().lines() {
    {
      let mut input = app.world_mut().resource_mut::<Input>();
      input.0 = line.unwrap();
    }
    app.update();
  }

  AppExit::Success
}

fn print_input(input: Res<Input>) {
  if !input.0.is_empty() {
    println!("You typed: {}", input.0);
  }
}

fn main() {
  App::new()
    .add_plugins(MinimalPlugins)
    .init_resource::<Input>()
    .set_runner(my_runner)
    .add_systems(Update, print_input)
    .run();
}

Typing things into the console will now print them back out:

Type stuff into the console
Hello world
You typed: Hello world

Schedules

A Schedule is a collection metadata, systems, and the executor responsible for running them. These are stored inside your World in the Schedules resource.

The Schedules resource is basically a HashMap<ScheduleLabel, Schedule>. When we add_systems to a schedule we are adding them to this hash via the ScheduleLabel we provide.

Your App will call Schedule::run on each schedule you have added. The schedule is responsible for passing your World around to be mutated by your systems.

The 3 schedules you will interact with the most are:

  1. Main holds all of our game logic
  2. Extract moves data from the main world to the render world
  3. Render renders everything

The Main schedule is actually just a system that calls other schedules in a linear order. Those other schedules are what we add our systems to get a specific order of behaviour. The Extract and Render schedules are added by the render plugins (bevy_render) and run inside the render SubApp, so they only exist when the default render pipeline is enabled.

This is how nesting systems works. A Schedule can contain a system which calls other schedules via World::run_schedule. There is no parent/child relationship between schedules and systems.

We usually only add stuff to the Main controlled schedules. The Extract and Render schedules are only used for graphics processing.

To schedule a system we would use add_systems and provide a specific ScheduleLabel to tell Bevy when to run it inside the Main schedule:

fn main() {
  let mut app = App::new()
    .add_systems(Update, hello_world);
}

FixedUpdate vs Update schedules

There is a common confusion about whether to use FixedUpdate vs Update for systems.

In general, you should put physics and game logic that needs to be deterministic in FixedUpdate. Things that react to the current frame, like input handling, UI and audio, belong in Update. As a rule of thumb, if you are moving a physics body or advancing a simulation, use FixedUpdate. If you are rendering, use Update.

To see why we can visualize the difference between the two schedules which update at different frequencies:

Update:       -X-X-X-X-X-X-X-X-X-X
FixedUpdate:  -----X-----X-----X--

If we pack all our logic into Update we are forcing all our logic to run every single frame. With a FixedUpdate we can control the frequency of our game's logic without modifying our game's frame rate.

A better idea is to separate the simulation of your game from the rendering so they can happen independently of each other.

This also comes into play with updating physics systems. Avian runs its logic inside a FixedUpdate. So if we are calculating velocities and changing our position inside the Update schedule we are likely doing so with slightly older data. This can sometimes lead to subtle bugs usually manifesting as jittering.

Interestingly, RunFixedMainLoop is implemented to be independent of the number of times our loop runs. Instead, it will run the schedule FixedMain only when a certain amount of time has passed.

Here is a sketch of what this fixed time update looks like conceptually:

#[derive(Resource)]
struct FixedTimestepState {
  accumulator: f64,
  step: f64,
}

fn fixed_timestep_system(world: &mut World) {
  world.resource_scope(|world, mut state: Mut<FixedTimestepState>| {
    let time = world.resource::<Time>();
    state.accumulator += time.delta_secs_f64();
    while state.accumulator >= state.step {
      world.run_schedule(FixedUpdate);
      state.accumulator -= state.step;
    }
  });
}

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

This is only a sketch: don't add this system to a real app. Bevy already runs RunFixedMainLoop as part of the Main schedule, so adding this system to Update alongside DefaultPlugins would run FixedUpdate twice per frame.

This also means that when writing tests, your systems added to the FixedMain schedules (like FixedUpdate) won't run until enough time has passed. You can control the time in tests with TimeUpdateStrategy and app.update():

#[cfg(test)]
mod tests {
  use super::*;
  use bevy::time::{Fixed, Time, TimePlugin, TimeUpdateStrategy};
  use std::time::Duration;

  #[derive(Resource, Default)]
  struct FixedUpdateCounter(u32);

  fn count_fixed_updates(mut counter: ResMut<FixedUpdateCounter>) {
    counter.0 += 1;
  }

  #[test]
  fn fixed_update_waits_for_accumulated_time() {
    // Half a fixed timestep plus a bit: two updates will accumulate just
    // over one timestep, so the third update is when the fixed schedule runs.
    let fixed_update_timestep = Time::<Fixed>::default().timestep();
    let time_step = fixed_update_timestep / 2 + Duration::from_millis(1);

    let mut app = App::new();
    app.add_plugins(TimePlugin)
      .add_systems(FixedUpdate, count_fixed_updates)
      .init_resource::<FixedUpdateCounter>()
      .insert_resource(TimeUpdateStrategy::ManualDuration(time_step));

    app.update();
    assert_eq!(
      app.world().resource::<FixedUpdateCounter>().0,
      0,
      "Not enough time has accumulated for the fixed timestep to run"
    );

    app.update();
    assert_eq!(
      app.world().resource::<FixedUpdateCounter>().0,
      0,
      "Still not enough time has accumulated"
    );

    app.update();
    assert_eq!(
      app.world().resource::<FixedUpdateCounter>().0,
      1,
      "Enough time has accumulated for the fixed timestep to run"
    );
  }
}

App states

The App is acting like a finite state machine and our logic triggers the transitions which move us from one state to another.

We can create custom states for our app that let us hook into this state machine with our own specific logic.

Creating app states

States in Bevy are any enum or struct that implements the States trait.

#[derive(Debug, Clone, Eq, PartialEq, Hash, Default, States)]
enum AppState {
  #[default]
  MainMenu,
  InGame,
  Paused,
}

fn main() {
  App::new()
    .add_plugins(DefaultPlugins)
    // Add our state to our app definition
    .init_state::<AppState>()
    .init_resource::<Ui>()
    .add_systems(Startup, setup)
    .add_observer(on_menu_button_pressed)
    // We can add systems to trigger during transitions
    .add_systems(OnEnter(AppState::MainMenu), spawn_menu)
    .add_systems(OnExit(AppState::MainMenu), despawn_menu)
    // Or we can use run conditions
    .add_systems(Update, play_game.run_if(in_state(AppState::InGame)))
    .add_systems(Update, toggle_game_pause)
    .run();
}

When you call App::init_state<S>:

  1. Bevy will add a resource for both State<S> and NextState<S> to your app.
  2. The StatesPlugin (included in DefaultPlugins) adds the StateTransition schedule and the systems that handle transitioning between states.

The StateTransition schedule runs as part of the Main schedule, so if you use states without DefaultPlugins you need to add StatesPlugin (bevy::state::app::StatesPlugin) yourself or init_state will panic.

Transitioning between states

We transition from one state to another by calling NextState::set(S) in any of our systems.

// When we click the "Start game" button we transition
// the app to the next state
fn on_menu_button_pressed(
  event: On<Pointer<Click>>,
  ui: Res<Ui>,
  mut next_state: ResMut<NextState<AppState>>,
) {
  let Some(menu_entity) = ui.menu else {
    return;
  };

  if event.event().entity == menu_entity {
    info!("Start Game button pressed");
    next_state.set(AppState::InGame);
  }
}

Your NextState<S> is an enum that can be in one of three states:

  1. NextState::Pending(s): The next state has been triggered and will transition
  2. NextState::Unchanged: The next state has not been triggered
  3. NextState::PendingIfNeq(s): The next state has been triggered and will transition if it is not equal to the current state

Bevy runs the OnExit(old) schedule, updates the current State<S>, then runs the OnEnter(new) schedule.

You are allowed to transition back to the state you are already on. set always runs the transition, so calling set(current_state) will run both OnEnter and OnExit again. If you want to skip the transition schedules when the state hasn't changed, use set_if_neq (or NextState::PendingIfNeq) instead.

If we wanted to create explicit transitions we could implement the logic on our state:

impl AppState {
  fn next(&self) -> Self {
    match *self {
      AppState::MainMenu => AppState::InGame,
      AppState::InGame => AppState::Paused,
      AppState::Paused => AppState::InGame,
    }
  }
}

That way the logic for toggling between states becomes much easier to reason about:

fn toggle_game_pause(
  mut next_state: ResMut<NextState<AppState>>,
  current_state: Res<State<AppState>>,
  input: Res<ButtonInput<KeyCode>>,
) {
  if input.just_pressed(KeyCode::Escape) {
    next_state.set(current_state.next());
  }
}

Sub-apps

Apps can have a SubApp added to them. This sub app has its own World and the two (or more) can run consecutively.

They are a tool for isolating data between separate processes and are almost exclusively used for Bevy's rendering pipeline.

Sub apps are not that nice to use yet. Their intention is to enable multi world apps. You can see a more complete discussion of multi world here.

#[derive(AppLabel, Clone, Copy, Hash, PartialEq, Eq, Debug)]
struct MySubApp;

fn main() {
  let mut app = App::new();
  app.insert_sub_app(MySubApp, SubApp::new());
  app.run();
}

Each SubApp contains its own Schedules and World which are separate from your main App. Note that a SubApp doesn't run anything by default: it will only run its systems if we set its update_schedule and add the matching schedules to its World.

To understand them a bit more, we can create a somewhat impractical but educational example.

Lets say we were making a game where we had separate chunks of our game we wanted to process completely separately and then sync with the main game world.

First we could define some kind of "chunks" that have a certain state we want to control, like changing their color:

#[derive(Default, Clone, Debug)]
enum ChunkState {
  Red,
  Green,
  #[default]
  Blue,
}

#[derive(Resource, Default, Clone)]
struct Chunk {
  id: u32,
  state: ChunkState,
}

#[derive(Resource)]
struct Chunks(HashMap<u32, Chunk>);

Then we can create a plugin that creates and inserts a sub app on our main app. Remember that this sub app will run consecutively after our main app, not in parallel. In the plugin we add the MainSchedulePlugin to the sub app and point its update_schedule at Main so our systems actually run.

#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, AppLabel)]
pub struct ChunkApp;

fn update_chunks(mut chunks: ResMut<Chunks>) {
  for chunk in chunks.0.values_mut() {
    match chunk.state {
      ChunkState::Red => chunk.state = ChunkState::Green,
      ChunkState::Green => chunk.state = ChunkState::Blue,
      ChunkState::Blue => chunk.state = ChunkState::Red,
    }
  }
}

struct ChunksPlugin;

impl Plugin for ChunksPlugin {
  fn build(&self, app: &mut App) {
    let mut sub_app = SubApp::new();

    sub_app
      .add_plugins(MainSchedulePlugin)
      .insert_resource(Chunk::default())
      .add_systems(Update, update_chunks);

    sub_app.update_schedule = Some(Main.intern());

    sub_app.set_extract(|main_world, sub_world| {
      let mut chunks = main_world.resource_mut::<Chunks>();
      let chunk = sub_world.resource::<Chunk>();
      chunks.0.insert(chunk.id, chunk.clone());
    });

    app.insert_sub_app(ChunkApp, sub_app);
  }
}

For a more complete example with more performance concerns you can check out pipelined_rendering.rs in bevy/crates/bevy_render which uses async.

Multithreading

Apps by default will run on multiple threads. The Scheduler is working hard to try and run your systems in parallel when they have disjoint sets of queries. Systems are run on the ComputeTaskPool, which is one of the task pools set up by the TaskPoolPlugin.

We can configure this behavior by changing the TaskPoolOptions of the TaskPoolPlugin:

use bevy::prelude::*;

fn main() {
  App::new()
    .add_plugins(DefaultPlugins.set(TaskPoolPlugin {
      task_pool_options: TaskPoolOptions::with_num_threads(4),
    }))
    .run();
}

Running headless apps

If you want to run your app without spawning a window or using any rendering systems, and with the minimum amount of resources, we can use MinimalPlugins instead of the DefaultPlugins we normally add.

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

This can be useful for writing and running tests that include various plugins from your game but don't need to be displayed on the screen.

If instead you wanted most other systems to run like Bevy's assets, scenes, etc but not render to your screen you could configure the DefaultPlugins to do so:

use bevy::{
  prelude::*,
  render::{
    RenderPlugin,
    settings::{RenderCreation, WgpuSettings},
  },
};

fn main() {
  App::new()
    .add_plugins(DefaultPlugins.set(RenderPlugin {
      synchronous_pipeline_compilation: true,
      render_creation: RenderCreation::Automatic(Box::new(WgpuSettings {
        backends: None,
        ..default()
      })),
      ..default()
    }))
    .run();
}