Bevy / Rust
23 min read

Building a Space Sim in Bevy - Part 9: RTS-Style Camera Control

Adding free camera movement, ship selection, follow mode, and piloting controls for a hybrid RTS/simulation experience.

Share:

Building a Space Sim in Bevy - Part 9: RTS-Style Camera Control

We have a problem. A good problem, but still a problem.

Our simulation is getting interesting. NPC traders are flying around, making smart decisions, building wealth for their factions. But we can only see one small piece of it. We’re stuck piloting one ship, unable to zoom out and watch the bigger picture.

What I really want is an RTS-style camera. Pan around freely, zoom out to see the whole sector, click on ships to follow them, and still be able to take control when I want to.

Let’s build that.

The Three Modes

I’ve designed three control modes that cover everything we need:

  1. FreeCam: Pan with WASD, zoom with scroll wheel, edge scroll with mouse
  2. Following: Camera tracks a ship, but the ship flies itself
  3. Piloting: You take direct control of a ship

These modes are orthogonal to game state. Whether the game is paused or playing, you can be in any control mode. This is important - we don’t want camera controls to break during pause menus or other state changes.

Let’s define the resource:

// src/control/components.rs
use bevy::prelude::*;

/// Control mode - orthogonal to GameState
#[derive(Resource, Default, Clone, Copy, PartialEq, Eq, Debug)]
pub enum ControlMode {
    /// Camera moves freely via WASD and mouse edge scrolling
    #[default]
    FreeCam,

    /// Camera follows a ship but player doesn't control it
    Following(Entity),

    /// Player is piloting the ship directly
    Piloting(Entity),
}

The Following and Piloting variants hold an Entity - the ship we’re focused on. Having both modes lets us observe an NPC’s trading behavior (Following) without interfering, or take the helm ourselves (Piloting).

Let’s add some helper methods:

impl ControlMode {
    pub fn is_piloting(&self) -> bool {
        matches!(self, ControlMode::Piloting(_))
    }

    pub fn is_free(&self) -> bool {
        matches!(self, ControlMode::FreeCam)
    }

    pub fn controlled_entity(&self) -> Option<Entity> {
        match self {
            ControlMode::Piloting(e) => Some(*e),
            _ => None,
        }
    }

    pub fn followed_entity(&self) -> Option<Entity> {
        match self {
            ControlMode::Following(e) | ControlMode::Piloting(e) => Some(*e),
            ControlMode::FreeCam => None,
        }
    }
}

Notice that followed_entity returns Some for both Following and Piloting - in both cases, the camera should track the ship.

The Selection System

Before we can follow or pilot a ship, we need to select it. This means click detection.

First, some marker components:

/// Marker for entities that can be selected
#[derive(Component, Default)]
pub struct Selectable;

/// Marker for currently selected entity
#[derive(Component)]
pub struct Selected;

/// Visual feedback for selection (we'll spawn this as a child)
#[derive(Component)]
pub struct SelectionIndicator;

The pattern here: Selectable is added to ships when they spawn. When clicked, we add Selected to mark them. The SelectionIndicator is a visual child entity (a circle or ring) that makes it obvious which ship is selected.

For double-click detection, we need some state:

#[derive(Resource, Default)]
pub struct ClickState {
    pub last_click_time: f32,
    pub last_click_entity: Option<Entity>,
}

pub const DOUBLE_CLICK_THRESHOLD: f32 = 0.3;  // 300ms
pub const CLICK_RADIUS: f32 = 30.0;  // How close to a ship counts as clicking it

The Free Camera Entity

Here’s a trick that makes the code cleaner: we create a separate entity for the free camera position. The actual camera then follows either this entity OR a ship, depending on mode.

/// Position entity for free camera mode
#[derive(Component)]
pub struct FreeCamera;

pub fn spawn_free_camera(mut commands: Commands) {
    commands.spawn((
        FreeCamera,
        Transform::default(),
    ));
}

This invisible entity just holds a position. When in FreeCam mode, WASD moves this entity, and the camera smoothly follows it. Simple and clean.

Let’s add configuration for camera behavior:

#[derive(Resource)]
pub struct FreeCameraConfig {
    pub move_speed: f32,
    pub edge_scroll_enabled: bool,
    pub edge_scroll_margin: f32,
    pub edge_scroll_speed: f32,
}

impl Default for FreeCameraConfig {
    fn default() -> Self {
        Self {
            move_speed: 500.0,
            edge_scroll_enabled: true,
            edge_scroll_margin: 50.0,  // Pixels from edge
            edge_scroll_speed: 400.0,
        }
    }
}

#[derive(Resource)]
pub struct CameraZoomConfig {
    pub min_zoom: f32,   // 0.5 = 2x magnification
    pub max_zoom: f32,   // 5.0 = zoomed out to 1/5 size
    pub zoom_speed: f32,
    pub scale_movement_with_zoom: bool,
}

