Asteroid Dodger: Physics

intermediate Rustmacroquadgame devphysics
0 / 0

This tutorial picks up from the Shooting and Destruction tutorial. You have bullets, hit detection, and asteroid splitting. Now you’ll add real physics — asteroids will bounce off each other, the ship will get knocked around on contact, and a broad-phase optimization will keep the pair-checking loop fast.

What you’ll add

Milestones overview

Milestone 1Asteroid PhysicsMass, elastic bounce, split_at_mut
Milestone 2Ship PhysicsUnified elastic impulse for ship-asteroid hits
Milestone 3Broad PhaseAABB optimization for asteroid-asteroid physics

Prerequisites


Milestone 1 — Asteroid physics

Milestone 1 of 3

Step 1 — Give asteroids mass (src/asteroid.rs)

Add a mass method to impl Asteroid. Mass is proportional to radius squared — treating each asteroid as a 2D disc with uniform density:

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

A small asteroid (radius 20) has mass 400; a large one (radius 50) has mass 2500 — six times heavier. This means large asteroids barely deflect on collision while small ones scatter.

Step 2 — Elastic asteroid-asteroid collisions (src/asteroid.rs + src/game.rs)

Add a standalone function to src/asteroid.rs (outside impl Asteroid) that resolves all pairwise collisions for a slice of asteroids:

pub fn resolve_collisions(asteroids: &mut [Asteroid]) {
    let n = asteroids.len();
    for i in 0..n {
        for j in (i + 1)..n {
            // split_at_mut gives two non-overlapping mutable slices.
            // Since j > i, asteroids[i] is in `left` and asteroids[j]
            // is right[0] — the first element of the right half.
            let (left, right) = asteroids.split_at_mut(j);
            let a = &mut left[i];
            let b = &mut right[0];

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

            // Unit normal pointing from a to b
            let normal = (b.pos - a.pos) / dist;

            // Relative velocity of a with respect to b along the normal.
            // Positive = approaching, negative = separating (skip).
            let vel_along = (a.vel - b.vel).dot(normal);
            if vel_along <= 0.0 { continue; }

            // Elastic impulse: restitution = 1 (perfectly bouncy)
            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);

            // Positional correction: push apart so they don't overlap
            let overlap = min_dist - dist;
            let correction = normal * overlap * 0.5;
            a.pos -= correction;
            b.pos += correction;
        }
    }
}
split_at_mut

Rust prevents holding two mutable references into the same Vec simultaneously — the borrow checker can’t prove they don’t alias. split_at_mut(j) solves this by splitting the slice at index j and returning two non-overlapping &mut [T]. The compiler can then verify statically that left[i] and right[0] are distinct memory locations.

Elastic collision formula

For a perfectly elastic collision (restitution = 1), the impulse magnitude along the collision normal is j = 2 × v_rel / (1/m₁ + 1/m₂). Each object’s velocity changes by j / mass — heavier objects change less. The vel_along <= 0 guard skips pairs that are already separating so we never apply a repulsive impulse where none is needed.

Call it from src/game.rs after updating asteroid positions. First update the import:

// Replace:
use crate::asteroid::Asteroid;
// With:
use crate::asteroid::{self, Asteroid};

Then add the call in update, after the asteroid update loop and before update_bullets:

for asteroid in &mut self.asteroids {
    asteroid.update(dt);
}
asteroid::resolve_collisions(&mut self.asteroids); // add this
self.update_bullets();
Run it

cargo run — asteroids now bounce off each other. Dense clusters scatter on impact. The FPS meter shows the cost of the O(n²) pair loop — watch it as the asteroid count grows.


Milestone 2 — Unified elastic physics

Milestone 2 of 3

The ship currently gets pushed out of asteroids positionally but doesn’t bounce. This milestone replaces the positional-correction-only approach with the same elastic impulse model used by asteroid-asteroid collisions.

Step 3 — Ship mass and elastic response

src/player.rs — add a mass constant:

pub const PLAYER_MASS: f32 = 500.0;

Update the import in src/game.rs:

