Bevy / Rust
20 min read

Building a Space Sim in Bevy - Part 13: Minimap

Creating a minimap using camera-based render-to-texture, render layers, and UI integration.

Share:

Building a Space Sim in Bevy - Part 13: Minimap

We have a problem of scale. Our sector is big - hex grids, multiple factions, ships flying everywhere. But we can only see a small piece at a time. Want to know if there’s a battle happening across the map? You have to fly there.

We need a minimap. A small window in the corner showing the entire sector at once.

The Clever Approach

There are two ways to build a minimap:

  1. Manual icons: Track every entity and draw icons on a canvas
  2. Second camera: Render the actual game world to a texture

Option 1 requires writing synchronization code for every entity type. Add a new ship class? Update the minimap. Change station sprites? Update the minimap. It’s maintenance overhead.

Option 2 is elegant: we literally point a camera at the world and display what it sees. Ships automatically appear. Stations automatically appear. No synchronization because we’re rendering the real entities.

We’re going with option 2. Bevy makes this surprisingly straightforward.

Render Layers

The key technology is Bevy’s RenderLayers. Each camera and entity has layers, and cameras only render entities on matching layers:

use bevy::render::view::RenderLayers;

// Layer 0 = Main game (default for everything)
// Layer 1 = Minimap only
// Layer 2 = Both main and minimap

pub const LAYER_MAIN: u8 = 0;
pub const LAYER_MINIMAP: u8 = 1;
pub const LAYER_BOTH: u8 = 2;

The main camera sees layer 0 (and 2). The minimap camera sees layer 1 (and 2). If we want something on both, we assign it layers 0 and 1 (or use layer 2 as shorthand).

This solves an important problem: we want ships on the minimap, but NOT the starfield background. By assigning stars to layer 0 only, the minimap camera never sees them.

Configuration

First, let’s define what our minimap looks like:

// src/ui/minimap/components.rs
use bevy::prelude::*;

#[derive(Resource)]
pub struct MinimapConfig {
    pub size: f32,           // UI size in pixels
    pub range: f32,          // World units visible
    pub border_width: f32,
    pub border_color: Color,
    pub background_color: Color,
}

impl Default for MinimapConfig {
    fn default() -> Self {
        Self {
            size: 200.0,
            range: 1500.0,
            border_width: 2.0,
            border_color: Color::srgb(0.3, 0.3, 0.3),
            background_color: Color::srgb(0.05, 0.05, 0.1),
        }
    }
}

The range is how much of the world we can see in the minimap. 1500 units means ships 1500 units from center are at the edge of the minimap.

We also need some resources and markers:

#[derive(Resource, Default)]
pub struct MinimapVisible(pub bool);

#[derive(Resource)]
pub struct MinimapRenderTarget {
    pub image: Handle<Image>,
}

#[derive(Component)]
pub struct MinimapCamera;

#[derive(Component)]
pub struct MinimapRoot;

#[derive(Component)]
pub struct MinimapIcon;

Creating the Render Target

Here’s where the magic happens. We create an image in memory, then tell a camera to render to it:

pub fn setup_minimap_render_target(
    mut commands: Commands,
    mut images: ResMut<Assets<Image>>,
    config: Res<MinimapConfig>,
) {
    let size = Extent3d {
        width: config.size as u32,
        height: config.size as u32,
        depth_or_array_layers: 1,
    };

    // Create the render target image
    let mut image = Image {
        texture_descriptor: TextureDescriptor {
            label: Some("minimap_render_target"),
            size,
            mip_level_count: 1,
            sample_count: 1,
            dimension: TextureDimension::D2,
            format: TextureFormat::Bgra8UnormSrgb,
            usage: TextureUsages::TEXTURE_BINDING
                | TextureUsages::COPY_DST
                | TextureUsages::RENDER_ATTACHMENT,
            view_formats: &[],
        },
        ..default()
    };

    image.resize(size);

    let image_handle = images.add(image);

    commands.insert_resource(MinimapRenderTarget {
        image: image_handle.clone(),
    });

    // Spawn the minimap camera
    commands.spawn((
        MinimapCamera,
        Camera2d,
        Camera {
            target: RenderTarget::Image(image_handle),
            order: -1, // Render before main camera
            clear_color: ClearColorConfig::Custom(Color::srgba(0.0, 0.0, 0.0, 0.0)),
            ..default()
        },
        OrthographicProjection {
            scale: config.range / config.size,
            ..OrthographicProjection::default_2d()
        },
        RenderLayers::layer(LAYER_MINIMAP),
    ));
}

