Asteroid Dodger: Shooting and Destruction

intermediate Rustmacroquadgame devshooting
0 / 0

This tutorial picks up from the Collision Detection tutorial (or the Unit Testing tutorial if you completed that). You have a ship that takes damage from asteroids, an HP bar, and a game-over loop. Now you’ll give the player a weapon and make asteroids break apart when shot.

What you’ll add

Milestones overview

Milestone 1ShootingBullet type, fire + hit detection
Milestone 2SplittingGeneration field, split on hit

Prerequisites


Milestone 1 — Shooting

Milestone 1 of 2

Step 1 — Bullet type (src/bullet.rs)

Create src/bullet.rs and register it in main.rs:

mod bullet;
// 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);
    }
}

Step 2 — Fire and hit detection

src/player.rs — add spawn_bullet. Add the import at the top and the method to impl Player:

use crate::bullet::Bullet;
pub fn spawn_bullet(&self) -> Bullet {
    let forward = Vec2::from_angle(self.angle);
    Bullet::new(self.pos + forward * 22.0, self.angle)
}

The bullet spawns at the tip of the ship and travels in the direction it’s facing. angle is private, but spawn_bullet lives in the same module, so it can access it.

src/game.rs — add bullets and shoot_cooldown fields:

use crate::bullet::Bullet;
pub struct Game {
    // ... existing fields ...
    bullets: Vec<Bullet>,
    shoot_cooldown: f32,
}

// In new():
bullets: Vec::new(),
shoot_cooldown: 0.0,

Add shooting input at the top of update (after the game-over guard):

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; // ~7 shots per second
}

Add an update_bullets method (shown below), then call it from update after the asteroid loop and before self.asteroids.retain:

// In update(), the call order should be:
for asteroid in &mut self.asteroids {
    asteroid.update(dt);
}
self.update_bullets();              // add this line
self.asteroids.retain(|a| !a.is_off_screen());
self.update_collisions();

The method itself:

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

    // Find which asteroids were hit
    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; // consume the bullet
            }
        }
        true // bullet survives
    });

    // Remove hit asteroids — iterate in reverse so earlier indices stay valid
    asteroids_hit.sort_unstable();
    for i in asteroids_hit.into_iter().rev() {
        self.asteroids.swap_remove(i);
    }
}

Add bullet draw calls to Game::draw:

for bullet in &self.bullets {
    bullet.draw();
}
swap_remove

Vec::swap_remove(i) removes element i by swapping it with the last element, then popping — O(1) rather than the O(n) shift of remove(i). Order is not preserved, which is fine for an unordered pool of asteroids. We iterate indices in reverse so that each removal doesn’t shift the indices we haven’t processed yet.

Run it

cargo run — hold Space to fire. Yellow dots travel in the direction the ship faces and destroy any asteroid they enter.


Milestone 2 — Asteroid splitting

Milestone 2 of 2

Large asteroids currently vanish when shot. This milestone makes them split into two smaller fragments. Second-generation fragments are destroyed on hit — no infinite splitting.

Step 3 — Add generation field and fragment constructor (src/asteroid.rs)

Add a generation field to the Asteroid struct:

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

Update spawn() to initialise the new field:

Asteroid { pos, vel: dir * speed, radius, vertices, generation: 0 }

Add a spawn_fragment constructor below spawn():

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

Step 4 — Split on hit (src/game.rs)

Replace update_bullets to split generation-0 asteroids instead of destroying them:

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

    // Collect fragments from generation-0 asteroids before removing
    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);
}
Why collect fragments separately?

We can’t push new asteroids into self.asteroids while iterating asteroids_hit indices — the push might reallocate the Vec, invalidating the indices. Collecting fragments into a separate Vec and calling extend after all removals keeps the logic clean and the indices valid.

Run it

cargo run — shoot a large asteroid and it breaks into two smaller pieces that scatter in opposite directions. Shoot a fragment and it’s destroyed.


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;

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 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/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};
use crate::asteroid::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);
        }

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

            // Push player fully out of the asteroid
            let normal = (player_pos - asteroid.pos).normalize_or_zero();
            let overlap = PLAYER_RADIUS + asteroid.radius - player_pos.distance(asteroid.pos);
            if overlap > 0.0 {
                player_pos += normal * overlap;
            }

            if !player_invincible {
                took_damage = true;
            }
        }

        self.player.pos = player_pos;

        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