Build an Asteroid Dodger in Rust with macroquad

beginner Rustmacroquadgame dev2D graphics
0 / 0

Building a game is one of the fastest ways to make an unfamiliar language concrete. Every feature — movement, physics, rendering — exercises a different part of the language. This tutorial builds the foundation of a top-down asteroid dodger in Rust using macroquad, a small game library (not a game engine): no entity systems, no scene graph, just drawing, input, and a game loop.

No prior Rust experience is required. If you’ve written C, Go, Python, or C++ before, the concepts will map cleanly — the tutorial calls out the differences as they come up.

What you’ll build

A spaceship that steers with rotation and momentum-based thrust. Irregularly shaped asteroids spawn from the screen edges and drift across the screen. The ship wraps around screen edges and glides with realistic drag. This tutorial focuses on the core game loop and rendering — collision, shooting, and physics come in the follow-up tutorials.

Controls: A/D to rotate, W to thrust.

Milestones overview

Milestone 1A WindowProject setup, module skeleton, black window
Milestone 2A ShipPlayer triangle on screen
Milestone 3MovementRotation, thrust, drag, screen wrapping
Milestone 4AsteroidsIrregular shapes, spawning, FPS meter

Prerequisites

What you need to know:

C, Go, Python, or C++Variables & control flowFunctions & structsBasic terminal / CLIPrior Rust experienceGame dev experienceLinear algebra

What you need installed:

Rust & Cargorustup updateInstall Rust — or run rustup update if you already have it
No system librariesmacroquad is pure Rust and brings everything it needs

Each milestone ends with cargo run, which opens a window. If you’re on a headless or remote machine, use cargo build instead — it compiles and reports errors without trying to open a display.


Milestone 1 — A window

Milestone 1 of 4

Step 1 — Create the project and module skeleton

cargo new asteroid-dodger
cd asteroid-dodger
cargo add macroquad

Rather than piling everything into src/main.rs, we’ll split the code into four files from the start:

src/
├── main.rs       — entry point, module declarations, game loop
├── player.rs     — Player struct, movement, and rendering
├── asteroid.rs   — Asteroid struct, spawning, and rendering
└── game.rs       — Game struct, update/draw orchestration

Create the additional source files:

touch src/player.rs src/asteroid.rs src/game.rs

How Rust modules work — three things to know:

Replace src/main.rs with:

use macroquad::prelude::*;

mod player;
mod asteroid;
mod game;

use game::Game;

#[macroquad::main("Asteroid Dodger")]
async fn main() {
    let mut game = Game::new();
    loop {
        game.update(get_frame_time());
        game.draw();
        next_frame().await;
    }
}
What is async here?

macroquad uses async/await for its game loop, but not for parallelism. next_frame().await yields control back to macroquad so it can swap the framebuffer and handle OS events — think of it as a structured swap_buffers() call. You won’t need to think about async beyond this pattern.

main.rs references game::Game, so game.rs needs to export that type. Give it a minimal stub that compiles — we’ll fill it in progressively:

// src/game.rs
use macroquad::prelude::*;

pub struct Game;

impl Game {
    pub fn new() -> Self { Self }
    pub fn update(&mut self, _dt: f32) {}
    pub fn draw(&self) { clear_background(BLACK); }
}

The other two files can stay empty for now. Empty modules are valid Rust.

Run it

cargo run — a black window titled “Asteroid Dodger” should open.


Milestone 2 — A ship on screen

Milestone 2 of 4

Step 2 — Player shape (src/player.rs)

// src/player.rs
use macroquad::prelude::*;

pub struct Player {
    pub pos: Vec2,
    pub vel: Vec2,
    angle: f32,  // private — nothing outside this module needs to read it
}

impl Player {
    pub fn new() -> Self {
        Self {
            pos: Vec2::new(screen_width() / 2.0, screen_height() / 2.0),
            vel: Vec2::ZERO,
            angle: -std::f32::consts::FRAC_PI_2, // pointing upward
        }
    }

    pub fn draw(&self) {
        let forward = Vec2::from_angle(self.angle);
        let side = Vec2::new(-forward.y, forward.x); // perpendicular

        draw_triangle(
            self.pos + forward * 20.0,                 // tip
            self.pos - forward * 10.0 + side * 12.0,  // base left
            self.pos - forward * 10.0 - side * 12.0,  // base right
            WHITE,
        );
    }
}

