Asteroid Dodger: Large Map and Minimap

intermediate Rustmacroquadgame devcameraUI
0 / 0

This tutorial picks up where the integration testing tutorial left off. The game currently takes place on a single screen — asteroids spawn from the edges and the ship wraps around. This tutorial expands the world to a 4000x4000 pixel arena, adds a camera that follows the player, and draws a minimap radar so you can see what’s beyond the viewport.

What you’ll add

Milestones overview

Milestone 1Camera SystemFollow-camera, world bounds, HUD overlay
Milestone 2MinimapRadar display with dots and viewport rect

Prerequisites


Milestone 1 — Camera system

Milestone 1 of 2

Step 1 — Follow the player with Camera2D

Add world size constants to src/game.rs:

pub const WORLD_W: f32 = 4000.0;
pub const WORLD_H: f32 = 4000.0;

macroquad’s Camera2D transforms world coordinates to screen coordinates. Set it at the start of each frame’s draw so all game objects render relative to the camera:

use macroquad::camera::{set_camera, set_default_camera, Camera2D};

In Game::draw, after clear_background(BLACK) and before drawing any game objects (particles, asteroids, etc.), add:

// World-space camera — centers viewport on the player
let camera = Camera2D {
    target: self.player.pos,
    zoom: Vec2::new(2.0 / screen_width(), 2.0 / screen_height()),
    ..Default::default()
};
set_camera(&camera);

All subsequent draw calls (particles, asteroids, bullets, bombs, player) now render in world space — they scroll as the player moves. The zoom values give 1:1 pixel mapping at the current window size.

Two coordinate spaces

With a camera active, there are two coordinate spaces. World space is where game objects live (0..4000 in both axes). Screen space is where HUD elements live (0..screen_width, 0..screen_height). You switch between them with set_camera and set_default_camera.

Step 2 — Screen-space HUD and world bounds

After drawing all game objects, switch back to screen coordinates for the HUD. Add set_default_camera() before any HUD drawing:

// Done drawing world objects — switch to screen space for HUD
set_default_camera();

draw_text(
    &format!("FPS: {}", get_fps()),
    screen_width() - 90.0, 24.0, 20.0, DARKGREEN,
);
draw_text(&format!("Score: {}", self.score as u32), 20.0, 24.0, 24.0, WHITE);
// ... HP bar, weapon indicator, bomb count, high score — all unchanged

src/player.rs — replace screen wrapping with world-edge clamping. In update, replace the wrapping block:

// Replace screen wrapping with world clamping
use crate::game::{WORLD_W, WORLD_H};

// Clamp to world bounds
self.pos.x = self.pos.x.clamp(0.0, WORLD_W);
self.pos.y = self.pos.y.clamp(0.0, WORLD_H);

src/asteroid.rs — change spawning from screen edges to a ring around a target position. Update spawn to take a center parameter:

pub fn spawn_around(center: Vec2) -> Self {
    let angle = rand::gen_range(0.0f32, std::f32::consts::TAU);
    let dist = rand::gen_range(600.0f32, 800.0);
    let pos = center + Vec2::from_angle(angle) * dist;

    let target = center + Vec2::new(
        rand::gen_range(-200.0f32, 200.0),
        rand::gen_range(-200.0f32, 200.0),
    );
    let dir    = (target - pos).normalize_or_zero();
    let speed  = rand::gen_range(60.0f32, 160.0);
    let radius = rand::gen_range(20.0f32, 50.0);
    let n      = rand::gen_range(7u32, 13) as usize;
    let vertices = (0..n)
        .map(|i| {
            let a = (i as f32 / n as f32) * std::f32::consts::TAU;
            let r = radius * rand::gen_range(0.7f32, 1.3);
            Vec2::from_angle(a) * r
        })
        .collect();
    Asteroid { pos, vel: dir * speed, radius, vertices, generation: 0 }
}

Keep the original spawn() method (it still works for the earlier tutorials) and add spawn_around alongside it.

Update is_off_screen to use distance from a point rather than screen bounds:

pub fn is_far_from(&self, center: Vec2) -> bool {
    self.pos.distance(center) > 1200.0
}

src/game.rs — update the spawning call and cleanup:

// In update(), replace Asteroid::spawn() with:
self.asteroids.push(Asteroid::spawn_around(self.player.pos));