impl Default for CameraZoomConfig {
    fn default() -> Self {
        Self {
            min_zoom: 0.5,
            max_zoom: 5.0,
            zoom_speed: 0.1,
            scale_movement_with_zoom: true,  // Important!
        }
    }
}

That scale_movement_with_zoom option is crucial. When zoomed out, you’re looking at a much larger area. If movement speed stays constant, panning feels sluggish. By scaling speed with zoom level, panning feels consistent regardless of zoom.

Free Camera Movement

Now let’s make WASD move the free camera:

pub fn free_camera_movement(
    mode: Res<ControlMode>,
    keyboard: Res<ButtonInput<KeyCode>>,
    time: Res<Time>,
    config: Res<FreeCameraConfig>,
    zoom_config: Res<CameraZoomConfig>,
    camera: Query<&OrthographicProjection, With<MainCamera>>,
    mut free_cam: Query<&mut Transform, With<FreeCamera>>,
) {
    // Only run in FreeCam mode
    if !mode.is_free() { return; }

    let Ok(mut transform) = free_cam.get_single_mut() else { return };
    let projection = camera.get_single().ok();

    let dt = time.delta_secs();
    let mut speed = config.move_speed;

    // Scale speed with zoom level for consistent feel
    if zoom_config.scale_movement_with_zoom {
        if let Some(proj) = projection {
            speed *= proj.scale;  // proj.scale is the zoom level
        }
    }

    // Gather input
    let mut movement = Vec2::ZERO;

    if keyboard.pressed(KeyCode::KeyW) || keyboard.pressed(KeyCode::ArrowUp) {
        movement.y += 1.0;
    }
    if keyboard.pressed(KeyCode::KeyS) || keyboard.pressed(KeyCode::ArrowDown) {
        movement.y -= 1.0;
    }
    if keyboard.pressed(KeyCode::KeyA) || keyboard.pressed(KeyCode::ArrowLeft) {
        movement.x -= 1.0;
    }
    if keyboard.pressed(KeyCode::KeyD) || keyboard.pressed(KeyCode::ArrowRight) {
        movement.x += 1.0;
    }

    // Apply movement
    if movement != Vec2::ZERO {
        movement = movement.normalize() * speed * dt;
        transform.translation.x += movement.x;
        transform.translation.y += movement.y;
    }
}

I support both WASD and arrow keys because… why not? Some people prefer arrows. The normalize() call ensures diagonal movement isn’t faster than cardinal movement.

Edge Scrolling

RTS games often scroll when you push the mouse to the screen edge. It feels natural once you’re used to it:

pub fn edge_scroll(
    mode: Res<ControlMode>,
    windows: Query<&Window>,
    config: Res<FreeCameraConfig>,
    time: Res<Time>,
    mut free_cam: Query<&mut Transform, With<FreeCamera>>,
) {
    if !mode.is_free() || !config.edge_scroll_enabled { return; }

    let Ok(window) = windows.get_single() else { return };
    let Some(cursor_pos) = window.cursor_position() else { return };
    let Ok(mut transform) = free_cam.get_single_mut() else { return };

    let margin = config.edge_scroll_margin;
    let speed = config.edge_scroll_speed * time.delta_secs();

    let mut movement = Vec2::ZERO;

    // Check each edge
    if cursor_pos.x < margin {
        movement.x -= speed;
    } else if cursor_pos.x > window.width() - margin {
        movement.x += speed;
    }

    if cursor_pos.y < margin {
        movement.y += speed;  // Y is inverted in screen space
    } else if cursor_pos.y > window.height() - margin {
        movement.y -= speed;
    }

    transform.translation.x += movement.x;
    transform.translation.y += movement.y;
}

Note the Y inversion comment. Screen coordinates have Y=0 at the top, but our world has Y=0 at the bottom. I always have to think twice about this.

Mouse Wheel Zoom

Scrolling the mouse wheel should zoom in and out:

pub fn camera_zoom(
    mut scroll: EventReader<MouseWheel>,
    config: Res<CameraZoomConfig>,
    mut camera: Query<&mut OrthographicProjection, With<MainCamera>>,
) {
    let Ok(mut projection) = camera.get_single_mut() else { return };

    for event in scroll.read() {
        // Negative because scroll up should zoom in (smaller scale)
        let zoom_delta = -event.y * config.zoom_speed;
        projection.scale = (projection.scale + zoom_delta)
            .clamp(config.min_zoom, config.max_zoom);
    }
}

In Bevy’s OrthographicProjection, scale controls zoom. A scale of 1.0 is normal, 0.5 is zoomed in 2x, and 2.0 is zoomed out 2x. We clamp to prevent zooming too far in either direction.