Vec2::from_angle(radians) returns the unit vector at that angle — (cos θ, sin θ). The perpendicular is found by swapping components and negating one (a 90° rotation).

The y-axis points downward

Like most 2D graphics APIs, macroquad’s y-axis increases downward. The ship starts at angle -π/2 so it points up the screen rather than down.

Step 3 — Wire the player into the game

Update src/game.rs to import and draw the player:

// src/game.rs
use macroquad::prelude::*;
use crate::player::Player;

pub struct Game {
    player: Player,
}

impl Game {
    pub fn new() -> Self {
        Self { player: Player::new() }
    }

    pub fn update(&mut self, _dt: f32) {}

    pub fn draw(&self) {
        clear_background(BLACK);
        self.player.draw();
    }
}
Run it

cargo run — a white triangle appears in the centre of the window.


Milestone 3 — A moving ship

Milestone 3 of 4

Step 4 — Rotation and thrust (src/player.rs)

Add movement constants and an update method. Add these constants at the top of the file, below the imports:

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

Add the update method to impl Player:

pub fn update(&mut self, dt: f32) {
    if is_key_down(KeyCode::A) { self.angle -= ROTATE_SPEED * dt; }
    if is_key_down(KeyCode::D) { self.angle += ROTATE_SPEED * dt; }

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

    // Drag decays velocity exponentially each frame
    self.vel *= 1.0 - DRAG * dt;
    if self.vel.length() > MAX_SPEED {
        self.vel = self.vel.normalize() * MAX_SPEED;
    }

    self.pos += self.vel * dt;

    // Wrap around screen edges
    let (sw, sh) = (screen_width(), screen_height());
    if self.pos.x < 0.0 { self.pos.x += sw; }
    if self.pos.x > sw  { self.pos.x -= sw; }
    if self.pos.y < 0.0 { self.pos.y += sh; }
    if self.pos.y > sh  { self.pos.y -= sh; }
}

Now call update from game.rs. Replace the stub:

pub fn update(&mut self, dt: f32) {
    self.player.update(dt);
}

dt is time in seconds since the last frame from get_frame_time(). Multiplying by dt makes movement frame-rate independent. The drag formula vel *= 1.0 - DRAG * dt approximates exponential decay — each frame you keep (1 - DRAG×dt) of your speed.

Run it

cargo runA/D rotates, W thrusts. Release W and the ship drifts to a stop. The ship wraps around screen edges.


Milestone 4 — Asteroids

Milestone 4 of 4

Step 5 — Asteroid shapes (src/asteroid.rs)

Asteroids are irregular polygons: N points around a circle, each at a randomly perturbed radius.

// src/asteroid.rs
use macroquad::prelude::*;

pub struct Asteroid {
    pub pos: Vec2,
    pub vel: Vec2,
    pub radius: f32,         // base radius, used for physics bounds
    pub vertices: Vec<Vec2>, // offsets from pos, pre-computed at spawn
}

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);

        // N evenly-spaced angles, each with a random radius perturbation ±30%
        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 }
    }

    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 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);
        }
    }
}
Vec and iterators

Vec<Vec2> is Rust’s growable array. The (0..n).map(…).collect() chain is a pipeline: iterate indices, transform each to a Vec2 offset, collect into a Vec — equivalent to a list comprehension in Python. TAU is 2π, one full circle in radians.

Step 6 — Spawning loop and FPS meter (src/game.rs)

Replace src/game.rs with the full version:

// src/game.rs
use macroquad::prelude::*;
use crate::player::Player;
use crate::asteroid::Asteroid;

pub struct Game {
    player: Player,
    asteroids: Vec<Asteroid>,
    score: f32,
    spawn_timer: f32,
    spawn_interval: f32,
}

impl Game {
    pub fn new() -> Self {
        Self {
            player: Player::new(),
            asteroids: Vec::new(),
            score: 0.0,
            spawn_timer: 0.0,
            spawn_interval: 3.0,
        }
    }