// Replace the retain call:
let player_pos = self.player.pos;
self.asteroids.retain(|a| !a.is_far_from(player_pos));

Update the grid to use the world size:

// In new() and reset():
grid: Grid::new(WORLD_W, WORLD_H, 120.0),
Grid size

A 4000x4000 world with 120px cells creates a 34x34 grid — 1156 cells. Each cell stores a Vec<usize> but most will be empty. This is fine for this scale. For much larger worlds you’d want a hash-based grid that only allocates occupied cells.

Run it

cargo run — the ship now moves through a large world. Asteroids spawn around you and drift past. The HUD stays fixed on screen while everything else scrolls. Flying to the edge of the world stops the ship rather than wrapping.


Milestone 2 — Minimap

Milestone 2 of 2

Step 3 — Draw the radar

Add a draw_minimap method to Game. It draws in screen space (after set_default_camera), so call it from draw alongside the other HUD elements:

fn draw_minimap(&self) {
    let size = 160.0;
    let mx = screen_width() - size - 16.0;
    let my = screen_height() - size - 16.0;
    let scale_x = size / WORLD_W;
    let scale_y = size / WORLD_H;

    // Semi-transparent background
    draw_rectangle(mx, my, size, size, Color::new(0.0, 0.0, 0.0, 0.6));
    draw_rectangle_lines(mx, my, size, size, 1.0, GRAY);

    // Asteroid dots
    for asteroid in &self.asteroids {
        let ax = mx + asteroid.pos.x * scale_x;
        let ay = my + asteroid.pos.y * scale_y;
        let r = (asteroid.radius * scale_x).max(1.0);
        draw_circle(ax, ay, r, Color::new(0.5, 0.5, 0.5, 0.8));
    }

    // Power-up dots
    for powerup in &self.powerups {
        let px = mx + powerup.pos.x * scale_x;
        let py = my + powerup.pos.y * scale_y;
        draw_circle(px, py, 2.5, powerup.kind.color());
    }

    // Bomb dots
    for bomb in &self.bombs {
        let bx = mx + bomb.pos.x * scale_x;
        let by = my + bomb.pos.y * scale_y;
        draw_circle(bx, by, 2.0, RED);
    }

    // Viewport rectangle — shows what the player can currently see
    let vw = screen_width() * scale_x;
    let vh = screen_height() * scale_y;
    let vx = mx + self.player.pos.x * scale_x - vw / 2.0;
    let vy = my + self.player.pos.y * scale_y - vh / 2.0;
    draw_rectangle_lines(vx, vy, vw, vh, 1.0, Color::new(1.0, 1.0, 1.0, 0.5));

    // Player dot (drawn last so it's on top)
    let px = mx + self.player.pos.x * scale_x;
    let py = my + self.player.pos.y * scale_y;
    draw_circle(px, py, 3.0, WHITE);
}

Call it in draw, after the HUD elements and before the game-over overlay:

self.draw_minimap();
Scale factor

The minimap is 160px wide and the world is 4000px, so the scale is 0.04 — every world pixel maps to 0.04 screen pixels. The viewport rectangle shows the player’s current view relative to the whole map, helping with orientation in the larger world.

Step 4 — Power-up color method

If your PowerUpType doesn’t already have a color() method from Tutorial 6, add one to src/powerup.rs:

pub fn color(&self) -> Color {
    match self {
        Self::WeaponUpgrade => GREEN,
        Self::BombRefill => RED,
        Self::Shield => SKYBLUE,
        Self::ScoreBoost => YELLOW,
    }
}

This is used by the minimap to color-code power-up dots so the player can spot them from the radar.

Run it

cargo run — a minimap appears in the bottom-right corner. Gray dots are asteroids, colored dots are power-ups, and a white rectangle shows your current viewport. The white dot in the center is your ship. As you fly around the 4000x4000 world, the minimap updates in real time.


Complete listing

Modified files: game.rs (camera, minimap, world constants, spawn_around), player.rs (world clamping), asteroid.rs (spawn_around, is_far_from). All other files (main.rs, collision.rs, bullet.rs, bomb.rs, weapon.rs, sound.rs, particle.rs, grid.rs, powerup.rs) are unchanged from the previous tutorial.

src/player.rs

