Tainted\\Coders

Bevy Code Organization

Bevy version: 0.18Last updated:

These are a collection of observations I've found while reading other Bevy projects and building my own.

You can find an example of how I like to organize my games at the Bevy Starter repo.

Don't put too much logic inside main.rs

When we run cargo new pong, cargo creates a basic project structure for us. Within the src folder there are two particularly special files:

  1. main.rs - the entrypoint for the binary executable
  2. lib.rs - the entrypoint for our reusable library code

If you have one, or both of these files, cargo build and cargo release will do different things:

// src/main.rs
use bevy::prelude::*;
use crate::plugins::*;

fn main() {
  let app = App::new().add_plugins((
    DefaultPlugins,
    GamePlugin,
    window::plugin,
  ));

  // Enable dev tools for dev builds.
  #[cfg(feature = "dev")]
  app.add_plugins((
    dev_tools::plugin,
    debug::plugin
  ));

  app.run();
}

Instead of placing any logic inside our main file, we move the responsibility to our own plugin in lib.rs called the GamePlugin.

This is what it might look like:

// src/lib.rs
use bevy::prelude::*;

mod camera;
mod debug;
mod dev_tools;
mod game;
mod input;
mod physics;
mod utils;
mod window;

pub struct GamePlugin;

impl Plugin for GamePlugin {
  fn build(&self, app: &mut App) {
    app.add_plugins((
      camera::plugin,
      physics::plugin,
      input::plugin,
      game::plugin,
    ));
  }
}

By avoiding main.rs we better separate the execution context from the rest of the game. In a test context we want our core logic but don't want to render anything.

When you are writing tests, we want to be able to spin up an app that has only the parts the test cares about. During tests you probably want to run your app in a headless mode and don't benefit from any debugging gizmos.

If DefaultPlugins was inside of your game's plugin, then in your tests you would have to manually setup each plugin individually. Your game's main plugin entrypoint should have only the minimum amount of things to get it running.

Now your tests can have a nice setup that include just what is required to run your game logic for the specific test.

#[cfg(test)]
mod tests {
    use bevy::prelude::*;

    fn setup() -> App {
        let mut app = App::new();
        app.add_plugins((MinimalPlugins, crate::AppPlugin));
        app.update();
        app
    }

    // Your tests here...
}

Generic systems

Systems can be made to be generic which lets you write more abstract pieces of functionality.

A great example is adding a system to clean up any type of component:

fn main() {
  App::new()
    .add_plugins(DefaultPlugins)
    .add_systems(OnExit(AppState::MainMenu), cleanup_system::<MenuClose>)
    .add_systems(OnExit(AppState::InGame), cleanup_system::<LevelUnload>)
}

// Type arguments on functions come after the function name, but before ordinary
// arguments. Here, the `Component` trait is a trait bound on T, our generic
type
fn cleanup_system<T: Component>(
  mut commands: Commands,
  query: Query<Entity, With<T>>
) {
  for e in &query {
    commands.entity(e).despawn_recursive();
  }
}

These types of systems can reduce duplication and allow more code reuse between disparate systems.

Custom queries

Using #[derive(QueryData)] you can build custom queries

#[derive(QueryData)]
#[query_data(derive(Debug))]
struct PlayerQuery {
  entity: Entity,
  health: &'static Health,
  ammo: &'static Ammo,
  player: &'static Player,
}

fn print_player_status(query: Query<PlayerQuery>) {
  for player in query.iter() {
    println!(
      "Player {:?} has {:?} health and {:?} ammo",
      player.entity, player.health, player.ammo
    );
  }
}

Read more about custom queries

Custom commands

We can implement our own custom commands to reduce the boilerplate of common actions.

pub struct SpawnBoid {
  pub position: Vec2,
}

impl SpawnBoid {
  pub fn random() -> Self {
    let mut rng = rand::thread_rng();
    let x = rng.gen_range(-200.0..200.0);
    let y = rng.gen_range(-200.0..200.0);
    Self {
      position: Vec2::new(x, y),
    }
  }
}

impl Command for SpawnBoid {
  fn apply(self, world: &mut World) {
    let assets = world.get_resource::<BoidAssets>();

    if let Some(assets) = assets {
      world.spawn((
        Boid,
        Transform::from_xyz(self.position.x, self.position.y, 0.),
        Mesh2d(assets.mesh.clone()),
        MeshMaterial2d(assets.material.clone()),
      ));
    }
  }
}

fn setup(mut commands: Commands) {
  for _ in 0..100 {
    commands.queue(SpawnBoid::random());
  }
}

Read more about custom commands

Use app states and run conditions

States are a great way to group up behavior that should only happen during certain parts of your game.

We can use these states to run certain systems conditionally:

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

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

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

fn main() {
  App::new()
    .add_plugins(DefaultPlugins)
    // Add our state to our app definition
    .init_state::<AppState>()
    // 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();
}

Read more about run conditions