    pub fn update(&mut self, dt: f32) {
        self.player.update(dt);
        self.score += dt;

        self.spawn_timer += dt;
        if self.spawn_timer >= self.spawn_interval {
            self.spawn_timer = 0.0;
            self.asteroids.push(Asteroid::spawn());
            self.spawn_interval = (self.spawn_interval - 0.15).max(0.6);
        }

        for asteroid in &mut self.asteroids {
            asteroid.update(dt);
        }

        self.asteroids.retain(|a| !a.is_off_screen());
    }

    pub fn draw(&self) {
        clear_background(BLACK);

        draw_text(
            &format!("FPS: {}", get_fps()),
            screen_width() - 90.0, 24.0, 20.0, DARKGREEN,
        );

        for asteroid in &self.asteroids {
            asteroid.draw();
        }
        self.player.draw();

        draw_text(&format!("Score: {}", self.score as u32), 20.0, 24.0, 24.0, WHITE);
    }
}
retain

Vec::retain(|item| condition) removes elements in-place where the closure returns false. The FPS counter is added now so we can observe performance changes as collision detection gets more expensive in later tutorials.

Run it

cargo run — asteroids drift in from the edges. The FPS counter appears top-right. The ship passes through them — collision detection comes in the next tutorial.


Complete listing

src/main.rs

use macroquad::prelude::*;

mod player;
mod asteroid;
mod game;

use game::Game;

#[macroquad::main("Asteroid Dodger")]
async fn main() {
    let mut game = Game::new();
    loop {
        game.update(get_frame_time());
        game.draw();
        next_frame().await;
    }
}

src/player.rs

use macroquad::prelude::*;

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,
    angle: f32,
}

impl Player {
    pub fn new() -> Self {
        Self {
            pos: Vec2::new(screen_width() / 2.0, screen_height() / 2.0),
            vel: Vec2::ZERO,
            angle: -std::f32::consts::FRAC_PI_2,
        }
    }

    pub fn update(&mut self, dt: f32) {
        if is_key_down(KeyCode::A) { self.angle -= ROTATE_SPEED * dt; }
        if is_key_down(KeyCode::D) { self.angle += ROTATE_SPEED * dt; }

        if is_key_down(KeyCode::W) {
            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;

        let (sw, sh) = (screen_width(), screen_height());
        if self.pos.x < 0.0 { self.pos.x += sw; }
        if self.pos.x > sw  { self.pos.x -= sw; }
        if self.pos.y < 0.0 { self.pos.y += sh; }
        if self.pos.y > sh  { self.pos.y -= sh; }
    }

    pub fn draw(&self) {
        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::*;

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

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 }
    }

    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 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);
        }
    }
}

src/game.rs

use macroquad::prelude::*;
use crate::player::Player;
use crate::asteroid::Asteroid;

pub struct Game {
    player: Player,
    asteroids: Vec<Asteroid>,
    score: f32,
    spawn_timer: f32,
    spawn_interval: f32,
}

impl Game {
    pub fn new() -> Self {
        Self {
            player: Player::new(),
            asteroids: Vec::new(),
            score: 0.0,
            spawn_timer: 0.0,
            spawn_interval: 3.0,
        }
    }

    pub fn update(&mut self, dt: f32) {
        self.player.update(dt);
        self.score += dt;

        self.spawn_timer += dt;
        if self.spawn_timer >= self.spawn_interval {
            self.spawn_timer = 0.0;
            self.asteroids.push(Asteroid::spawn());
            self.spawn_interval = (self.spawn_interval - 0.15).max(0.6);
        }

        for asteroid in &mut self.asteroids {
            asteroid.update(dt);
        }

        self.asteroids.retain(|a| !a.is_off_screen());
    }

    pub fn draw(&self) {
        clear_background(BLACK);

        draw_text(
            &format!("FPS: {}", get_fps()),
            screen_width() - 90.0, 24.0, 20.0, DARKGREEN,
        );

        for asteroid in &self.asteroids {
            asteroid.draw();
        }
        self.player.draw();

        draw_text(&format!("Score: {}", self.score as u32), 20.0, 24.0, 24.0, WHITE);
    }
}

Next steps

This tutorial gave you a flyable ship and a field of drifting asteroids. The follow-up tutorials build on this foundation: