Asteroid Dodger: Sound and Particle Effects

intermediate Rustmacroquadgame devaudioparticles
0 / 0

This tutorial picks up where the collisions and physics tutorial left off. You have shooting, elastic bounce physics, and asteroid splitting. The game plays well, but it feels flat without audio or visual feedback. By the end of this tutorial, lasers will chirp, explosions will rumble, and particle bursts will mark every impact — all generated procedurally with no external asset files.

What you’ll add

Milestones overview

Milestone 1Sound EffectsProcedural WAV generation, async loading
Milestone 2Particle EffectsParticle pool, bursts on impact and damage

Prerequisites


Milestone 1 — Sound effects

Milestone 1 of 2

Step 1 — Enable audio and create src/sound.rs

macroquad’s audio support is behind a feature flag — it’s not included by default. Enable it:

cargo add macroquad --features audio

This updates Cargo.toml to macroquad = { version = "0.4", features = ["audio"] }. Without this, macroquad::audio won’t exist and the next step won’t compile.

Register the new module in main.rs:

mod sound;

The plan: generate WAV audio data entirely in memory. No .wav files on disk, no asset folder. A WAV file is a 44-byte header followed by raw PCM samples — one of the simplest audio formats.

WAV format

A WAV file starts with a 44-byte RIFF/WAVE header that describes the sample rate, bit depth, and data length. After the header, raw 16-bit signed integers represent the waveform. Because the format is so simple, you can build a valid WAV file with nothing but byte slicing and to_le_bytes().

Create src/sound.rs with three helper functions and a Sounds struct:

// src/sound.rs
use macroquad::audio::{Sound, load_sound_from_bytes, play_sound, PlaySoundParams};

const SAMPLE_RATE: u32 = 44100;

fn generate_wav(samples: &[i16]) -> Vec<u8> {
    let data_len = (samples.len() * 2) as u32;
    let file_len = 36 + data_len;
    let mut buf = Vec::with_capacity(file_len as usize + 8);
    // RIFF header
    buf.extend_from_slice(b"RIFF");
    buf.extend_from_slice(&file_len.to_le_bytes());
    buf.extend_from_slice(b"WAVE");
    // fmt subchunk: PCM, mono, 44100 Hz, 16-bit
    buf.extend_from_slice(b"fmt ");
    buf.extend_from_slice(&16u32.to_le_bytes());   // subchunk size
    buf.extend_from_slice(&1u16.to_le_bytes());    // PCM format
    buf.extend_from_slice(&1u16.to_le_bytes());    // mono
    buf.extend_from_slice(&SAMPLE_RATE.to_le_bytes());
    buf.extend_from_slice(&(SAMPLE_RATE * 2).to_le_bytes()); // byte rate
    buf.extend_from_slice(&2u16.to_le_bytes());    // block align
    buf.extend_from_slice(&16u16.to_le_bytes());   // bits per sample
    // data subchunk
    buf.extend_from_slice(b"data");
    buf.extend_from_slice(&data_len.to_le_bytes());
    for &s in samples {
        buf.extend_from_slice(&s.to_le_bytes());
    }
    buf
}

fn sine_samples(freq: f32, duration: f32, volume: f32) -> Vec<i16> {
    let n = (SAMPLE_RATE as f32 * duration) as usize;
    (0..n)
        .map(|i| {
            let t = i as f32 / SAMPLE_RATE as f32;
            let fade = 1.0 - (t / duration);
            let sample = (t * freq * std::f32::consts::TAU).sin() * volume * fade;
            (sample * i16::MAX as f32) as i16
        })
        .collect()
}

fn noise_samples(duration: f32, volume: f32) -> Vec<i16> {
    let n = (SAMPLE_RATE as f32 * duration) as usize;
    (0..n)
        .map(|i| {
            let t = i as f32 / SAMPLE_RATE as f32;
            let fade = 1.0 - (t / duration);
            let noise = macroquad::rand::gen_range(-1.0f32, 1.0);
            (noise * volume * fade * i16::MAX as f32) as i16
        })
        .collect()
}

pub struct Sounds {
    pub shoot: Sound,
    pub explode: Sound,
    pub damage: Sound,
    pub thrust: Sound,
    pub volume: f32,
}

impl Sounds {
    pub async fn load() -> Self {
        let shoot_wav = generate_wav(&sine_samples(880.0, 0.08, 0.3));
        let explode_wav = generate_wav(&noise_samples(0.2, 0.4));
        let damage_wav = generate_wav(&sine_samples(220.0, 0.25, 0.5));
        let thrust_wav = generate_wav(&noise_samples(0.1, 0.15));
        Self {
            shoot: load_sound_from_bytes(&shoot_wav).await.unwrap(),
            explode: load_sound_from_bytes(&explode_wav).await.unwrap(),
            damage: load_sound_from_bytes(&damage_wav).await.unwrap(),
            thrust: load_sound_from_bytes(&thrust_wav).await.unwrap(),
            volume: 0.5,
        }
    }

    pub fn play(&self, sound: &Sound) {
        play_sound(sound, PlaySoundParams { looped: false, volume: self.volume });
    }
}

Each sound uses a different generator:

Both generators apply a linear fade-out (1.0 - t/duration) so sounds don’t clip at the end.

Async load

load_sound_from_bytes is async because macroquad’s audio backend initialises lazily. The .await lets the runtime finish setup before returning the Sound handle. This is why Sounds::load() is pub async fn — it must be called from an async context.

Note that play constructs PlaySoundParams with explicit fields rather than using ..Default::default(). In macroquad 0.4, PlaySoundParams does not implement Default, so the spread syntax won’t compile.

Step 2 — Wire sounds into the game

src/main.rs — load sounds before creating the game, and pass them in:

use macroquad::prelude::*;

mod collision;
mod player;
mod asteroid;
mod bullet;
mod game;
mod sound;

use game::Game;
use sound::Sounds;

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

src/game.rs — add the Sounds import and field:

use crate::sound::Sounds;

Add sounds: Sounds to the Game struct and update new to accept it:

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

impl Game {
    pub fn new(sounds: Sounds) -> 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,
            sounds,
        }
    }

The previous tutorial used *self = Game::new() to restart, but Game::new now requires a Sounds argument. Rather than passing sounds through the restart path, add a reset method that clears gameplay state while preserving the sounds field:

pub fn reset(&mut self) {
    self.player = Player::new();
    self.asteroids.clear();
    self.bullets.clear();
    self.score = 0.0;
    self.spawn_timer = 0.0;
    self.spawn_interval = 3.0;
    self.shoot_cooldown = 0.0;
    self.state = GameState::Playing;
}

Update the game-over guard to use reset:

if self.state == GameState::GameOver {
    if is_key_pressed(KeyCode::R) { self.reset(); }
    return;
}

Now play sounds at the right moments. In update, after the game-over guard:

// Thrust sound
if is_key_down(KeyCode::W) {
    self.sounds.play(&self.sounds.thrust.clone());
}

// Shooting
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.sounds.play(&self.sounds.shoot.clone());
}

In update_bullets, after removing hit asteroids, play the explosion sound:

// At the end of update_bullets, after swap_remove loop:
if !asteroids_hit.is_empty() {
    self.sounds.play(&self.sounds.explode.clone());
}

In update_collisions, when damage is taken:

if took_damage {
    self.player.hp -= 20.0;
    self.player.invincibility = 1.5;
    self.sounds.play(&self.sounds.damage.clone());
    if self.player.hp <= 0.0 {
        self.state = GameState::GameOver;
    }
}
Why .clone() on Sound?

Sound in macroquad is a lightweight handle — internally it’s a reference-counted pointer to the audio backend’s buffer. Cloning it doesn’t duplicate the audio data; it copies a small handle. We clone here because self.sounds is borrowed through &self, and play takes &self too — cloning the handle into a temporary avoids conflicting borrows on self.sounds.

Run it