Click Selection

Now the fun part - clicking on ships. This requires converting screen coordinates to world coordinates:

pub fn handle_clicks(
    mut commands: Commands,
    mouse: Res<ButtonInput<MouseButton>>,
    windows: Query<&Window>,
    camera: Query<(&Camera, &GlobalTransform), With<MainCamera>>,
    selectables: Query<(Entity, &Transform), With<Selectable>>,
    selected: Query<Entity, With<Selected>>,
    mut click_state: ResMut<ClickState>,
    mut mode: ResMut<ControlMode>,
    time: Res<Time>,
) {
    if !mouse.just_pressed(MouseButton::Left) { return; }

    let Ok(window) = windows.get_single() else { return };
    let Ok((camera, camera_transform)) = camera.get_single() else { return };
    let Some(cursor_pos) = window.cursor_position() else { return };

    // Convert screen position to world position
    // This is the magic that makes clicking work with zoom and pan
    let Ok(world_pos) = camera.viewport_to_world_2d(camera_transform, cursor_pos) else {
        return;
    };

    // Find what we clicked
    let clicked = selectables.iter()
        .find(|(_, transform)| {
            transform.translation.truncate().distance(world_pos) < CLICK_RADIUS
        })
        .map(|(entity, _)| entity);

    let now = time.elapsed_secs();

    if let Some(entity) = clicked {
        // Did we double-click the same entity?
        let is_double_click = click_state.last_click_entity == Some(entity)
            && now - click_state.last_click_time < DOUBLE_CLICK_THRESHOLD;

        if is_double_click {
            // Double-click: start following
            *mode = ControlMode::Following(entity);
        } else {
            // Single click: just select
            // First, deselect anything already selected
            for prev in selected.iter() {
                commands.entity(prev).remove::<Selected>();
            }
            commands.entity(entity).insert(Selected);
        }

        // Update click state for double-click detection
        click_state.last_click_time = now;
        click_state.last_click_entity = Some(entity);
    } else {
        // Clicked empty space
        for prev in selected.iter() {
            commands.entity(prev).remove::<Selected>();
        }
        *mode = ControlMode::FreeCam;
        click_state.last_click_entity = None;
    }
}

The viewport_to_world_2d method is doing the heavy lifting here. It takes a screen position and gives us the corresponding world position, accounting for camera zoom, position, everything. Without this, clicking would be nearly impossible to get right.

The double-click logic is straightforward: if we click the same entity within 300ms, it’s a double-click.

Piloting with Enter/Escape

Selected a ship? Press Enter to take control:

pub fn handle_enter_pilot(
    keyboard: Res<ButtonInput<KeyCode>>,
    selected: Query<Entity, With<Selected>>,
    mut mode: ResMut<ControlMode>,
) {
    if keyboard.just_pressed(KeyCode::Enter) {
        if let Ok(entity) = selected.get_single() {
            *mode = ControlMode::Piloting(entity);
        }
    }
}

pub fn handle_escape_pilot(
    keyboard: Res<ButtonInput<KeyCode>>,
    mut mode: ResMut<ControlMode>,
) {
    if keyboard.just_pressed(KeyCode::Escape) {
        if mode.is_piloting() {
            *mode = ControlMode::FreeCam;
        }
    }
}

When you enter Piloting mode, your ship movement systems should check ControlMode and only respond to input when piloting that specific entity.

Camera Following

Finally, we need the camera to actually follow its target. This runs in PostUpdate, after all movement is done:

pub fn update_camera_position(
    mode: Res<ControlMode>,
    free_cam: Query<&Transform, (With<FreeCamera>, Without<MainCamera>)>,
    targets: Query<&Transform, (With<Ship>, Without<MainCamera>, Without<FreeCamera>)>,
    mut camera: Query<&mut Transform, With<MainCamera>>,
) {
    let Ok(mut camera_transform) = camera.get_single_mut() else { return };

    // Determine target based on mode
    let target_pos = match *mode {
        ControlMode::FreeCam => {
            // Follow the free camera entity
            free_cam.get_single().ok().map(|t| t.translation.truncate())
        }
        ControlMode::Following(entity) | ControlMode::Piloting(entity) => {
            // Follow the ship
            targets.get(entity).ok().map(|t| t.translation.truncate())
        }
    };

    if let Some(target) = target_pos {
        // Smooth follow with lerp
        let current = camera_transform.translation.truncate();
        let new_pos = current.lerp(target, 0.1);  // 10% per frame
        camera_transform.translation.x = new_pos.x;
        camera_transform.translation.y = new_pos.y;
    }
}

The lerp gives us smooth following instead of jarring snaps. At 10% per frame, the camera quickly catches up but never stutters.

Those Without<> filters in the queries are important. Without them, Bevy might complain about conflicting borrows - we can’t have the same entity in two queries that both want to write transforms.

