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
.
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
- They help to avoid destructuring or using
x.0, x.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((
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());
}
}