use crate::player::{Player, PLAYER_RADIUS, PLAYER_MASS};

Replace the body of update_collisions in src/game.rs with the elastic version. The key change: track player_vel as a local (same copy pattern as player_pos), and apply the elastic impulse formula rather than pure positional correction:

fn update_collisions(&mut self) {
    let player_aabb = self.player.aabb();
    let mut player_pos = self.player.pos;
    let mut player_vel = self.player.vel;  // track velocity too now
    let player_invincible = self.player.invincibility > 0.0;
    let mut took_damage = false;

    for asteroid in &mut self.asteroids {
        if !player_aabb.overlaps(&asteroid.aabb()) { continue; }
        if !collision::circle_hits_polygon(
            player_pos, PLAYER_RADIUS, asteroid.pos, &asteroid.vertices,
        ) { continue; }

        // Normal points from player toward asteroid (A→B convention,
        // matching the asteroid-asteroid code in asteroid.rs)
        let normal = (asteroid.pos - player_pos).normalize_or_zero();
        let vel_along = (player_vel - asteroid.vel).dot(normal);

        if vel_along > 0.0 {
            // Same elastic formula as asteroid::resolve_collisions
            let ma = asteroid.mass();
            let j = 2.0 * vel_along / (1.0 / PLAYER_MASS + 1.0 / ma);
            player_vel   -= normal * (j / PLAYER_MASS);
            asteroid.vel += normal * (j / ma);

            let overlap = PLAYER_RADIUS + asteroid.radius
                - player_pos.distance(asteroid.pos);
            if overlap > 0.0 {
                let correction = normal * overlap * 0.5;
                player_pos   -= correction;
                asteroid.pos += correction;
            }
        }

        if !player_invincible {
            took_damage = true;
        }
    }

    self.player.pos = player_pos;
    self.player.vel = player_vel;  // write velocity back

    if took_damage {
        self.player.hp -= 20.0;
        self.player.invincibility = 1.5;
        if self.player.hp <= 0.0 {
            self.state = GameState::GameOver;
        }
    }
}
Consistent conventions

The normal here points from player toward asteroid — the same A→B convention used in asteroid::resolve_collisions. Positive vel_along means the player is moving toward the asteroid. The impulse pushes the player backward (-= normal * j/mass) and the asteroid forward (+= normal * j/mass). Keeping one convention everywhere makes the physics easier to reason about and extend.

Run it

cargo run — flying into an asteroid now sends both the ship and the asteroid flying in physically plausible directions. Small asteroids scatter; large ones barely flinch and knock the ship aside instead.


Milestone 3 — Broad-phase asteroid physics

Milestone 3 of 3

The ship-asteroid collision code already uses an AABB broad phase. But resolve_collisions checks every pair with distance() directly. Add the same AABB guard.

Step 4 — Add AABB guard to resolve_collisions

Add a single AABB check at the top of the inner loop, before the distance calculation:

pub fn resolve_collisions(asteroids: &mut [Asteroid]) {
    let n = asteroids.len();
    for i in 0..n {
        for j in (i + 1)..n {
            let (left, right) = asteroids.split_at_mut(j);
            let a = &mut left[i];
            let b = &mut right[0];

            // Broad phase — skip pairs whose bounding boxes don't overlap
            if !a.aabb().overlaps(&b.aabb()) { continue; }

            let dist = a.pos.distance(b.pos);
            // ... rest unchanged
        }
    }
}

The AABB test involves four comparisons against precomputed min/max values — far cheaper than the distance() call which requires a subtraction, two multiplications, an addition, and a square root.

Run it

cargo run — behaviour is identical, but the FPS meter should hold steadier as asteroids pile up.


Complete listing

The project now has six source files:

src/
├── main.rs
├── collision.rs
├── player.rs
├── asteroid.rs
├── bullet.rs
└── game.rs

src/main.rs

use macroquad::prelude::*;

mod collision;
mod player;
mod asteroid;
mod bullet;
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/collision.rs

use macroquad::prelude::Vec2;