use macroquad::prelude::*;
use crate::collision::Aabb;
use crate::bullet::Bullet;
use crate::bomb::Bomb;
use crate::game::{Keybindings, WORLD_W, WORLD_H};
use crate::weapon::WeaponType;

pub const PLAYER_RADIUS: f32 = 16.0;
pub const PLAYER_MASS: f32 = 500.0;

const THRUST: f32 = 300.0;
const DRAG: f32 = 1.8;
const ROTATE_SPEED: f32 = 3.0;
const MAX_SPEED: f32 = 420.0;

pub struct Player {
    pub pos: Vec2,
    pub vel: Vec2,
    pub hp: f32,
    pub max_hp: f32,
    pub invincibility: f32,
    angle: f32,
}

impl Player {
    pub fn new() -> Self {
        Self {
            pos: Vec2::new(WORLD_W / 2.0, WORLD_H / 2.0),
            vel: Vec2::ZERO,
            hp: 100.0,
            max_hp: 100.0,
            invincibility: 0.0,
            angle: -std::f32::consts::FRAC_PI_2,
        }
    }

    pub fn update(&mut self, dt: f32, keys: &Keybindings) {
        if is_key_down(keys.rotate_left) { self.angle -= ROTATE_SPEED * dt; }
        if is_key_down(keys.rotate_right) { self.angle += ROTATE_SPEED * dt; }

        if is_key_down(keys.thrust) {
            self.vel += Vec2::from_angle(self.angle) * THRUST * dt;
        }

        self.vel *= 1.0 - DRAG * dt;
        if self.vel.length() > MAX_SPEED {
            self.vel = self.vel.normalize() * MAX_SPEED;
        }

        self.pos += self.vel * dt;

        // Clamp to world bounds
        self.pos.x = self.pos.x.clamp(0.0, WORLD_W);
        self.pos.y = self.pos.y.clamp(0.0, WORLD_H);

        if self.invincibility > 0.0 {
            self.invincibility -= dt;
        }
    }

    pub fn aabb(&self) -> Aabb {
        Aabb::from_center_radius(self.pos, PLAYER_RADIUS)
    }

    pub fn spawn_bullets(&self, weapon: WeaponType) -> Vec<Bullet> {
        let forward = Vec2::from_angle(self.angle);
        let tip = self.pos + forward * 22.0;
        match weapon {
            WeaponType::Blaster => {
                vec![Bullet::new(tip, self.angle)]
            }
            WeaponType::Spread => {
                let spread = 0.15;
                vec![
                    Bullet::new_with(tip, self.angle - spread, 550.0, false, 2.5, ORANGE),
                    Bullet::new_with(tip, self.angle, 600.0, false, 2.5, ORANGE),
                    Bullet::new_with(tip, self.angle + spread, 550.0, false, 2.5, ORANGE),
                ]
            }
            WeaponType::Rapid => {
                vec![Bullet::new_with(tip, self.angle, 700.0, false, 1.5, SKYBLUE)]
            }
            WeaponType::Laser => {
                vec![Bullet::new_with(tip, self.angle, 900.0, true, 1.5, LIME)]
            }
        }
    }

    pub fn spawn_bomb(&self) -> Bomb {
        let forward = Vec2::from_angle(self.angle);
        Bomb::new(self.pos + forward * 22.0, self.angle)
    }

    pub fn draw(&self) {
        if self.invincibility > 0.0 && (self.invincibility * 8.0) as u32 % 2 == 0 {
            return;
        }
        let forward = Vec2::from_angle(self.angle);
        let side = Vec2::new(-forward.y, forward.x);
        draw_triangle(
            self.pos + forward * 20.0,
            self.pos - forward * 10.0 + side * 12.0,
            self.pos - forward * 10.0 - side * 12.0,
            WHITE,
        );
    }
}

src/asteroid.rs

use macroquad::prelude::*;
use crate::collision::Aabb;

pub struct Asteroid {
    pub pos: Vec2,
    pub vel: Vec2,
    pub radius: f32,
    pub vertices: Vec<Vec2>,
    pub generation: u8,
}