Let me break down the important parts:

  1. Image creation: We create a 200x200 texture with the right format for rendering
  2. TEXTURE_BINDING | RENDER_ATTACHMENT: The image can be both rendered to AND sampled from
  3. RenderTarget::Image: Tells the camera to render to our image instead of the screen
  4. order: -1: Minimap renders before main camera (so the image is ready when we display it)
  5. RenderLayers::layer(LAYER_MINIMAP): Camera only sees minimap layer

The scale calculation is interesting. If range = 1500 and size = 200, then scale = 7.5. This means each pixel in the minimap represents 7.5 world units.

The Minimap UI

Now we display the render target in our UI:

pub fn spawn_minimap_ui(
    mut commands: Commands,
    config: Res<MinimapConfig>,
    render_target: Res<MinimapRenderTarget>,
) {
    commands.spawn((
        MinimapRoot,
        Node {
            position_type: PositionType::Absolute,
            right: Val::Px(10.0),
            bottom: Val::Px(10.0),
            width: Val::Px(config.size + config.border_width * 2.0),
            height: Val::Px(config.size + config.border_width * 2.0),
            padding: UiRect::all(Val::Px(config.border_width)),
            ..default()
        },
        BackgroundColor(config.border_color),
    )).with_children(|parent| {
        // The actual minimap image
        parent.spawn((
            ImageNode {
                image: render_target.image.clone(),
                ..default()
            },
            Node {
                width: Val::Percent(100.0),
                height: Val::Percent(100.0),
                ..default()
            },
        ));
    });
}

We position it in the bottom-right corner using absolute positioning. The border is just padding around the image. The ImageNode displays our render target texture, which updates every frame automatically.

Marking Entities for the Minimap

We need to tell entities to appear on the minimap layer:

pub fn setup_minimap_icons(
    mut commands: Commands,
    stations: Query<Entity, (With<Station>, Without<MinimapIcon>)>,
    ships: Query<Entity, (With<Ship>, Without<MinimapIcon>)>,
) {
    // Stations visible on minimap
    for entity in stations.iter() {
        commands.entity(entity).insert((
            MinimapIcon,
            RenderLayers::from_layers(&[LAYER_MAIN, LAYER_MINIMAP]),
        ));
    }

    // Ships visible on minimap
    for entity in ships.iter() {
        commands.entity(entity).insert((
            MinimapIcon,
            RenderLayers::from_layers(&[LAYER_MAIN, LAYER_MINIMAP]),
        ));
    }
}

The Without<MinimapIcon> filter ensures we only process each entity once. After this runs, ships and stations appear on both the main view AND the minimap.

Keeping Stars Off the Minimap

When spawning stars, we explicitly limit them to the main layer:

pub fn spawn_starfield(mut commands: Commands, config: Res<StarfieldConfig>) {
    // ... star generation code ...

    commands.spawn((
        Star,
        // Only main camera layer - NOT minimap!
        RenderLayers::layer(LAYER_MAIN),
        Sprite { /* ... */ },
        Transform { /* ... */ },
    ));
}

Without this, the minimap would be a mess of white dots. We only want the important stuff: ships, stations, maybe hex grid outlines.

Syncing Camera Position

The minimap should center on whatever we’re looking at:

pub fn update_minimap_camera(
    mode: Res<ControlMode>,
    free_cam: Query<&Transform, With<FreeCamera>>,
    ships: Query<&Transform, With<Ship>>,
    mut minimap_camera: Query<&mut Transform, (With<MinimapCamera>, Without<FreeCamera>, Without<Ship>)>,
) {
    let Ok(mut cam_transform) = minimap_camera.get_single_mut() else { return };

    let target_pos = match *mode {
        ControlMode::FreeCam => {
            free_cam.get_single().ok().map(|t| t.translation.truncate())
        }
        ControlMode::Following(entity) | ControlMode::Piloting(entity) => {
            ships.get(entity).ok().map(|t| t.translation.truncate())
        }
    };

    if let Some(pos) = target_pos {
        cam_transform.translation.x = pos.x;
        cam_transform.translation.y = pos.y;
    }
}