Selection Indicator

It’s hard to tell what’s selected without visual feedback. Let’s spawn a ring around selected ships:

pub fn update_selection_indicator(
    mut commands: Commands,
    selected: Query<Entity, Added<Selected>>,
    deselected: Query<Entity, Without<Selected>>,
    indicators: Query<(Entity, &Parent), With<SelectionIndicator>>,
) {
    // Add indicator to newly selected entities
    for entity in selected.iter() {
        commands.entity(entity).with_children(|parent| {
            parent.spawn((
                SelectionIndicator,
                Sprite {
                    color: Color::srgba(0.2, 0.8, 0.2, 0.6),  // Green, semi-transparent
                    custom_size: Some(Vec2::splat(40.0)),  // Larger than ship
                    ..default()
                },
                Transform::from_xyz(0.0, 0.0, -0.1),  // Slightly behind ship
            ));
        });
    }

    // Remove indicators from entities that lost selection
    for (indicator, parent) in indicators.iter() {
        if deselected.contains(parent.get()) {
            commands.entity(indicator).despawn();
        }
    }
}

The Added<Selected> filter only catches entities that just got the Selected component this frame. This prevents spawning duplicate indicators.

Wiring It All Up

Here’s the plugin that registers everything:

#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub enum ControlSet {
    Input,
    Selection,
    ModeTransition,
    CameraUpdate,
}

impl Plugin for ControlPlugin {
    fn build(&self, app: &mut App) {
        app.init_resource::<ControlMode>()
           .init_resource::<FreeCameraConfig>()
           .init_resource::<CameraZoomConfig>()
           .init_resource::<ClickState>()
           // Input and selection in Update
           .configure_sets(Update, (
               ControlSet::Input,
               ControlSet::Selection,
               ControlSet::ModeTransition,
           ).chain())
           // Camera position in PostUpdate (after movement)
           .configure_sets(PostUpdate, ControlSet::CameraUpdate)
           .add_systems(Startup, spawn_free_camera)
           .add_systems(Update, (
               free_camera_movement.in_set(ControlSet::Input),
               edge_scroll.in_set(ControlSet::Input),
               camera_zoom.in_set(ControlSet::Input),
               handle_clicks.in_set(ControlSet::Selection),
               handle_enter_pilot.in_set(ControlSet::Selection),
               handle_escape_pilot.in_set(ControlSet::ModeTransition),
               update_selection_indicator.in_set(ControlSet::ModeTransition),
           ))
           .add_systems(PostUpdate,
               update_camera_position.in_set(ControlSet::CameraUpdate)
           );
    }
}

The ordering matters: Input → Selection → ModeTransition → CameraUpdate. We read input, then process clicks, then handle mode changes, then finally update the camera position.

The Experience

Fire up the game. You can now:

  1. WASD to pan around the sector
  2. Mouse wheel to zoom in and out
  3. Mouse edge to scroll (if you like that style)
  4. Click a ship to select it (green ring appears)
  5. Double-click to follow a ship automatically
  6. Enter to take direct control of selected ship
  7. Escape to return to free camera

This transforms the game. Instead of being stuck in one ship’s cockpit, you’re a sector commander. Zoom out, watch the trade routes emerge, follow an NPC to see their decision-making, then take control to try your hand at manual piloting.

NPC Awareness

One more thing: NPCs should pause their AI when you’re piloting them. Add a check to your NPC behavior systems:

pub fn npc_decision_system(
    mode: Res<ControlMode>,
    mut npcs: Query<(Entity, &mut NpcBehavior), With<NpcTrader>>,
) {
    for (entity, mut behavior) in npcs.iter_mut() {
        // Skip this NPC if player is piloting it
        if mode.controlled_entity() == Some(entity) {
            continue;
        }

        // Normal AI logic...
    }
}

When you take over, the NPC pauses. When you release control, they resume right where they left off.

What We Built

  • ControlMode resource: Three-way state for camera control
  • Selection system: Click detection with visual feedback
  • Free camera: WASD, edge scroll, and a separate position entity
  • Zoom: Mouse wheel with configurable limits
  • Following and Piloting: Double-click to follow, Enter to pilot
  • Smooth camera: Lerp-based following for polish

What We Learned

  • Orthogonal state: Control mode is separate from game state
  • Screen-to-world conversion: viewport_to_world_2d for click detection
  • Lerp for smoothness: Interpolation prevents jarring camera jumps
  • SystemSet ordering: Input → Selection → Camera
  • Query filters: Without<> prevents conflicting borrows

What’s Next

We can observe our peaceful trading empire… but where’s the conflict? Ships just fly around making money.

In Part 10, we add combat. Weapons, damage, hostile factions. The galaxy gets dangerous.

Lock and load.