Bevy / Rust
22 min read

Building a Space Sim in Bevy - Part 1: Project Setup & Bevy Fundamentals

Starting our journey into 2D space game development with Bevy 0.17. We set up the project, learn ECS basics, and create a movable ship.

Share:

Building a Space Sim in Bevy - Part 1: Project Setup & Bevy Fundamentals

I’ve always wanted to build a space trading game. Something like a 2D version of Elite or Freelancer - where you can fly around, watch NPC traders go about their business, and get into the occasional dogfight. After exploring various game engines, I settled on Bevy. It’s Rust-native, has an elegant ECS architecture, and the community is fantastic.

This is the first part of a series where we’ll build this game together, from scratch. By the end, we’ll have:

  • Flyable ships with realistic physics
  • Faction-owned space stations
  • NPC traders with AI behavior and docking
  • Combat with weapons and damage systems
  • A living economy with supply and demand

But let’s not get ahead of ourselves. Today, we’re starting with the absolute basics: getting a ship on screen and making it move.

Setting Up the Project

First, let’s create our project:

cargo new space_sim
cd space_sim

Now open Cargo.toml and add Bevy. I’m using version 0.17, which has some nice improvements over previous versions:

[package]
name = "space_sim"
version = "0.1.0"
edition = "2024"

[dependencies]
bevy = { version = "0.17", features = ["dynamic_linking"] }

# These settings speed up development builds significantly
[profile.dev]
opt-level = 1

[profile.dev.package."*"]
opt-level = 3

That dynamic_linking feature is a lifesaver during development. Without it, every small change triggers a full recompile of Bevy, which takes forever. With it, rebuilds are much faster.

Understanding ECS (The “Aha!” Moment)

Before we write more code, let me explain Bevy’s architecture. It uses something called Entity-Component-System (ECS), which was confusing to me at first coming from object-oriented programming.

In traditional OOP, you might have a Ship class with properties and methods:

// This is NOT how Bevy works
class Ship {
    position: Vec2,
    velocity: Vec2,
    fn update() { ... }
}

ECS flips this around:

  • Entities are just unique IDs. Think of them as empty containers.
  • Components are pure data - no methods, just fields. You attach them to entities.
  • Systems are functions that operate on entities with specific components.

So instead of a Ship class, we have:

  • An entity (just an ID)
  • A Position component attached to it
  • A Velocity component attached to it
  • A movement_system that finds all entities with both Position and Velocity, and updates their positions

Why does this matter? Because it’s incredibly flexible. Want to add velocity to a bullet? Just attach the Velocity component - the same movement system handles it automatically. Want some ships to be invulnerable? Just don’t give them a Health component. The systems only affect entities that have the right components.

Our First Window

Let’s get something on screen. Replace the contents of src/main.rs:

use bevy::prelude::*;

fn main() {
    App::new()
        .add_plugins(DefaultPlugins.set(WindowPlugin {
            primary_window: Some(Window {
                title: "Space Sim".into(),
                resolution: (1280., 720.).into(),
                ..default()
            }),
            ..default()
        }))
        .run();
}

Run it with cargo run. You should see a blank gray window. Not exciting, but it proves everything is working!

Let me break down what’s happening:

  • App::new() creates a new Bevy application
  • DefaultPlugins adds all the standard stuff: windowing, rendering, input, etc.
  • We customize the WindowPlugin to set our window title and size
  • .run() starts the game loop

Adding a Camera and Ship

For 2D games, we need a camera to see anything. Let’s add one, along with a colored rectangle to represent our ship:

use bevy::prelude::*;

fn main() {
    App::new()
        .add_plugins(DefaultPlugins.set(WindowPlugin {
            primary_window: Some(Window {
                title: "Space Sim".into(),
                resolution: (1280., 720.).into(),
                ..default()
            }),
            ..default()
        }))
        .add_systems(Startup, setup)
        .run();
}

fn setup(mut commands: Commands) {
    // Every 2D game needs a camera
    commands.spawn(Camera2d::default());

    // Our ship - just a blue rectangle for now
    commands.spawn(Sprite {
        color: Color::srgb(0.2, 0.6, 1.0),
        custom_size: Some(Vec2::new(30.0, 40.0)),
        ..default()
    });
}

A few things to note:

  • add_systems(Startup, setup) tells Bevy to run our setup function once when the game starts
  • commands is how we create and modify entities
  • commands.spawn(...) creates a new entity with the given components
  • Sprite is a built-in component that renders a 2D image (or colored rectangle in our case)

Run it again, and you’ll see a blue rectangle in the center of the screen. That’s our ship!

Defining Our Ship Components

Now here’s where ECS starts to shine. Let’s think about what data our ship needs:

  • Some way to identify it as a ship (so other systems can find it)
  • Velocity (how fast it’s moving)
  • Engine properties (how powerful the thrusters are)
  • Current input state (is the player pressing forward?)

Each of these becomes a component:

/// Marker component - entities with this are ships
#[derive(Component)]
pub struct Ship;

/// Marker for the player-controlled ship
#[derive(Component)]
pub struct Player;

/// How fast we're moving and rotating
#[derive(Component, Default)]
pub struct Velocity {
    pub linear: Vec2,
    pub angular: f32,
}

/// Our engine's capabilities
#[derive(Component)]
pub struct Engine {
    pub thrust: f32,
    pub rotation_speed: f32,
}