This mirrors our main camera following logic. When you’re in FreeCam mode, the minimap centers on the free camera position. When following or piloting a ship, it centers on that ship.

Toggle with N Key

Sometimes you want more screen space. Let’s add a toggle:

pub fn toggle_minimap(
    keyboard: Res<ButtonInput<KeyCode>>,
    mut visible: ResMut<MinimapVisible>,
    mut minimap_root: Query<&mut Visibility, With<MinimapRoot>>,
) {
    if keyboard.just_pressed(KeyCode::KeyN) {
        visible.0 = !visible.0;

        if let Ok(mut vis) = minimap_root.get_single_mut() {
            *vis = if visible.0 {
                Visibility::Visible
            } else {
                Visibility::Hidden
            };
        }
    }
}

Press N to hide the minimap. Press N again to show it. The MinimapVisible resource lets other systems check if the minimap is shown.

Hex Grid Overlay

For extra context, let’s add a hex grid visible only on the minimap:

pub fn spawn_minimap_hex_overlay(
    mut commands: Commands,
    loaded_map: Res<LoadedMap>,
) {
    for hex in loaded_map.hex_grid.all_hexes() {
        let world_pos = loaded_map.hex_grid.hex_to_world(&hex);

        commands.spawn((
            Sprite {
                color: Color::srgba(0.3, 0.3, 0.3, 0.3),
                custom_size: Some(Vec2::splat(loaded_map.hex_grid.hex_size * 1.5)),
                ..default()
            },
            Transform::from_translation(world_pos.extend(-90.0)),
            // Minimap only - not main view
            RenderLayers::layer(LAYER_MINIMAP),
        ));
    }
}

These hex outlines appear ONLY on the minimap (layer 1), not the main game view. This gives strategic context without cluttering the gameplay view.

Wiring It Up

impl Plugin for MinimapPlugin {
    fn build(&self, app: &mut App) {
        app.init_resource::<MinimapConfig>()
           .insert_resource(MinimapVisible(true))  // Start visible
           .add_systems(Startup, setup_minimap_render_target)
           .add_systems(OnEnter(GameState::Playing), (
               spawn_minimap_ui,
               spawn_minimap_hex_overlay,
           ))
           .add_systems(Update, (
               setup_minimap_icons,
               update_minimap_camera,
               toggle_minimap,
           ).run_if(in_state(GameState::Playing)));
    }
}

The Result

Run the game and look at the bottom-right corner. There’s your sector - a god’s-eye view of everything happening. Ships moving as colored dots. Stations as larger markers. The hex grid showing territory.

Press N to toggle it off when you want a cleaner view. Press N again to bring it back.

The beautiful thing: you didn’t write any icon-drawing code. The minimap literally renders the game world. Add a new entity type tomorrow and it automatically appears. The render-to-texture approach scales with your game’s complexity.

Performance Notes

You might worry about rendering the game twice. In practice, it’s cheap:

  • Low resolution: 200x200 is tiny compared to your main view
  • Fewer entities: Render layers exclude the starfield
  • Simple geometry: Minimap doesn’t need the same detail
  • One extra pass: Modern GPUs handle this trivially

If you ever need to optimize, reduce config.size or render the minimap every other frame.

What We Built

  • Render-to-texture: Camera outputs to an image instead of screen
  • Render layers: Control what each camera sees
  • UI integration: Display render target as UI image
  • Entity marking: Automatic minimap appearance via layers
  • Toggle system: N key to show/hide

What We Learned

  • RenderTarget::Image: Off-screen rendering to texture
  • RenderLayers: Per-entity visibility control
  • Camera order: Multiple cameras render in sequence
  • ImageNode: Display images in Bevy UI
  • Layer composition: Entities can belong to multiple layers

What’s Next

Our game is feature-complete! Ships fly, trade, fight. Factions own territory. The minimap shows it all.

In Part 14, we add polish: dynamic pricing configuration and per-module logging. Small touches that make development and gameplay smoother.

The finish line is in sight.