pub struct Aabb {
    pub min: Vec2,
    pub max: Vec2,
}

impl Aabb {
    pub fn from_center_radius(center: Vec2, radius: f32) -> Self {
        Self {
            min: center - Vec2::splat(radius),
            max: center + Vec2::splat(radius),
        }
    }

    pub fn overlaps(&self, other: &Aabb) -> bool {
        self.min.x <= other.max.x && self.max.x >= other.min.x
            && self.min.y <= other.max.y && self.max.y >= other.min.y
    }
}

pub fn closest_point_on_segment(a: Vec2, b: Vec2, p: Vec2) -> Vec2 {
    let ab = b - a;
    let t = ((p - a).dot(ab) / ab.length_squared()).clamp(0.0, 1.0);
    a + ab * t
}

pub fn point_in_polygon(point: Vec2, poly_pos: Vec2, vertices: &[Vec2]) -> bool {
    let n = vertices.len();
    let mut inside = false;
    let mut j = n - 1;
    for i in 0..n {
        let vi = poly_pos + vertices[i];
        let vj = poly_pos + vertices[j];
        if (vi.y > point.y) != (vj.y > point.y)
            && point.x < (vj.x - vi.x) * (point.y - vi.y) / (vj.y - vi.y) + vi.x
        {
            inside = !inside;
        }
        j = i;
    }
    inside
}

pub fn circle_hits_polygon(
    center: Vec2,
    radius: f32,
    poly_pos: Vec2,
    vertices: &[Vec2],
) -> bool {
    let n = vertices.len();
    for i in 0..n {
        let a = poly_pos + vertices[i];
        let b = poly_pos + vertices[(i + 1) % n];
        if center.distance(closest_point_on_segment(a, b, center)) < radius {
            return true;
        }
    }
    point_in_polygon(center, poly_pos, vertices)
}

src/player.rs

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

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