impl Asteroid {
    pub fn spawn() -> Self {
        let (sw, sh) = (screen_width(), screen_height());
        let pos = match rand::gen_range(0u32, 4) {
            0 => Vec2::new(rand::gen_range(0.0, sw), -60.0),
            1 => Vec2::new(sw + 60.0, rand::gen_range(0.0, sh)),
            2 => Vec2::new(rand::gen_range(0.0, sw), sh + 60.0),
            _ => Vec2::new(-60.0, rand::gen_range(0.0, sh)),
        };
        let target = Vec2::new(
            sw / 2.0 + rand::gen_range(-150.0f32, 150.0),
            sh / 2.0 + rand::gen_range(-150.0f32, 150.0),
        );
        let dir    = (target - pos).normalize_or_zero();
        let speed  = rand::gen_range(60.0f32, 160.0);
        let radius = rand::gen_range(20.0f32, 50.0);
        let n      = rand::gen_range(7u32, 13) as usize;
        let vertices = (0..n)
            .map(|i| {
                let angle = (i as f32 / n as f32) * std::f32::consts::TAU;
                let r = radius * rand::gen_range(0.7f32, 1.3);
                Vec2::from_angle(angle) * r
            })
            .collect();
        Asteroid { pos, vel: dir * speed, radius, vertices, generation: 0 }
    }

    pub fn spawn_around(center: Vec2) -> Self {
        let angle = rand::gen_range(0.0f32, std::f32::consts::TAU);
        let dist = rand::gen_range(600.0f32, 800.0);
        let pos = center + Vec2::from_angle(angle) * dist;
        let target = center + Vec2::new(
            rand::gen_range(-200.0f32, 200.0),
            rand::gen_range(-200.0f32, 200.0),
        );
        let dir    = (target - pos).normalize_or_zero();
        let speed  = rand::gen_range(60.0f32, 160.0);
        let radius = rand::gen_range(20.0f32, 50.0);
        let n      = rand::gen_range(7u32, 13) as usize;
        let vertices = (0..n)
            .map(|i| {
                let a = (i as f32 / n as f32) * std::f32::consts::TAU;
                let r = radius * rand::gen_range(0.7f32, 1.3);
                Vec2::from_angle(a) * r
            })
            .collect();
        Asteroid { pos, vel: dir * speed, radius, vertices, generation: 0 }
    }

    pub fn spawn_fragment(parent_pos: Vec2, parent_vel: Vec2, parent_radius: f32, kick_dir: Vec2) -> Self {
        let radius = parent_radius * rand::gen_range(0.45f32, 0.55);
        let n = rand::gen_range(5u32, 9) as usize;
        let vertices = (0..n)
            .map(|i| {
                let angle = (i as f32 / n as f32) * std::f32::consts::TAU;
                let r = radius * rand::gen_range(0.7f32, 1.3);
                Vec2::from_angle(angle) * r
            })
            .collect();
        let vel = parent_vel + kick_dir * rand::gen_range(40.0f32, 100.0);
        Asteroid {
            pos: parent_pos + kick_dir * radius,
            vel,
            radius,
            vertices,
            generation: 1,
        }
    }

    pub fn update(&mut self, dt: f32) {
        self.pos += self.vel * dt;
    }

    pub fn is_off_screen(&self) -> bool {
        let margin = 200.0;
        let (sw, sh) = (screen_width(), screen_height());
        self.pos.x < -margin || self.pos.x > sw + margin
            || self.pos.y < -margin || self.pos.y > sh + margin
    }

    pub fn is_far_from(&self, center: Vec2) -> bool {
        self.pos.distance(center) > 1200.0
    }

    pub fn aabb(&self) -> Aabb {
        let mut min = Vec2::splat(f32::MAX);
        let mut max = Vec2::splat(f32::MIN);
        for v in &self.vertices {
            let world = self.pos + *v;
            min = min.min(world);
            max = max.max(world);
        }
        Aabb { min, max }
    }

    pub fn mass(&self) -> f32 {
        self.radius * self.radius
    }

    pub fn draw(&self) {
        let n = self.vertices.len();
        for i in 0..n {
            let a = self.pos + self.vertices[i];
            let b = self.pos + self.vertices[(i + 1) % n];
            draw_triangle(self.pos, a, b, GRAY);
            draw_line(a.x, a.y, b.x, b.y, 1.5, LIGHTGRAY);
        }
    }
}

