Tainted\\Coders

Bevy Code Organization

Bevy version: 0.16Last 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 your 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:

When cargo detects the main.rs file it will compile your project as a binary crate, as opposed to a library. This means people don't have to have Rust installed to play our game, they just run the executable.

When cargo detects lib.rs it will compile your project as a library crate which produces a .rlib file that other crates (or our own) can link to and use.

If cargo detects both then you will have a binary crate in addition to your library files. The executable will be still be run-able without rust, and part of that executable can use your library files.

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

fn main() {
  let app = App::new().add_plugins((
    DefaultPlugins,
    AppPlugin,
    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 AppPlugin.

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 AppPlugin;

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

By avoiding main we better separate the execution context and library context.

In the execution context we want a window to display and the player be able to interact and play your game. In a test context we don't want any of these things.

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