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 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:
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:
main.rswill be compiled into an executable binary.lib.rswill be compiled into a library file.main.rsandlib.rswill both be compiled if they both exist.
// 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
- 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());
}
}
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();
}