pub fn resolve_collisions(asteroids: &mut [Asteroid], pairs: &[(usize, usize)]) {
    for &(i, j) in pairs {
        if i >= asteroids.len() || j >= asteroids.len() { continue; }

        let (left, right) = asteroids.split_at_mut(j);
        let a = &mut left[i];
        let b = &mut right[0];

        if !a.aabb().overlaps(&b.aabb()) { continue; }

        let dist = a.pos.distance(b.pos);
        let min_dist = a.radius + b.radius;
        if dist >= min_dist || dist < 0.001 { continue; }

        let normal = (b.pos - a.pos) / dist;
        let vel_along = (a.vel - b.vel).dot(normal);
        if vel_along <= 0.0 { continue; }

        let ma = a.mass();
        let mb = b.mass();
        let j_imp = 2.0 * vel_along / (1.0 / ma + 1.0 / mb);
        a.vel -= normal * (j_imp / ma);
        b.vel += normal * (j_imp / mb);

        let overlap = min_dist - dist;
        let correction = normal * overlap * 0.5;
        a.pos -= correction;
        b.pos += correction;
    }
}

src/game.rs — showing only the changes. The full file is identical to the previous tutorial’s game.rs with these modifications:

Add at the top of the file:

use macroquad::camera::{set_camera, set_default_camera, Camera2D};

pub const WORLD_W: f32 = 4000.0;
pub const WORLD_H: f32 = 4000.0;

In reset(), update the grid:

self.grid = Grid::new(WORLD_W, WORLD_H, 120.0);

In new(), update the grid:

grid: Grid::new(WORLD_W, WORLD_H, 120.0),

In update(), change the asteroid spawn call:

self.asteroids.push(Asteroid::spawn_around(self.player.pos));

Change the asteroid retain call:

let player_pos = self.player.pos;
self.asteroids.retain(|a| !a.is_far_from(player_pos));

In draw(), add camera setup after clear_background(BLACK) and before drawing world objects:

// Set world-space camera
let camera = Camera2D {
    target: self.player.pos,
    zoom: Vec2::new(2.0 / screen_width(), 2.0 / screen_height()),
    ..Default::default()
};
set_camera(&camera);

// Draw world objects (particles, asteroids, bullets, bombs, powerups, player)
self.particles.draw();
for asteroid in &self.asteroids { asteroid.draw(); }
for bullet in &self.bullets { bullet.draw(); }
for bomb in &self.bombs { bomb.draw(); }
for powerup in &self.powerups { powerup.draw(); }
self.player.draw();

// Switch to screen space for HUD
set_default_camera();

// All HUD drawing below (score, HP bar, weapon, bombs, FPS, high score)

Add the minimap call after HUD drawing, before the game-over overlay:

self.draw_minimap();

Add the draw_minimap method to impl Game:

fn draw_minimap(&self) {
    let size = 160.0;
    let mx = screen_width() - size - 16.0;
    let my = screen_height() - size - 16.0;
    let scale_x = size / WORLD_W;
    let scale_y = size / WORLD_H;

    draw_rectangle(mx, my, size, size, Color::new(0.0, 0.0, 0.0, 0.6));
    draw_rectangle_lines(mx, my, size, size, 1.0, GRAY);

    for asteroid in &self.asteroids {
        let ax = mx + asteroid.pos.x * scale_x;
        let ay = my + asteroid.pos.y * scale_y;
        let r = (asteroid.radius * scale_x).max(1.0);
        draw_circle(ax, ay, r, Color::new(0.5, 0.5, 0.5, 0.8));
    }

    for powerup in &self.powerups {
        let px = mx + powerup.pos.x * scale_x;
        let py = my + powerup.pos.y * scale_y;
        draw_circle(px, py, 2.5, powerup.kind.color());
    }

    for bomb in &self.bombs {
        let bx = mx + bomb.pos.x * scale_x;
        let by = my + bomb.pos.y * scale_y;
        draw_circle(bx, by, 2.0, RED);
    }

    let vw = screen_width() * scale_x;
    let vh = screen_height() * scale_y;
    let vx = mx + self.player.pos.x * scale_x - vw / 2.0;
    let vy = my + self.player.pos.y * scale_y - vh / 2.0;
    draw_rectangle_lines(vx, vy, vw, vh, 1.0, Color::new(1.0, 1.0, 1.0, 0.5));

    let px = mx + self.player.pos.x * scale_x;
    let py = my + self.player.pos.y * scale_y;
    draw_circle(px, py, 3.0, WHITE);
}

Next steps

This completes the asteroid dodger tutorial series. Here are ideas for further development: