Build an Asteroid Dodger in Rust with macroquad
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
Prerequisites
What you need to know:
What you need installed:
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 4Step 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:
mod foo;inmain.rstells the compiler thatsrc/foo.rsis a module namedfoo. You write this once in the owning file.pub— everything in Rust is private to its module by default.pubopts a struct, field, function, or constant into being visible from other modules.use crate::foo::Bar;— bringsBarfrom modulefoointo scope.crate::means “start from the root of this project”, equivalent to a package path in Go or an absolute import in Python.
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;
}
}
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.
cargo run — a black window titled “Asteroid Dodger” should open.
Milestone 2 — A ship on screen
Milestone 2 of 4Step 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).
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();
}
}
cargo run — a white triangle appears in the centre of the window.
Milestone 3 — A moving ship
Milestone 3 of 4Step 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.
cargo run — A/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 4Step 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<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);
}
}
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.
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:
- Collision Detection — AABB broad phase, polygon-circle narrow phase, HP bar, game over and restart