Tainted\\Coders

Bevy Code Organization

Bevy version: 0.15Last 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 it detects both then you will have a binary crate in addition to your library files. The executable will be runnable without rust, and part of that executable can use your library files.

Here is what a god tier main.rs looks like for Bevy:

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

use starter::AppPlugin;

fn main() {
  App::new().add_plugins(AppPlugin).run();
}

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

// 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((
      window::plugin,
      camera::plugin,
      physics::plugin,
      input::plugin,
      game::plugin,
    ));

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

Doing it this way allows us to write plugins without as much boilerplate:

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

#[derive(Component)]
pub struct MainCamera;

pub(super) fn plugin(app: &mut App) {
  app.add_systems(Startup, initialize_camera);
}

fn initialize_camera(mut commands: Commands) {
  commands.spawn((Camera2dBundle::default(), MainCamera));
}

Avoiding overloading main means we separate execution and library contexts. This leads to some easy wins:

Parent and child relationships

Entities can hold parent/child relationships to each other using special components.

Most commonly, these hierarchies are used for inheriting Transform values from the Parent to its Children.

Children will set their position and visibility according to their parents, so these relationships can be great at organizing behavior together.

Read more about bevy hierarchy

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