cargo run — hold W to hear thrust hiss, press Space for laser chirps, shoot an asteroid for an explosion burst, and fly into one for a damage thud.


Milestone 2 — Particle effects

Milestone 2 of 2

Step 3 — Create src/particle.rs

Register the module in main.rs:

mod particle;

The particle system is a simple pool: each particle has a position, velocity, lifetime, size, and color. Every frame, particles move, slow down via drag, shrink, and fade. Dead particles are removed.

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

pub struct Particle {
    pos: Vec2,
    vel: Vec2,
    lifetime: f32,
    max_lifetime: f32,
    radius: f32,
    color: Color,
}

impl Particle {
    pub fn new(pos: Vec2, vel: Vec2, lifetime: f32, radius: f32, color: Color) -> Self {
        Self { pos, vel, lifetime, max_lifetime: lifetime, radius, color }
    }

    pub fn is_alive(&self) -> bool {
        self.lifetime > 0.0
    }
}

pub struct Particles {
    pool: Vec<Particle>,
}

impl Particles {
    pub fn new() -> Self {
        Self { pool: Vec::new() }
    }

    pub fn update(&mut self, dt: f32) {
        for p in &mut self.pool {
            p.pos += p.vel * dt;
            p.vel *= 1.0 - 3.0 * dt;
            p.lifetime -= dt;
        }
        self.pool.retain(|p| p.is_alive());
    }

    pub fn draw(&self) {
        for p in &self.pool {
            let t = p.lifetime / p.max_lifetime;
            let r = p.radius * t;
            let mut c = p.color;
            c.a = t;
            draw_circle(p.pos.x, p.pos.y, r, c);
        }
    }

    pub fn burst(&mut self, pos: Vec2, count: usize, speed: f32, color: Color) {
        for _ in 0..count {
            let angle = rand::gen_range(0.0f32, std::f32::consts::TAU);
            let spd = rand::gen_range(speed * 0.3, speed);
            let vel = Vec2::from_angle(angle) * spd;
            let lifetime = rand::gen_range(0.3f32, 0.8);
            let radius = rand::gen_range(1.5f32, 4.0);
            self.pool.push(Particle::new(pos, vel, lifetime, radius, color));
        }
    }
}

The key design choices:

Step 4 — Emit particles from game.rs

Add the import and field:

use crate::particle::Particles;
pub struct Game {
    // ... existing fields ...
    particles: Particles,
}

// In new():
particles: Particles::new(),

Update reset to clear the particle pool:

pub fn reset(&mut self) {
    // ... existing resets ...
    self.particles = Particles::new();
}

Call particles.update(dt) in the update method, after update_bullets:

self.update_bullets();
self.particles.update(dt);

Emit particles on asteroid destruction. In update_bullets, capture hit positions before removing asteroids. The key change is collecting (pos, radius) pairs from hit asteroids, then iterating them by reference after removal:

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

    // Capture positions and spawn fragments before removal
    let mut fragments: Vec<Asteroid> = Vec::new();
    let mut hit_positions: Vec<(Vec2, f32)> = Vec::new();
    for &i in &asteroids_hit {
        let asteroid = &self.asteroids[i];
        hit_positions.push((asteroid.pos, asteroid.radius));
        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);

    // Burst particles at each hit position
    for &(pos, radius) in &hit_positions {
        let count = (radius * 0.8) as usize;
        self.particles.burst(pos, count, radius * 2.0, LIGHTGRAY);
    }
    if !hit_positions.is_empty() {
        self.sounds.play(&self.sounds.explode.clone());
    }
}
Why collect hit_positions separately?

We need asteroid positions to spawn particles, but swap_remove destroys the asteroids. Collecting (pos, radius) into a separate Vec preserves the data past removal. Note the iteration uses for &(pos, radius) in &hit_positions — destructuring by reference. Using for (pos, radius) in hit_positions would move the Vec, consuming it before the .is_empty() check on the next line.

Emit particles on player damage. In update_collisions, burst orange particles when the player takes a hit:

if took_damage {
    self.player.hp -= 20.0;
    self.player.invincibility = 1.5;
    self.particles.burst(self.player.pos, 12, 80.0, ORANGE);
    self.sounds.play(&self.sounds.damage.clone());
    if self.player.hp <= 0.0 {
        self.state = GameState::GameOver;
    }
}

Draw particles. In Game::draw, render particles after clear_background but before asteroids so they appear behind solid objects:

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

    for asteroid in &self.asteroids {
        asteroid.draw();
    }
    // ... rest of draw unchanged
}
Run it

cargo run — shoot an asteroid and gray particles scatter from the impact point. Larger asteroids produce more particles. Fly into an asteroid and orange sparks burst from the ship.


Complete listing

The project now has eight source files:

src/
├── main.rs
├── collision.rs
├── player.rs
├── asteroid.rs
├── bullet.rs
├── game.rs
├── sound.rs      (new)
└── particle.rs   (new)

collision.rs, player.rs, asteroid.rs, and bullet.rs are unchanged from the collisions and physics tutorial.

src/main.rs

use macroquad::prelude::*;

mod collision;
mod player;
mod asteroid;
mod bullet;
mod game;
mod sound;
mod particle;

use game::Game;
use sound::Sounds;

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

src/sound.rs

use macroquad::audio::{Sound, load_sound_from_bytes, play_sound, PlaySoundParams};

const SAMPLE_RATE: u32 = 44100;

fn generate_wav(samples: &[i16]) -> Vec<u8> {
    let data_len = (samples.len() * 2) as u32;
    let file_len = 36 + data_len;
    let mut buf = Vec::with_capacity(file_len as usize + 8);
    buf.extend_from_slice(b"RIFF");
    buf.extend_from_slice(&file_len.to_le_bytes());
    buf.extend_from_slice(b"WAVE");
    buf.extend_from_slice(b"fmt ");
    buf.extend_from_slice(&16u32.to_le_bytes());
    buf.extend_from_slice(&1u16.to_le_bytes());
    buf.extend_from_slice(&1u16.to_le_bytes());
    buf.extend_from_slice(&SAMPLE_RATE.to_le_bytes());
    buf.extend_from_slice(&(SAMPLE_RATE * 2).to_le_bytes());
    buf.extend_from_slice(&2u16.to_le_bytes());
    buf.extend_from_slice(&16u16.to_le_bytes());
    buf.extend_from_slice(b"data");
    buf.extend_from_slice(&data_len.to_le_bytes());
    for &s in samples {
        buf.extend_from_slice(&s.to_le_bytes());
    }
    buf
}

fn sine_samples(freq: f32, duration: f32, volume: f32) -> Vec<i16> {
    let n = (SAMPLE_RATE as f32 * duration) as usize;
    (0..n)
        .map(|i| {
            let t = i as f32 / SAMPLE_RATE as f32;
            let fade = 1.0 - (t / duration);
            let sample = (t * freq * std::f32::consts::TAU).sin() * volume * fade;
            (sample * i16::MAX as f32) as i16
        })
        .collect()
}

fn noise_samples(duration: f32, volume: f32) -> Vec<i16> {
    let n = (SAMPLE_RATE as f32 * duration) as usize;
    (0..n)
        .map(|i| {
            let t = i as f32 / SAMPLE_RATE as f32;
            let fade = 1.0 - (t / duration);
            let noise = macroquad::rand::gen_range(-1.0f32, 1.0);
            (noise * volume * fade * i16::MAX as f32) as i16
        })
        .collect()
}

pub struct Sounds {
    pub shoot: Sound,
    pub explode: Sound,
    pub damage: Sound,
    pub thrust: Sound,
    pub volume: f32,
}

