Asteroid Dodger: Shooting and Destruction
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
- Bullet type and firing mechanic
- Bullet-asteroid hit detection
- Asteroid splitting into fragments on destruction
Milestones overview
Prerequisites
- Completed the Collision Detection tutorial
- Project compiles with collision detection, HP bar, and game-over loop
Milestone 1 — Shooting
Milestone 1 of 2Step 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();
}
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.
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 2Large 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);
}
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.
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
- Borrowing and Ownership Patterns — deep dive into the borrow checker patterns used in this game
- Physics — elastic asteroid-asteroid and ship-asteroid bounce physics