Bevy Code Organization
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:
main.rs
- the entrypoint for the binary executablelib.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
.
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:
// 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:
- Moving things into separate crates later on is easier
- Setting up tests is simplified (easy to spin up a new
App
)
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
.
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();
}
}
Custom queries
Using #[derive(QueryData)]
you can build custom queries
- They help to avoid destructuring or using
q.0, q.1, ...
access pattern. - Adding, removing components or changing items order with structs greatly reduces maintenance burden, as you don't need to update statements that destructure tuples, care about order of elements, etc. Instead, you can just add or remove places where a certain element is used.
- Named structs enable the composition pattern, that makes query types easier to re-use.
- You can bypass the limit of 15 components that exists for query tuples.
#[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((
BoidBundle::new(self.position.x, self.position.y),
MaterialMesh2dBundle {
mesh: assets.mesh.clone().into(),
material: assets.material.clone(),
..default()
},
));
}
}
}
fn setup(mut commands: Commands) {
for _ in 0..100 {
commands.add(SpawnBoid::random());
}
}