/// Current thrust input (updated by input system, read by physics)
#[derive(Component, Default)]
pub struct ThrustInput {
    pub forward: f32,    // -1.0 to 1.0
    pub rotation: f32,   // -1.0 to 1.0
}

Notice how Ship and Player are empty structs - they’re just markers. This is a common pattern in ECS. We can query for “all entities that have the Ship component” without Ship needing to contain any data.

Now let’s update our spawn code to include all these components:

fn setup(mut commands: Commands) {
    commands.spawn(Camera2d::default());

    // Spawn our player ship with all its components
    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()
        },
    ));
}

The tuple syntax (Component1, Component2, ...) spawns an entity with multiple components at once.

Reading Input

Now let’s make it move! First, we need a system to read keyboard input:

fn handle_input(
    keyboard: Res<ButtonInput<KeyCode>>,
    mut query: Query<&mut ThrustInput, With<Player>>,
) {
    // Try to get the player's ThrustInput component
    let Ok(mut input) = query.get_single_mut() else {
        return; // No player found, nothing to do
    };

    // Reset each frame - we only thrust while keys are held
    input.forward = 0.0;
    input.rotation = 0.0;

    // Forward/backward
    if keyboard.pressed(KeyCode::KeyW) || keyboard.pressed(KeyCode::ArrowUp) {
        input.forward += 1.0;
    }
    if keyboard.pressed(KeyCode::KeyS) || keyboard.pressed(KeyCode::ArrowDown) {
        input.forward -= 0.5; // Reverse is weaker, feels more realistic
    }

    // Rotation
    if keyboard.pressed(KeyCode::KeyA) || keyboard.pressed(KeyCode::ArrowLeft) {
        input.rotation += 1.0;
    }
    if keyboard.pressed(KeyCode::KeyD) || keyboard.pressed(KeyCode::ArrowRight) {
        input.rotation -= 1.0;
    }
}

Let me explain the function signature:

  • Res<ButtonInput<KeyCode>> gives us read access to the keyboard state
  • Query<&mut ThrustInput, With<Player>> finds entities that have both ThrustInput AND Player, giving us mutable access to the ThrustInput

The With<Player> filter is important - without it, we’d get the ThrustInput of every ship, not just the player’s.

Physics Systems

Now we need to convert that input into actual movement. I’m splitting this into two systems:

  1. apply_thrust - Converts input into velocity changes
  2. apply_velocity - Applies velocity to position

Why split them? Because later we’ll have NPC ships that don’t use player input but still need velocity applied. By separating concerns, we can reuse apply_velocity for everything that moves.

fn apply_thrust(
    time: Res<Time>,
    mut query: Query<(&ThrustInput, &Engine, &mut Velocity, &Transform)>,
) {
    for (input, engine, mut velocity, transform) in query.iter_mut() {
        let dt = time.delta_secs();

        // Get the direction the ship is facing
        // Bevy's 2D sprites face +Y by default
        let forward = transform.rotation * Vec3::Y;
        let forward_2d = Vec2::new(forward.x, forward.y);

        // Apply thrust in the forward direction
        velocity.linear += forward_2d * input.forward * engine.thrust * dt;

        // Set rotation speed (not cumulative like linear velocity)
        velocity.angular = input.rotation * engine.rotation_speed;

        // Apply some drag so we don't accelerate forever
        // This gives a nice "drifty" space feel
        velocity.linear *= 0.99;
    }
}

fn apply_velocity(
    time: Res<Time>,
    mut query: Query<(&Velocity, &mut Transform)>,
) {
    let dt = time.delta_secs();

    for (velocity, mut transform) in query.iter_mut() {
        // Update position
        transform.translation.x += velocity.linear.x * dt;
        transform.translation.y += velocity.linear.y * dt;

        // Update rotation
        transform.rotate_z(velocity.angular * dt);
    }
}

The time.delta_secs() gives us the time since the last frame. Multiplying by this makes our physics frame-rate independent - the ship moves the same speed whether you’re running at 30fps or 144fps.

Putting It All Together

Finally, let’s register our systems:

fn main() {
    App::new()
        .add_plugins(DefaultPlugins.set(WindowPlugin {
            primary_window: Some(Window {
                title: "Space Sim".into(),
                resolution: (1280., 720.).into(),
                ..default()
            }),
            ..default()
        }))
        .add_systems(Startup, setup)
        .add_systems(Update, (
            handle_input,
            apply_thrust,
            apply_velocity,
        ).chain())
        .run();
}

That .chain() is important! It tells Bevy to run these systems in order. Without it, Bevy might run them in parallel or in any order, which could cause weird bugs (imagine applying velocity before applying thrust).

Try It Out!

Run cargo run and use WASD or arrow keys to fly around. You should feel:

  • Gradual acceleration when thrusting
  • A floaty, drifty feel from the drag
  • Smooth rotation
  • The ship keeps moving even after you release the thrust key

That’s the space ship feeling we’re going for!

What We Learned

  • Bevy’s ECS: Entities are IDs, components are data, systems are behavior
  • Marker components: Empty structs like Ship and Player for filtering
  • Queries: How to find and access entities with specific components
  • System ordering: Using .chain() to ensure deterministic execution
  • Delta time: Making physics frame-rate independent

What’s Next

Our ship flies beautifully… and then off into infinite nothingness. We need to organize our code better and add some boundaries to the world. In Part 2, we’ll restructure into proper modules and plugins, add game states, and keep our ship from escaping into the void.

See you there!