impl Player {
    pub fn new() -> Self {
        Self {
            pos: Vec2::new(screen_width() / 2.0, screen_height() / 2.0),
            vel: Vec2::ZERO,
            hp: 100.0,
            invincibility: 0.0,
            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; }

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

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

    pub fn spawn_bullet(&self) -> Bullet {
        let forward = Vec2::from_angle(self.angle);
        Bullet::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_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 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]) {
    let n = asteroids.len();
    for i in 0..n {
        for j in (i + 1)..n {
            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/bullet.rs

use macroquad::prelude::*;

const SPEED: f32 = 600.0;

pub struct Bullet {
    pub pos: Vec2,
    vel: Vec2,
}

impl Bullet {
    pub fn new(pos: Vec2, angle: f32) -> Self {
        Self { pos, vel: Vec2::from_angle(angle) * SPEED }
    }

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

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

    pub fn draw(&self) {
        draw_circle(self.pos.x, self.pos.y, 3.0, YELLOW);
    }
}

src/game.rs

use macroquad::prelude::*;
use crate::player::{Player, PLAYER_RADIUS, PLAYER_MASS};
use crate::asteroid::{self, Asteroid};
use crate::bullet::Bullet;
use crate::collision;

#[derive(PartialEq)]
enum GameState {
    Playing,
    GameOver,
}

pub struct Game {
    player: Player,
    asteroids: Vec<Asteroid>,
    bullets: Vec<Bullet>,
    score: f32,
    spawn_timer: f32,
    spawn_interval: f32,
    shoot_cooldown: f32,
    state: GameState,
}

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

    pub fn update(&mut self, dt: f32) {
        if self.state == GameState::GameOver {
            if is_key_pressed(KeyCode::R) { *self = Game::new(); }
            return;
        }

        self.player.update(dt);
        self.score += dt;

        self.shoot_cooldown -= dt;
        if is_key_down(KeyCode::Space) && self.shoot_cooldown <= 0.0 {
            self.bullets.push(self.player.spawn_bullet());
            self.shoot_cooldown = 0.15;
        }

        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);
        }
        asteroid::resolve_collisions(&mut self.asteroids);

        self.update_bullets();

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

    fn update_bullets(&mut self) {
        for bullet in &mut self.bullets {
            bullet.update(get_frame_time());
        }
        self.bullets.retain(|b| !b.is_off_screen());

        let mut asteroids_hit: Vec<usize> = Vec::new();
        self.bullets.retain(|bullet| {
            for (i, asteroid) in self.asteroids.iter().enumerate() {
                if collision::point_in_polygon(bullet.pos, asteroid.pos, &asteroid.vertices) {
                    if !asteroids_hit.contains(&i) {
                        asteroids_hit.push(i);
                    }
                    return false;
                }
            }
            true
        });

        let mut fragments: Vec<Asteroid> = Vec::new();
        for &i in &asteroids_hit {
            let asteroid = &self.asteroids[i];
            if asteroid.generation == 0 {
                let spread = Vec2::from_angle(rand::gen_range(0.0f32, std::f32::consts::TAU));
                fragments.push(Asteroid::spawn_fragment(
                    asteroid.pos, asteroid.vel, asteroid.radius, spread,
                ));
                fragments.push(Asteroid::spawn_fragment(
                    asteroid.pos, asteroid.vel, asteroid.radius, -spread,
                ));
            }
        }

        asteroids_hit.sort_unstable();
        for i in asteroids_hit.into_iter().rev() {
            self.asteroids.swap_remove(i);
        }
        self.asteroids.extend(fragments);
    }

    fn update_collisions(&mut self) {
        let player_aabb = self.player.aabb();
        let mut player_pos = self.player.pos;
        let mut player_vel = self.player.vel;
        let player_invincible = self.player.invincibility > 0.0;
        let mut took_damage = false;

        for asteroid in &mut self.asteroids {
            if !player_aabb.overlaps(&asteroid.aabb()) { continue; }
            if !collision::circle_hits_polygon(
                player_pos, PLAYER_RADIUS, asteroid.pos, &asteroid.vertices,
            ) { continue; }

            let normal = (asteroid.pos - player_pos).normalize_or_zero();
            let vel_along = (player_vel - asteroid.vel).dot(normal);

            if vel_along > 0.0 {
                let ma = asteroid.mass();
                let j = 2.0 * vel_along / (1.0 / PLAYER_MASS + 1.0 / ma);
                player_vel   -= normal * (j / PLAYER_MASS);
                asteroid.vel += normal * (j / ma);

                let overlap = PLAYER_RADIUS + asteroid.radius
                    - player_pos.distance(asteroid.pos);
                if overlap > 0.0 {
                    let correction = normal * overlap * 0.5;
                    player_pos   -= correction;
                    asteroid.pos += correction;
                }
            }

            if !player_invincible {
                took_damage = true;
            }
        }

        self.player.pos = player_pos;
        self.player.vel = player_vel;

        if took_damage {
            self.player.hp -= 20.0;
            self.player.invincibility = 1.5;
            if self.player.hp <= 0.0 {
                self.state = GameState::GameOver;
            }
        }
    }

    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();
        }
        for bullet in &self.bullets {
            bullet.draw();
        }
        self.player.draw();

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

        draw_rectangle(20.0, 36.0, 200.0, 16.0, DARKGRAY);
        let fill_width = 200.0 * (self.player.hp / 100.0).clamp(0.0, 1.0);
        let bar_color = if self.player.hp > 60.0 { GREEN }
                        else if self.player.hp > 30.0 { YELLOW }
                        else { RED };
        draw_rectangle(20.0, 36.0, fill_width, 16.0, bar_color);
        draw_rectangle_lines(20.0, 36.0, 200.0, 16.0, 1.5, WHITE);

        if self.state == GameState::GameOver {
            let msg = format!("Game Over — Score: {}", self.score as u32);
            draw_text(&msg, screen_width() / 2.0 - 180.0, screen_height() / 2.0, 40.0, WHITE);
            draw_text("Press R to restart", screen_width() / 2.0 - 110.0, screen_height() / 2.0 + 50.0, 24.0, GRAY);
        }
    }
}

Next steps