impl Sounds {
    pub async fn load() -> Self {
        let shoot_wav = generate_wav(&sine_samples(880.0, 0.08, 0.3));
        let explode_wav = generate_wav(&noise_samples(0.2, 0.4));
        let damage_wav = generate_wav(&sine_samples(220.0, 0.25, 0.5));
        let thrust_wav = generate_wav(&noise_samples(0.1, 0.15));
        Self {
            shoot: load_sound_from_bytes(&shoot_wav).await.unwrap(),
            explode: load_sound_from_bytes(&explode_wav).await.unwrap(),
            damage: load_sound_from_bytes(&damage_wav).await.unwrap(),
            thrust: load_sound_from_bytes(&thrust_wav).await.unwrap(),
            volume: 0.5,
        }
    }

    pub fn play(&self, sound: &Sound) {
        play_sound(sound, PlaySoundParams { looped: false, volume: self.volume });
    }
}

src/particle.rs

use macroquad::prelude::*;

pub struct Particle {
    pos: Vec2,
    vel: Vec2,
    lifetime: f32,
    max_lifetime: f32,
    radius: f32,
    color: Color,
}

impl Particle {
    pub fn new(pos: Vec2, vel: Vec2, lifetime: f32, radius: f32, color: Color) -> Self {
        Self { pos, vel, lifetime, max_lifetime: lifetime, radius, color }
    }

    pub fn is_alive(&self) -> bool {
        self.lifetime > 0.0
    }
}

pub struct Particles {
    pool: Vec<Particle>,
}

impl Particles {
    pub fn new() -> Self {
        Self { pool: Vec::new() }
    }

    pub fn update(&mut self, dt: f32) {
        for p in &mut self.pool {
            p.pos += p.vel * dt;
            p.vel *= 1.0 - 3.0 * dt;
            p.lifetime -= dt;
        }
        self.pool.retain(|p| p.is_alive());
    }

    pub fn draw(&self) {
        for p in &self.pool {
            let t = p.lifetime / p.max_lifetime;
            let r = p.radius * t;
            let mut c = p.color;
            c.a = t;
            draw_circle(p.pos.x, p.pos.y, r, c);
        }
    }

    pub fn burst(&mut self, pos: Vec2, count: usize, speed: f32, color: Color) {
        for _ in 0..count {
            let angle = rand::gen_range(0.0f32, std::f32::consts::TAU);
            let spd = rand::gen_range(speed * 0.3, speed);
            let vel = Vec2::from_angle(angle) * spd;
            let lifetime = rand::gen_range(0.3f32, 0.8);
            let radius = rand::gen_range(1.5f32, 4.0);
            self.pool.push(Particle::new(pos, vel, lifetime, radius, color));
        }
    }
}

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;
use crate::sound::Sounds;
use crate::particle::Particles;

#[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,
    sounds: Sounds,
    particles: Particles,
}

impl Game {
    pub fn new(sounds: Sounds) -> 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,
            sounds,
            particles: Particles::new(),
        }
    }

    pub fn reset(&mut self) {
        self.player = Player::new();
        self.asteroids.clear();
        self.bullets.clear();
        self.score = 0.0;
        self.spawn_timer = 0.0;
        self.spawn_interval = 3.0;
        self.shoot_cooldown = 0.0;
        self.state = GameState::Playing;
        self.particles = Particles::new();
    }

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

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

        if is_key_down(KeyCode::W) {
            self.sounds.play(&self.sounds.thrust.clone());
        }

        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.sounds.play(&self.sounds.shoot.clone());
        }

        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.particles.update(dt);

        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();
        let mut hit_positions: Vec<(Vec2, f32)> = Vec::new();
        for &i in &asteroids_hit {
            let asteroid = &self.asteroids[i];
            hit_positions.push((asteroid.pos, asteroid.radius));
            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);

        for &(pos, radius) in &hit_positions {
            let count = (radius * 0.8) as usize;
            self.particles.burst(pos, count, radius * 2.0, LIGHTGRAY);
        }
        if !hit_positions.is_empty() {
            self.sounds.play(&self.sounds.explode.clone());
        }
    }

    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;
            self.particles.burst(self.player.pos, 12, 80.0, ORANGE);
            self.sounds.play(&self.sounds.damage.clone());
            if self.player.hp <= 0.0 {
                self.state = GameState::GameOver;
            }
        }
    }

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

        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