Building a Space Sim in Bevy - Part 2: Proper Project Structure
Organizing our Bevy game with plugins, modules, game states, and system sets for clean, maintainable architecture.
Building a Space Sim in Bevy - Part 2: Proper Project Structure
In Part 1, we got a ship flying around. It felt great! But look at our code - everything is crammed into main.rs. That’s fine for a demo, but as we add features (stations, NPCs, combat, trading…), this approach will collapse into spaghetti.
Today we’re going to fix that. We’ll learn about Bevy plugins, game states, and system sets. By the end, our code will be organized, maintainable, and ready to grow.
Why Structure Matters
I’ve made the mistake of “I’ll organize it later” in game projects before. Spoiler: later never comes, and eventually you’re afraid to touch anything because everything depends on everything else.
Bevy actually makes good architecture easier than bad architecture, once you understand the patterns. Let’s learn them now while our project is still small.
The Plugin Pattern
Bevy’s answer to code organization is plugins. A plugin is a self-contained unit that groups related components, resources, and systems. Think of it like a mini-application that you plug into your main app.
Here’s what a plugin looks like:
pub struct ShipPlugin;
impl Plugin for ShipPlugin {
fn build(&self, app: &mut App) {
app.add_systems(Startup, spawn_player_ship)
.add_systems(Update, (
handle_input,
apply_thrust,
apply_velocity,
).chain());
}
} Then in main.rs, you just do:
app.add_plugins(ShipPlugin) Beautiful, right? All the ship-related code lives together, and main.rs stays clean.
Our Project Structure
Here’s how I’m organizing the code:
src/
├── main.rs # Entry point - just plugin registration
├── lib.rs # Public exports
├── states/
│ └── mod.rs # GameState enum
├── core/
│ ├── mod.rs
│ ├── plugin.rs # CorePlugin (camera, etc.)
│ └── camera.rs # Camera components and systems
└── ship/
├── mod.rs
├── plugin.rs # ShipPlugin
├── components.rs # Ship, Velocity, Engine, etc.
└── systems.rs # Movement systems Each feature gets its own folder with a consistent structure: mod.rs for exports, plugin.rs for the plugin definition, components.rs for data, and systems.rs for behavior.
Game States
Games have different modes. You might be on a menu, playing, paused, or in a cutscene. Bevy has a built-in state system to handle this cleanly.
Let’s define our states:
// src/states/mod.rs
use bevy::prelude::*;
#[derive(States, Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum GameState {
#[default]
Loading,
MainMenu,
Playing,
Paused,
} The #[default] attribute means we start in Loading. The various derives make it work with Bevy’s state system.
Now here’s the powerful part - we can gate systems to only run in specific states:
app.add_systems(Update,
handle_input.run_if(in_state(GameState::Playing))
); This means handle_input only runs when we’re in the Playing state. No more checking state manually in every system!
System Sets: Taming Complexity
Here’s a problem I ran into quickly: as you add more systems, figuring out their order becomes a nightmare. You start adding .after(this_system).before(that_system) everywhere, and it becomes impossible to understand.
Bevy’s solution is System Sets - named groups that run in a defined order. Instead of ordering individual systems, you order groups:
// src/ship/plugin.rs
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub enum ShipSet {
Input, // Read player input
Physics, // Convert input to velocity
Movement, // Apply velocity to position
Boundaries, // Keep ships in bounds
} Now we configure the sets to run in order:
impl Plugin for ShipPlugin {
fn build(&self, app: &mut App) {
// Configure set ordering - these run in sequence
app.configure_sets(Update, (
ShipSet::Input,
ShipSet::Physics,
ShipSet::Movement,
ShipSet::Boundaries,
).chain().run_if(in_state(GameState::Playing)));
// Add systems to their sets
app.add_systems(Update, (
handle_input.in_set(ShipSet::Input),
apply_thrust.in_set(ShipSet::Physics),
apply_velocity.in_set(ShipSet::Movement),
wrap_position.in_set(ShipSet::Boundaries),
));
}
} The magic is that other plugins can now depend on these sets! For example, when we add stations later:
// In StationPlugin
app.configure_sets(Update,
StationSet::Detection.after(ShipSet::Movement)
); Station detection runs after ships have moved. No need to know about individual systems - we just depend on the set.
Module Organization
Let me show you how I organize each module. It’s repetitive, but that consistency is valuable:
// src/ship/mod.rs
mod components;
mod plugin;
mod systems;
pub use components::*;
pub use plugin::{ShipPlugin, ShipSet}; // src/ship/components.rs
use bevy::prelude::*;
#[derive(Component)]
pub struct Ship;
#[derive(Component)]
pub struct Player;
#[derive(Component, Default)]
pub struct Velocity {
pub linear: Vec2,
pub angular: f32,
}
#[derive(Component)]
pub struct Engine {
pub thrust: f32,
pub rotation_speed: f32,
}
#[derive(Component, Default)]
pub struct ThrustInput {
pub forward: f32,
pub rotation: f32,
} // src/ship/systems.rs
use bevy::prelude::*;
use super::components::*;
pub fn handle_input(/* ... */) { /* ... */ }
pub fn apply_thrust(/* ... */) { /* ... */ }
pub fn apply_velocity(/* ... */) { /* ... */ } Idempotent Spawning
Here’s a gotcha that bit me: when states can be re-entered (MainMenu → Playing → MainMenu → Playing), spawning systems might run multiple times. You don’t want two player ships!
The fix is simple - check if the entity already exists:
pub fn spawn_player_ship(
mut commands: Commands,
existing: Query<Entity, With<Player>>,
) {
// Don't spawn if player already exists!
if !existing.is_empty() {
return;
}
commands.spawn((
Ship,
Player,
Velocity::default(),
Engine {
thrust: 200.0,
rotation_speed: 5.0,
},
ThrustInput::default(),
Sprite {
color: Color::srgb(0.2, 0.6, 1.0),
custom_size: Some(Vec2::new(30.0, 40.0)),
..default()
},
));
} This pattern - checking before spawning - comes up constantly. I call it “idempotent spawning” because you can call the function multiple times and it does the right thing.
A Smooth-Following Camera
While we’re organizing, let’s upgrade our camera. A static camera is boring - we want it to smoothly follow the player:
// src/core/camera.rs
use bevy::prelude::*;
#[derive(Component)]
pub struct MainCamera;
#[derive(Component)]
pub struct CameraTarget;
pub fn spawn_camera(mut commands: Commands) {
commands.spawn((
Camera2d::default(),
MainCamera,
));
}
pub fn camera_follow(
target: Query<&Transform, (With<CameraTarget>, Without<MainCamera>)>,
mut camera: Query<&mut Transform, With<MainCamera>>,
) {
let Ok(target_transform) = target.get_single() else { return };
let Ok(mut camera_transform) = camera.get_single_mut() else { return };
// Smooth lerp toward target
let target_pos = target_transform.translation.truncate();
let camera_pos = camera_transform.translation.truncate();
let new_pos = camera_pos.lerp(target_pos, 0.1); // 0.1 = smoothing factor
camera_transform.translation.x = new_pos.x;
camera_transform.translation.y = new_pos.y;
} The lerp (linear interpolation) with factor 0.1 creates smooth following. The camera moves 10% of the remaining distance each frame, giving that nice “elastic” feel.
Just add CameraTarget to the player ship:
commands.spawn((
Ship,
Player,
CameraTarget, // Add this!
// ... rest of components
)); World Boundaries
Let’s keep ships from flying into infinity. For now, we’ll use simple wrapping - fly off one edge, appear on the other:
const WORLD_HALF_SIZE: f32 = 2000.0;
pub fn wrap_position(mut query: Query<&mut Transform, With<Ship>>) {
for mut transform in query.iter_mut() {
let pos = &mut transform.translation;
if pos.x > WORLD_HALF_SIZE {
pos.x = -WORLD_HALF_SIZE;
} else if pos.x < -WORLD_HALF_SIZE {
pos.x = WORLD_HALF_SIZE;
}
if pos.y > WORLD_HALF_SIZE {
pos.y = -WORLD_HALF_SIZE;
} else if pos.y < -WORLD_HALF_SIZE {
pos.y = WORLD_HALF_SIZE;
}
}
} Later we’ll replace this with soft boundaries that push ships back, but wrapping is fine for now.
The Clean Main.rs
After all this organization, look how clean our entry point becomes:
// src/main.rs
use bevy::prelude::*;
use space_sim::{
GameState,
CorePlugin,
ShipPlugin,
};
fn main() {
App::new()
.add_plugins(DefaultPlugins.set(WindowPlugin {
primary_window: Some(Window {
title: "Space Sim".into(),
resolution: (1280., 720.).into(),
..default()
}),
..default()
}))
.init_state::<GameState>()
.add_plugins((
CorePlugin,
ShipPlugin,
))
// Skip menu for now, go straight to Playing
.add_systems(Startup, |mut next: ResMut<NextState<GameState>>| {
next.set(GameState::Playing);
})
.run();
} That’s it! All the actual game code is in plugins. Adding a new feature means creating a new plugin and adding one line here.
Library Exports
Finally, we expose our public API:
// src/lib.rs
pub mod states;
pub mod core;
pub mod ship;
pub use states::GameState;
pub use core::CorePlugin;
pub use ship::{ShipPlugin, ShipSet}; Why This Structure Wins
Encapsulation: Each feature is self-contained. The ship module doesn’t know or care about stations.
Testability: We can test plugins in isolation. Spawn just the ship plugin, verify it works.
Maintainability: Need to fix ship physics? Look in
ship/systems.rs. Need to add a ship component? It goes inship/components.rs.Extensibility: New features are just new plugins. They slot right in.
Clear dependencies: System sets make ordering explicit and centralized.
What We Learned
- Plugins: Self-contained feature bundles
- Game States: Different modes with automatic system gating
- System Sets: Named phases for clear execution order
- Module Organization: Consistent structure for each feature
- Idempotent Spawning: Safe handling of state re-entry
What’s Next
We have structure! But space is empty and boring. In Part 3, we’ll add a beautiful parallax starfield that creates an illusion of depth and makes our void feel alive.
The stars are calling!