Asteroid Dodger: Menu, Power-ups, and Shop

intermediate Rustmacroquadgame devUIgameplay
0 / 0

This tutorial picks up where the traits and generics tutorial left off. The game has solid mechanics — asteroids collide, split, and explode — but there is no title screen, no way to change controls, and destroying asteroids yields nothing but particle effects. This tutorial adds gameplay progression and UI: collectible power-ups that drop from destroyed asteroids, a main menu with settings, rebindable keybindings, a between-rounds shop, and high score tracking.

What you’ll add

Milestones overview

Milestone 1Power-upsCollectible drops from destroyed asteroids
Milestone 2Main MenuTitle screen, game over, restart flow
Milestone 3KeybindingsAction enum, rebindable controls
Milestone 4ShopBetween-rounds upgrade store
Milestone 5High ScoreLive tracking on HUD and menu

Prerequisites


Milestone 1 — Power-ups

Milestone 1 of 5

Destroying asteroids currently yields nothing but particle effects. This milestone adds collectible power-ups that drop from destroyed asteroids — four types, each with a distinct color, that the player picks up on contact.

Step 1 — Power-up module (src/powerup.rs)

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

mod powerup;
// src/powerup.rs
use macroquad::prelude::*;

#[derive(Clone, Copy, PartialEq)]
pub enum PowerUpType {
    WeaponUpgrade,  // cycles to next weapon
    BombRefill,     // +2 bombs
    Shield,         // 5 seconds invincibility
    ScoreBoost,     // 2x score for 10 seconds
}

impl PowerUpType {
    pub fn color(&self) -> Color {
        match self {
            Self::WeaponUpgrade => GREEN,
            Self::BombRefill => RED,
            Self::Shield => BLUE,
            Self::ScoreBoost => YELLOW,
        }
    }

    pub fn label(&self) -> &'static str {
        match self {
            Self::WeaponUpgrade => "Weapon!",
            Self::BombRefill => "Bombs!",
            Self::Shield => "Shield!",
            Self::ScoreBoost => "2x Score!",
        }
    }

    pub fn random() -> Self {
        match rand::gen_range(0u32, 4) {
            0 => Self::WeaponUpgrade,
            1 => Self::BombRefill,
            2 => Self::Shield,
            _ => Self::ScoreBoost,
        }
    }
}

pub struct PowerUp {
    pub pos: Vec2,
    pub kind: PowerUpType,
    pub lifetime: f32,
    spin: f32,
}

impl PowerUp {
    pub fn new(pos: Vec2) -> Self {
        Self {
            pos,
            kind: PowerUpType::random(),
            lifetime: 8.0,
            spin: 0.0,
        }
    }

    pub fn update(&mut self, dt: f32) {
        self.lifetime -= dt;
        self.spin += dt * 3.0;
    }

    pub fn is_expired(&self) -> bool {
        self.lifetime <= 0.0
    }

    pub fn draw(&self) {
        let size = 12.0;
        let color = self.kind.color();
        let cos = self.spin.cos();
        let sin = self.spin.sin();

        // Diamond shape: two triangles rotated by spin
        let top = self.pos + Vec2::new(cos, sin) * size;
        let bottom = self.pos - Vec2::new(cos, sin) * size;
        let left = self.pos + Vec2::new(-sin, cos) * size * 0.6;
        let right = self.pos - Vec2::new(-sin, cos) * size * 0.6;

        draw_triangle(top, left, right, color);
        draw_triangle(bottom, left, right, color);

        // Blink when about to expire
        if self.lifetime < 2.0 && (self.lifetime * 6.0) as u32 % 2 == 0 {
            draw_triangle(top, left, right, WHITE);
            draw_triangle(bottom, left, right, WHITE);
        }
    }
}

Each power-up type has a distinct color so the player can identify drops at a glance: green for weapon, red for bombs, blue for shield, yellow for score. The diamond shape spins over time and blinks white in its final 2 seconds before despawning.

Step 2 — Collecting power-ups (src/game.rs)

Wire power-ups into the game. First, add the imports and new fields:

use crate::powerup::{PowerUp, PowerUpType};

Add to Game:

powerups: Vec<PowerUp>,
score_multiplier: f32,
score_boost_timer: f32,

Initialise in new() and reset():

powerups: Vec::new(),
score_multiplier: 1.0,
score_boost_timer: 0.0,

Spawning drops — in update_bullets, after the loop that collects hit_positions, add a power-up spawn roll for each destroyed asteroid:

for &(pos, _radius) in &hit_positions {
    if rand::gen_range(0u32, 100) < 25 {
        self.powerups.push(PowerUp::new(pos));
    }
}

Do the same inside update_bombs, after the asteroid destruction loop in for pos in &exploded_positions:

for &(pos, _radius) in &hit_positions_for_drops {
    if rand::gen_range(0u32, 100) < 25 {
        self.powerups.push(PowerUp::new(pos));
    }
}

To collect hit_positions_for_drops inside the bomb explosion loop, record each destroyed asteroid’s position alongside the existing hit_indices logic:

let mut hit_positions_for_drops: Vec<(Vec2, f32)> = Vec::new();
// inside the asteroid loop:
hit_positions_for_drops.push((asteroid.pos, asteroid.radius));

Collecting power-ups — add an update_powerups method:

fn update_powerups(&mut self, dt: f32) {
    for powerup in &mut self.powerups {
        powerup.update(dt);
    }

    let player_pos = self.player.pos;
    let collect_dist = crate::player::PLAYER_RADIUS + 12.0;

    // Process collections separately to satisfy the borrow checker
    let mut collected: Vec<PowerUpType> = Vec::new();
    let mut remaining: Vec<PowerUp> = Vec::new();
    for powerup in self.powerups.drain(..) {
        if powerup.pos.distance(player_pos) < collect_dist {
            collected.push(powerup.kind);
        } else if !powerup.is_expired() {
            remaining.push(powerup);
        }
    }
    self.powerups = remaining;

    for kind in collected {
        match kind {
            PowerUpType::WeaponUpgrade => {
                let idx = WeaponType::ALL.iter()
                    .position(|w| *w == self.current_weapon)
                    .unwrap_or(0);
                self.current_weapon = WeaponType::ALL[(idx + 1) % WeaponType::ALL.len()];
            }
            PowerUpType::BombRefill => {
                self.bomb_count += 2;
            }
            PowerUpType::Shield => {
                self.player.invincibility = 5.0;
            }
            PowerUpType::ScoreBoost => {
                self.score_multiplier = 2.0;
                self.score_boost_timer = 10.0;
            }
        }
        self.particles.burst(player_pos, 8, 40.0, kind.color());
    }
}

The method drains all power-ups, sorts them into collected vs remaining, then applies effects. This avoids borrowing self while iterating.

Score multiplier decay — in the main update method, replace self.score += dt; with:

self.score += dt * self.score_multiplier;
if self.score_boost_timer > 0.0 {
    self.score_boost_timer -= dt;
    if self.score_boost_timer <= 0.0 {
        self.score_multiplier = 1.0;
    }
}

Call update_powerups from update, after update_bombs:

self.update_bombs();
self.update_powerups(dt);

Drawing — add power-up rendering in Game::draw, alongside other game objects:

for powerup in &self.powerups {
    powerup.draw();
}

Add a HUD indicator when score boost is active, below the bomb count:

if self.score_boost_timer > 0.0 {
    draw_text(
        &format!("2x SCORE {:.0}s", self.score_boost_timer),
        20.0, 106.0, 20.0, YELLOW,
    );
}
Run it

cargo run — destroy asteroids and watch for spinning diamond drops. Green diamonds cycle your weapon, red ones add 2 bombs, blue grants 5 seconds of shield (the ship blinks), and yellow doubles your score rate with a HUD countdown. Drops despawn after 8 seconds, blinking white as warning.

Drop balance

A 25% drop rate with 4 equally-weighted types means each specific power-up appears roughly 6% of the time. This feels rewarding without flooding the screen. Adjust the threshold in gen_range(0u32, 100) < 25 to tune drop frequency.


Milestone 2 — Menu screen

Milestone 2 of 5

Right now the game drops straight into gameplay. This milestone adds a title screen, a proper game-over screen, and the state machine that ties it all together.

Step 3 — Add menu and game-over states

Expand the GameState enum in src/game.rs:

#[derive(PartialEq)]
enum GameState {
    Menu,
    Playing,
    GameOver,
    Settings,
    AwaitingKey(Action),
}

Settings and AwaitingKey won’t be used until Milestone 3, but adding them now avoids revisiting the enum later.

Add a high_score: u32 field to Game:

// In Game struct:
high_score: u32,

// In new():
high_score: 0,

Change the initial state from Playing to Menu:

state: GameState::Menu,

Add a reset method that restores gameplay state without losing high_score or keybindings:

pub fn reset(&mut self) {
    self.player = Player::new();
    self.asteroids.clear();
    self.bullets.clear();
    self.powerups.clear();
    self.score = 0.0;
    self.score_multiplier = 1.0;
    self.score_boost_timer = 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();
    self.grid = Grid::new(screen_width(), screen_height(), 120.0);
}

Update the beginning of update() to handle menu and game-over input:

pub fn update(&mut self, dt: f32) {
    match &self.state {
        GameState::Menu => {
            if is_key_pressed(KeyCode::Enter) {
                self.reset();
                self.state = GameState::Playing;
            }
            if is_key_pressed(KeyCode::S) {
                self.state = GameState::Settings;
            }
            if is_key_pressed(KeyCode::Escape) {
                std::process::exit(0);
            }
            return;
        }
        GameState::GameOver => {
            let final_score = self.score as u32;
            if final_score > self.high_score {
                self.high_score = final_score;
            }
            if is_key_pressed(KeyCode::R) {
                self.reset();
            }
            if is_key_pressed(KeyCode::Escape) {
                self.state = GameState::Menu;
            }
            return;
        }
        GameState::Settings | GameState::AwaitingKey(_) => {
            self.update_settings();
            return;
        }
        GameState::Playing => {}
    }

    // ... rest of Playing update unchanged
}

Add the menu and game-over drawing functions:

fn draw_menu(&self) {
    let cx = screen_width() / 2.0;
    let cy = screen_height() / 2.0;
    draw_text("ASTEROID DODGER", cx - 220.0, cy - 80.0, 52.0, WHITE);
    draw_text("[Enter] Play", cx - 70.0, cy, 28.0, GREEN);
    draw_text("[S] Settings", cx - 70.0, cy + 40.0, 28.0, LIGHTGRAY);
    draw_text("[Esc] Exit", cx - 60.0, cy + 80.0, 28.0, GRAY);
    if self.high_score > 0 {
        draw_text(
            &format!("High Score: {}", self.high_score),
            cx - 90.0, cy + 140.0, 24.0, YELLOW,
        );
    }
}

fn draw_game_over(&self) {
    let cx = screen_width() / 2.0;
    let cy = screen_height() / 2.0;
    let final_score = self.score as u32;
    let msg = format!("Game Over — Score: {}", final_score);
    draw_text(&msg, cx - 180.0, cy, 40.0, WHITE);
    if final_score >= self.high_score {
        draw_text("NEW HIGH SCORE!", cx - 110.0, cy + 40.0, 28.0, YELLOW);
    }
    draw_text("[R] Restart   [Esc] Menu", cx - 150.0, cy + 80.0, 24.0, GRAY);
}

Update draw() to dispatch based on state:

pub fn draw(&self) {
    clear_background(BLACK);
    match &self.state {
        GameState::Menu => { self.draw_menu(); return; }
        GameState::Settings | GameState::AwaitingKey(_) => {
            self.draw_settings();
            return;
        }
        _ => {}
    }

    // ... existing gameplay drawing code ...

    if self.state == GameState::GameOver {
        self.draw_game_over();
    }
}
Run it

cargo run — the game opens to a title screen. Press Enter to play, die, and see the game-over screen with R to restart or Esc to return to the menu.


Milestone 3 — Keybindings

Milestone 3 of 5

Hardcoded keys work, but players expect to remap controls. This milestone defines an Action enum, a Keybindings struct, and a settings screen to rebind them.

Step 4 — Define Action and Keybindings

Add the Action enum and Keybindings struct at the top of src/game.rs, above the GameState enum. They need to be pub because player.rs will import Keybindings.

#[derive(Clone, Copy, PartialEq)]
pub enum Action {
    Thrust,
    RotateLeft,
    RotateRight,
    Shoot,
}

impl Action {
    pub const ALL: [Action; 4] = [
        Action::Thrust,
        Action::RotateLeft,
        Action::RotateRight,
        Action::Shoot,
    ];

    pub fn label(&self) -> &'static str {
        match self {
            Action::Thrust => "Thrust",
            Action::RotateLeft => "Rotate Left",
            Action::RotateRight => "Rotate Right",
            Action::Shoot => "Shoot",
        }
    }
}

pub struct Keybindings {
    pub thrust: KeyCode,
    pub rotate_left: KeyCode,
    pub rotate_right: KeyCode,
    pub shoot: KeyCode,
}

impl Keybindings {
    pub fn default_bindings() -> Self {
        Self {
            thrust: KeyCode::W,
            rotate_left: KeyCode::A,
            rotate_right: KeyCode::D,
            shoot: KeyCode::Space,
        }
    }

    pub fn key_for(&self, action: Action) -> KeyCode {
        match action {
            Action::Thrust => self.thrust,
            Action::RotateLeft => self.rotate_left,
            Action::RotateRight => self.rotate_right,
            Action::Shoot => self.shoot,
        }
    }

    pub fn set_key(&mut self, action: Action, key: KeyCode) {
        match action {
            Action::Thrust => self.thrust = key,
            Action::RotateLeft => self.rotate_left = key,
            Action::RotateRight => self.rotate_right = key,
            Action::Shoot => self.shoot = key,
        }
    }
}

Add keybindings: Keybindings to the Game struct, initialised with Keybindings::default_bindings() in new().

Why a struct instead of a HashMap?

With only four actions, a flat struct with named fields is simpler, faster, and gives you compile-time guarantees that every action has a binding. A HashMap<Action, KeyCode> would need hashing, allocation, and runtime checks for missing keys — overkill here.

Now update src/player.rs to use the keybindings instead of hardcoded keys. Add the import and change the update signature:

use crate::game::Keybindings;

// Change the update method signature:
pub fn update(&mut self, dt: f32, keys: &Keybindings) {
    if is_key_down(keys.rotate_left) { self.angle -= ROTATE_SPEED * dt; }
    if is_key_down(keys.rotate_right) { self.angle += ROTATE_SPEED * dt; }

    if is_key_down(keys.thrust) {
        self.vel += Vec2::from_angle(self.angle) * THRUST * dt;
    }

    // ... rest unchanged
}

Update the call site in game.rs:

self.player.update(dt, &self.keybindings);

And update the shoot check to use the keybinding:

if is_key_down(self.keybindings.shoot) && self.shoot_cooldown <= 0.0 {
Circular imports?

player.rs imports Keybindings from game.rs, while game.rs imports Player from player.rs. In Rust this is perfectly fine — modules within the same crate can reference each other freely. The compiler resolves everything in one pass across the crate.

Step 5 — Settings screen

Add the update_settings and draw_settings methods to Game:

fn update_settings(&mut self) {
    if is_key_pressed(KeyCode::Escape) {
        self.state = GameState::Menu;
        return;
    }

    if let GameState::AwaitingKey(action) = self.state {
        if let Some(key) = get_last_key_pressed() {
            if key != KeyCode::Escape {
                self.keybindings.set_key(action, key);
            }
            self.state = GameState::Settings;
        }
        return;
    }

    if is_key_pressed(KeyCode::Left) {
        self.sounds.volume = (self.sounds.volume - 0.1).max(0.0);
    }
    if is_key_pressed(KeyCode::Right) {
        self.sounds.volume = (self.sounds.volume + 0.1).min(1.0);
    }

    let action_keys = [KeyCode::Key1, KeyCode::Key2, KeyCode::Key3, KeyCode::Key4];
    for (i, &key) in action_keys.iter().enumerate() {
        if is_key_pressed(key) {
            self.state = GameState::AwaitingKey(Action::ALL[i]);
        }
    }
}

The AwaitingKey state uses get_last_key_pressed() — macroquad’s function that returns Option<KeyCode>. When the player presses any key it gets assigned to that action. Pressing Escape cancels the rebind.

fn draw_settings(&self) {
    let cx = screen_width() / 2.0;
    let mut y = 100.0;

    draw_text("SETTINGS", cx - 80.0, y, 40.0, WHITE);
    y += 60.0;

    draw_text("Volume", cx - 180.0, y, 24.0, LIGHTGRAY);
    let bar_x = cx - 20.0;
    let bar_w = 200.0;
    draw_rectangle(bar_x, y - 14.0, bar_w, 16.0, DARKGRAY);
    draw_rectangle(bar_x, y - 14.0, bar_w * self.sounds.volume, 16.0, GREEN);
    draw_rectangle_lines(bar_x, y - 14.0, bar_w, 16.0, 1.0, WHITE);
    draw_text("[<-/->]", cx + 200.0, y, 18.0, GRAY);
    y += 50.0;

    draw_text("Keybindings", cx - 180.0, y, 24.0, WHITE);
    y += 30.0;

    for (i, action) in Action::ALL.iter().enumerate() {
        let key = self.keybindings.key_for(*action);
        let key_name = format!("{:?}", key);
        let label = format!("[{}] {} — {}", i + 1, action.label(), key_name);

        let color = if self.state == GameState::AwaitingKey(*action) {
            YELLOW
        } else {
            LIGHTGRAY
        };

        draw_text(&label, cx - 180.0, y, 20.0, color);

        if self.state == GameState::AwaitingKey(*action) {
            draw_text("press any key...", cx + 120.0, y, 18.0, YELLOW);
        }

        y += 30.0;
    }

    y += 20.0;
    draw_text("[Esc] Back", cx - 180.0, y, 20.0, GRAY);
}
get_last_key_pressed

get_last_key_pressed() returns Option<KeyCode> and only fires once per physical press — it won’t repeat while held. This makes it ideal for menu input and key rebinding where you want exactly one event per press.

Run it

cargo run — from the menu, press S to open settings. Use arrow keys to adjust volume. Press 1-4 to rebind an action, then press the new key. Esc returns to the menu. Start a game and verify the new bindings work.


Milestone 4 — Shop

Milestone 4 of 5

Every 45 seconds the game pauses and opens a shop where the player spends score points on permanent upgrades. Score serves double duty as both leaderboard rank and currency — spending on upgrades means a lower final score.

Step 6 — Shop state and items (src/game.rs)

Add a Shop variant to GameState:

#[derive(PartialEq)]
enum GameState {
    Menu,
    Playing,
    GameOver,
    Settings,
    AwaitingKey(Action),
    Shop,
}

Add shop-related fields to Game:

round_timer: f32,
fire_rate_mult: f32,
shop_flash_timer: f32,

Initialise in new() and reset():

round_timer: 0.0,
fire_rate_mult: 1.0,
shop_flash_timer: 0.0,

Add max_hp to Player in src/player.rs:

pub struct Player {
    pub pos: Vec2,
    pub vel: Vec2,
    pub hp: f32,
    pub max_hp: f32,
    pub invincibility: f32,
    angle: f32,
}

Initialise max_hp to 100.0 in Player::new():

hp: 100.0,
max_hp: 100.0,

Update the HP bar in Game::draw to use max_hp instead of the hardcoded 100.0:

let fill_width = 200.0 * (self.player.hp / self.player.max_hp).clamp(0.0, 1.0);

Define the shop items as a struct and constant array:

struct ShopItem {
    name: &'static str,
    cost: u32,
    description: &'static str,
}

const SHOP_ITEMS: [ShopItem; 4] = [
    ShopItem { name: "Max HP +20", cost: 30, description: "Increase maximum health by 20" },
    ShopItem { name: "Fire Rate +", cost: 40, description: "Reduce all weapon cooldowns by 15%" },
    ShopItem { name: "+3 Bombs", cost: 20, description: "Add 3 bombs to your inventory" },
    ShopItem { name: "Shield 10s", cost: 50, description: "10 seconds of invincibility" },
];

Triggering the shop — in update, inside the Playing branch, increment the round timer and transition to Shop every 45 seconds:

self.round_timer += dt;
if self.round_timer >= 45.0 {
    self.state = GameState::Shop;
}

Add a Shop match arm in update:

GameState::Shop => {
    self.update_shop();
    return;
}

Step 7 — Shop UI (src/game.rs)

Implement update_shop:

fn update_shop(&mut self) {
    self.shop_flash_timer -= get_frame_time();
    let score = self.score as u32;

    if is_key_pressed(KeyCode::Key1) && score >= SHOP_ITEMS[0].cost {
        self.score -= SHOP_ITEMS[0].cost as f32;
        self.player.max_hp += 20.0;
        self.player.hp = self.player.max_hp;
        self.shop_flash_timer = 0.5;
    }
    if is_key_pressed(KeyCode::Key2) && score >= SHOP_ITEMS[1].cost {
        self.score -= SHOP_ITEMS[1].cost as f32;
        self.fire_rate_mult *= 0.85;
        self.shop_flash_timer = 0.5;
    }
    if is_key_pressed(KeyCode::Key3) && score >= SHOP_ITEMS[2].cost {
        self.score -= SHOP_ITEMS[2].cost as f32;
        self.bomb_count += 3;
        self.shop_flash_timer = 0.5;
    }
    if is_key_pressed(KeyCode::Key4) && score >= SHOP_ITEMS[3].cost {
        self.score -= SHOP_ITEMS[3].cost as f32;
        self.player.invincibility = 10.0;
        self.shop_flash_timer = 0.5;
    }

    if is_key_pressed(KeyCode::Enter) || is_key_pressed(KeyCode::Escape) {
        self.round_timer = 0.0;
        self.state = GameState::Playing;
    }
}

Apply fire_rate_mult to weapon cooldowns. In the shooting block, replace the cooldown line:

self.shoot_cooldown = self.current_weapon.cooldown() * self.fire_rate_mult;

Implement draw_shop:

fn draw_shop(&self) {
    let cx = screen_width() / 2.0;
    let mut y = screen_height() / 2.0 - 160.0;

    draw_text("SHOP", cx - 50.0, y, 48.0, YELLOW);
    y += 30.0;
    draw_text(
        &format!("Score: {}", self.score as u32),
        cx - 60.0, y, 24.0, WHITE,
    );
    y += 50.0;

    let score = self.score as u32;
    for (i, item) in SHOP_ITEMS.iter().enumerate() {
        let affordable = score >= item.cost;
        let color = if affordable { WHITE } else { DARKGRAY };
        let num = i + 1;

        draw_text(
            &format!("[{}] {} [{}pts]", num, item.name, item.cost),
            cx - 180.0, y, 24.0, color,
        );
        draw_text(item.description, cx - 180.0, y + 22.0, 18.0, GRAY);
        y += 60.0;
    }

    if self.shop_flash_timer > 0.0 {
        draw_text("Purchased!", cx - 60.0, y, 24.0, GREEN);
        y += 30.0;
    }

    y += 10.0;
    draw_text("[Enter/Esc] Continue playing", cx - 140.0, y, 20.0, LIGHTGRAY);
}

Wire draw_shop into Game::draw. Add a Shop arm alongside the existing state matches:

GameState::Shop => { self.draw_shop(); return; }
Run it

cargo run — after 45 seconds of play, the shop opens automatically. Press 1-4 to buy upgrades (affordable items are white, too-expensive ones are gray). A green “Purchased!” flash confirms each buy. Press Enter or Escape to resume. The timer resets, so the next shop opens 45 seconds later.

Economy design

Score serves double duty as both leaderboard rank and currency. Spending score on upgrades creates a strategic tension: buying fire rate now means a lower final score. This is a classic roguelike tradeoff that adds replayability without new mechanics.


Milestone 5 — High score

Milestone 5 of 5

The high score is already tracked across rounds (it updates at game over from Milestone 2), but it only shows then. This milestone makes it update live and displays it on the HUD throughout gameplay.

Step 8 — Live high score on the HUD

In update(), after incrementing the score during Playing, update the high score in real time:

let current_score = self.score as u32;
if current_score > self.high_score {
    self.high_score = current_score;
}

In draw(), show the best score alongside the current score during gameplay:

draw_text(
    &format!("Best: {}", self.high_score),
    screen_width() - 120.0, 48.0, 20.0, GRAY,
);

The menu already shows the high score when it’s greater than zero (from Step 3), and the game-over screen shows “NEW HIGH SCORE!” when beaten. With the live update, the player sees their best score at all times.

Run it

cargo run — play a round and watch the “Best:” counter on the HUD. Die, restart, and notice the high score persists. It also shows on the main menu.


Complete listing

The project now has 12 source files. New in this tutorial: powerup.rs. Modified: main.rs (add mod powerup), game.rs (power-ups, menu/shop/settings states, round timer, score multiplier, fire rate multiplier, keybindings, high score), player.rs (add max_hp field, keybindings parameter). Unchanged: collision.rs, asteroid.rs, bullet.rs, bomb.rs, weapon.rs, sound.rs, particle.rs, grid.rs.

src/main.rs

use macroquad::prelude::*;

mod collision;
mod player;
mod asteroid;
mod bullet;
mod game;
mod sound;
mod particle;
mod grid;
mod weapon;
mod bomb;
mod powerup;

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/powerup.rs

use macroquad::prelude::*;

#[derive(Clone, Copy, PartialEq)]
pub enum PowerUpType {
    WeaponUpgrade,
    BombRefill,
    Shield,
    ScoreBoost,
}

impl PowerUpType {
    pub fn color(&self) -> Color {
        match self {
            Self::WeaponUpgrade => GREEN,
            Self::BombRefill => RED,
            Self::Shield => BLUE,
            Self::ScoreBoost => YELLOW,
        }
    }

    pub fn label(&self) -> &'static str {
        match self {
            Self::WeaponUpgrade => "Weapon!",
            Self::BombRefill => "Bombs!",
            Self::Shield => "Shield!",
            Self::ScoreBoost => "2x Score!",
        }
    }

    pub fn random() -> Self {
        match rand::gen_range(0u32, 4) {
            0 => Self::WeaponUpgrade,
            1 => Self::BombRefill,
            2 => Self::Shield,
            _ => Self::ScoreBoost,
        }
    }
}

pub struct PowerUp {
    pub pos: Vec2,
    pub kind: PowerUpType,
    pub lifetime: f32,
    spin: f32,
}

impl PowerUp {
    pub fn new(pos: Vec2) -> Self {
        Self {
            pos,
            kind: PowerUpType::random(),
            lifetime: 8.0,
            spin: 0.0,
        }
    }

    pub fn update(&mut self, dt: f32) {
        self.lifetime -= dt;
        self.spin += dt * 3.0;
    }

    pub fn is_expired(&self) -> bool {
        self.lifetime <= 0.0
    }

    pub fn draw(&self) {
        let size = 12.0;
        let color = self.kind.color();
        let cos = self.spin.cos();
        let sin = self.spin.sin();
        let top = self.pos + Vec2::new(cos, sin) * size;
        let bottom = self.pos - Vec2::new(cos, sin) * size;
        let left = self.pos + Vec2::new(-sin, cos) * size * 0.6;
        let right = self.pos - Vec2::new(-sin, cos) * size * 0.6;
        draw_triangle(top, left, right, color);
        draw_triangle(bottom, left, right, color);
        if self.lifetime < 2.0 && (self.lifetime * 6.0) as u32 % 2 == 0 {
            draw_triangle(top, left, right, WHITE);
            draw_triangle(bottom, left, right, WHITE);
        }
    }
}

src/player.rs

use macroquad::prelude::*;
use crate::collision::Aabb;
use crate::bullet::Bullet;
use crate::bomb::Bomb;
use crate::game::Keybindings;
use crate::weapon::WeaponType;

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 max_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,
            max_hp: 100.0,
            invincibility: 0.0,
            angle: -std::f32::consts::FRAC_PI_2,
        }
    }

    pub fn update(&mut self, dt: f32, keys: &Keybindings) {
        if is_key_down(keys.rotate_left) { self.angle -= ROTATE_SPEED * dt; }
        if is_key_down(keys.rotate_right) { self.angle += ROTATE_SPEED * dt; }
        if is_key_down(keys.thrust) {
            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_bullets(&self, weapon: WeaponType) -> Vec<Bullet> {
        let forward = Vec2::from_angle(self.angle);
        let tip = self.pos + forward * 22.0;
        match weapon {
            WeaponType::Blaster => {
                vec![Bullet::new(tip, self.angle)]
            }
            WeaponType::Spread => {
                let spread = 0.15;
                vec![
                    Bullet::new_with(tip, self.angle - spread, 550.0, false, 2.5, ORANGE),
                    Bullet::new_with(tip, self.angle, 600.0, false, 2.5, ORANGE),
                    Bullet::new_with(tip, self.angle + spread, 550.0, false, 2.5, ORANGE),
                ]
            }
            WeaponType::Rapid => {
                vec![Bullet::new_with(tip, self.angle, 700.0, false, 1.5, SKYBLUE)]
            }
            WeaponType::Laser => {
                vec![Bullet::new_with(tip, self.angle, 900.0, true, 1.5, LIME)]
            }
        }
    }

    pub fn spawn_bomb(&self) -> Bomb {
        let forward = Vec2::from_angle(self.angle);
        Bomb::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/game.rs

use macroquad::prelude::*;
use crate::player::{Player, PLAYER_RADIUS, PLAYER_MASS};
use crate::asteroid::{self, Asteroid};
use crate::bullet::Bullet;
use crate::bomb::{Bomb, EXPLOSION_RADIUS};
use crate::collision;
use crate::sound::Sounds;
use crate::particle::Particles;
use crate::grid::Grid;
use crate::weapon::WeaponType;
use crate::powerup::{PowerUp, PowerUpType};

#[derive(Clone, Copy, PartialEq)]
pub enum Action {
    Thrust,
    RotateLeft,
    RotateRight,
    Shoot,
}

impl Action {
    pub const ALL: [Action; 4] = [
        Action::Thrust,
        Action::RotateLeft,
        Action::RotateRight,
        Action::Shoot,
    ];

    pub fn label(&self) -> &'static str {
        match self {
            Action::Thrust => "Thrust",
            Action::RotateLeft => "Rotate Left",
            Action::RotateRight => "Rotate Right",
            Action::Shoot => "Shoot",
        }
    }
}

pub struct Keybindings {
    pub thrust: KeyCode,
    pub rotate_left: KeyCode,
    pub rotate_right: KeyCode,
    pub shoot: KeyCode,
}

impl Keybindings {
    pub fn default_bindings() -> Self {
        Self {
            thrust: KeyCode::W,
            rotate_left: KeyCode::A,
            rotate_right: KeyCode::D,
            shoot: KeyCode::Space,
        }
    }

    pub fn key_for(&self, action: Action) -> KeyCode {
        match action {
            Action::Thrust => self.thrust,
            Action::RotateLeft => self.rotate_left,
            Action::RotateRight => self.rotate_right,
            Action::Shoot => self.shoot,
        }
    }

    pub fn set_key(&mut self, action: Action, key: KeyCode) {
        match action {
            Action::Thrust => self.thrust = key,
            Action::RotateLeft => self.rotate_left = key,
            Action::RotateRight => self.rotate_right = key,
            Action::Shoot => self.shoot = key,
        }
    }
}

struct ShopItem {
    name: &'static str,
    cost: u32,
    description: &'static str,
}

const SHOP_ITEMS: [ShopItem; 4] = [
    ShopItem { name: "Max HP +20", cost: 30, description: "Increase maximum health by 20" },
    ShopItem { name: "Fire Rate +", cost: 40, description: "Reduce all weapon cooldowns by 15%" },
    ShopItem { name: "+3 Bombs", cost: 20, description: "Add 3 bombs to your inventory" },
    ShopItem { name: "Shield 10s", cost: 50, description: "10 seconds of invincibility" },
];

#[derive(PartialEq)]
enum GameState {
    Menu,
    Playing,
    GameOver,
    Settings,
    AwaitingKey(Action),
    Shop,
}

pub struct Game {
    player: Player,
    asteroids: Vec<Asteroid>,
    bullets: Vec<Bullet>,
    bombs: Vec<Bomb>,
    powerups: Vec<PowerUp>,
    score: f32,
    score_multiplier: f32,
    score_boost_timer: f32,
    spawn_timer: f32,
    spawn_interval: f32,
    shoot_cooldown: f32,
    round_timer: f32,
    fire_rate_mult: f32,
    shop_flash_timer: f32,
    state: GameState,
    sounds: Sounds,
    particles: Particles,
    grid: Grid,
    keybindings: Keybindings,
    high_score: u32,
    current_weapon: WeaponType,
    bomb_count: u32,
}

impl Game {
    pub fn new(sounds: Sounds) -> Self {
        Self {
            player: Player::new(),
            asteroids: Vec::new(),
            bullets: Vec::new(),
            bombs: Vec::new(),
            powerups: Vec::new(),
            score: 0.0,
            score_multiplier: 1.0,
            score_boost_timer: 0.0,
            spawn_timer: 0.0,
            spawn_interval: 3.0,
            shoot_cooldown: 0.0,
            round_timer: 0.0,
            fire_rate_mult: 1.0,
            shop_flash_timer: 0.0,
            state: GameState::Menu,
            sounds,
            particles: Particles::new(),
            grid: Grid::new(screen_width(), screen_height(), 120.0),
            keybindings: Keybindings::default_bindings(),
            high_score: 0,
            current_weapon: WeaponType::Blaster,
            bomb_count: 3,
        }
    }

    pub fn reset(&mut self) {
        self.player = Player::new();
        self.asteroids.clear();
        self.bullets.clear();
        self.bombs.clear();
        self.powerups.clear();
        self.score = 0.0;
        self.score_multiplier = 1.0;
        self.score_boost_timer = 0.0;
        self.spawn_timer = 0.0;
        self.spawn_interval = 3.0;
        self.shoot_cooldown = 0.0;
        self.round_timer = 0.0;
        self.fire_rate_mult = 1.0;
        self.shop_flash_timer = 0.0;
        self.state = GameState::Playing;
        self.particles = Particles::new();
        self.grid = Grid::new(screen_width(), screen_height(), 120.0);
        self.current_weapon = WeaponType::Blaster;
        self.bomb_count = 3;
    }

    pub fn update(&mut self, dt: f32) {
        match &self.state {
            GameState::Menu => {
                if is_key_pressed(KeyCode::Enter) {
                    self.reset();
                    self.state = GameState::Playing;
                }
                if is_key_pressed(KeyCode::S) {
                    self.state = GameState::Settings;
                }
                if is_key_pressed(KeyCode::Escape) {
                    std::process::exit(0);
                }
                return;
            }
            GameState::GameOver => {
                let final_score = self.score as u32;
                if final_score > self.high_score {
                    self.high_score = final_score;
                }
                if is_key_pressed(KeyCode::R) {
                    self.reset();
                }
                if is_key_pressed(KeyCode::Escape) {
                    self.state = GameState::Menu;
                }
                return;
            }
            GameState::Settings | GameState::AwaitingKey(_) => {
                self.update_settings();
                return;
            }
            GameState::Shop => {
                self.update_shop();
                return;
            }
            GameState::Playing => {}
        }

        self.player.update(dt, &self.keybindings);
        self.score += dt * self.score_multiplier;
        if self.score_boost_timer > 0.0 {
            self.score_boost_timer -= dt;
            if self.score_boost_timer <= 0.0 {
                self.score_multiplier = 1.0;
            }
        }

        if is_key_down(self.keybindings.thrust) {
            self.sounds.play(&self.sounds.thrust.clone());
        }

        // Weapon switching
        if is_key_pressed(KeyCode::Key1) { self.current_weapon = WeaponType::Blaster; }
        if is_key_pressed(KeyCode::Key2) { self.current_weapon = WeaponType::Spread; }
        if is_key_pressed(KeyCode::Key3) { self.current_weapon = WeaponType::Rapid; }
        if is_key_pressed(KeyCode::Key4) { self.current_weapon = WeaponType::Laser; }

        self.shoot_cooldown -= dt;
        if is_key_down(self.keybindings.shoot) && self.shoot_cooldown <= 0.0 {
            let new_bullets = self.player.spawn_bullets(self.current_weapon);
            self.bullets.extend(new_bullets);
            self.shoot_cooldown = self.current_weapon.cooldown() * self.fire_rate_mult;
            self.sounds.play(&self.sounds.shoot.clone());
        }

        // Bomb deployment
        if is_key_pressed(KeyCode::B) && self.bomb_count > 0 {
            self.bombs.push(self.player.spawn_bomb());
            self.bomb_count -= 1;
            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);
        }

        self.grid.clear();
        for (i, asteroid) in self.asteroids.iter().enumerate() {
            self.grid.insert(i, asteroid.pos.x, asteroid.pos.y, asteroid.radius);
        }
        let pairs = self.grid.potential_pairs();
        asteroid::resolve_collisions(&mut self.asteroids, &pairs);

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

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

        let current_score = self.score as u32;
        if current_score > self.high_score {
            self.high_score = current_score;
        }

        // Shop timer
        self.round_timer += dt;
        if self.round_timer >= 45.0 {
            self.state = GameState::Shop;
        }
    }

    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);
                    }
                    if !bullet.piercing { 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());
        }

        // Spawn power-ups from bullet kills
        for &(pos, _radius) in &hit_positions {
            if rand::gen_range(0u32, 100) < 25 {
                self.powerups.push(PowerUp::new(pos));
            }
        }
    }

    fn update_bombs(&mut self) {
        for bomb in &mut self.bombs {
            bomb.update(get_frame_time());
        }

        for bomb in &mut self.bombs {
            if bomb.exploded { continue; }
            for asteroid in &self.asteroids {
                if bomb.pos.distance(asteroid.pos) < asteroid.radius {
                    bomb.exploded = true;
                    bomb.timer = 0.0;
                    break;
                }
            }
        }

        let mut exploded_positions: Vec<Vec2> = Vec::new();
        for bomb in &self.bombs {
            if bomb.exploded {
                exploded_positions.push(bomb.pos);
            }
        }

        for pos in &exploded_positions {
            let mut hit_indices: Vec<usize> = Vec::new();
            let mut fragments: Vec<Asteroid> = Vec::new();
            let mut hit_positions_for_drops: Vec<(Vec2, f32)> = Vec::new();
            for (i, asteroid) in self.asteroids.iter().enumerate() {
                if pos.distance(asteroid.pos) < EXPLOSION_RADIUS {
                    hit_indices.push(i);
                    hit_positions_for_drops.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,
                        ));
                    }
                    self.particles.burst(asteroid.pos, (asteroid.radius * 0.6) as usize, asteroid.radius * 1.5, LIGHTGRAY);
                }
            }
            hit_indices.sort_unstable();
            for i in hit_indices.into_iter().rev() {
                self.asteroids.swap_remove(i);
            }
            self.asteroids.extend(fragments);
            self.particles.burst(*pos, 40, 200.0, RED);
            self.particles.burst(*pos, 25, 150.0, ORANGE);
            self.sounds.play(&self.sounds.explode.clone());

            // Spawn power-ups from bomb kills
            for &(drop_pos, _radius) in &hit_positions_for_drops {
                if rand::gen_range(0u32, 100) < 25 {
                    self.powerups.push(PowerUp::new(drop_pos));
                }
            }
        }

        self.bombs.retain(|b| !b.exploded && !b.is_off_screen());
    }

    fn update_powerups(&mut self, dt: f32) {
        for powerup in &mut self.powerups {
            powerup.update(dt);
        }

        let player_pos = self.player.pos;
        let collect_dist = PLAYER_RADIUS + 12.0;

        let mut collected: Vec<PowerUpType> = Vec::new();
        let mut remaining: Vec<PowerUp> = Vec::new();
        for powerup in self.powerups.drain(..) {
            if powerup.pos.distance(player_pos) < collect_dist {
                collected.push(powerup.kind);
            } else if !powerup.is_expired() {
                remaining.push(powerup);
            }
        }
        self.powerups = remaining;

        for kind in collected {
            match kind {
                PowerUpType::WeaponUpgrade => {
                    let idx = WeaponType::ALL.iter()
                        .position(|w| *w == self.current_weapon)
                        .unwrap_or(0);
                    self.current_weapon = WeaponType::ALL[(idx + 1) % WeaponType::ALL.len()];
                }
                PowerUpType::BombRefill => {
                    self.bomb_count += 2;
                }
                PowerUpType::Shield => {
                    self.player.invincibility = 5.0;
                }
                PowerUpType::ScoreBoost => {
                    self.score_multiplier = 2.0;
                    self.score_boost_timer = 10.0;
                }
            }
            self.particles.burst(player_pos, 8, 40.0, kind.color());
        }
    }

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

    fn update_settings(&mut self) {
        if is_key_pressed(KeyCode::Escape) {
            self.state = GameState::Menu;
            return;
        }

        if let GameState::AwaitingKey(action) = self.state {
            if let Some(key) = get_last_key_pressed() {
                if key != KeyCode::Escape {
                    self.keybindings.set_key(action, key);
                }
                self.state = GameState::Settings;
            }
            return;
        }

        if is_key_pressed(KeyCode::Left) {
            self.sounds.volume = (self.sounds.volume - 0.1).max(0.0);
        }
        if is_key_pressed(KeyCode::Right) {
            self.sounds.volume = (self.sounds.volume + 0.1).min(1.0);
        }

        let action_keys = [KeyCode::Key1, KeyCode::Key2, KeyCode::Key3, KeyCode::Key4];
        for (i, &key) in action_keys.iter().enumerate() {
            if is_key_pressed(key) {
                self.state = GameState::AwaitingKey(Action::ALL[i]);
            }
        }
    }

    fn update_shop(&mut self) {
        self.shop_flash_timer -= get_frame_time();
        let score = self.score as u32;

        if is_key_pressed(KeyCode::Key1) && score >= SHOP_ITEMS[0].cost {
            self.score -= SHOP_ITEMS[0].cost as f32;
            self.player.max_hp += 20.0;
            self.player.hp = self.player.max_hp;
            self.shop_flash_timer = 0.5;
        }
        if is_key_pressed(KeyCode::Key2) && score >= SHOP_ITEMS[1].cost {
            self.score -= SHOP_ITEMS[1].cost as f32;
            self.fire_rate_mult *= 0.85;
            self.shop_flash_timer = 0.5;
        }
        if is_key_pressed(KeyCode::Key3) && score >= SHOP_ITEMS[2].cost {
            self.score -= SHOP_ITEMS[2].cost as f32;
            self.bomb_count += 3;
            self.shop_flash_timer = 0.5;
        }
        if is_key_pressed(KeyCode::Key4) && score >= SHOP_ITEMS[3].cost {
            self.score -= SHOP_ITEMS[3].cost as f32;
            self.player.invincibility = 10.0;
            self.shop_flash_timer = 0.5;
        }

        if is_key_pressed(KeyCode::Enter) || is_key_pressed(KeyCode::Escape) {
            self.round_timer = 0.0;
            self.state = GameState::Playing;
        }
    }

    fn draw_menu(&self) {
        let cx = screen_width() / 2.0;
        let cy = screen_height() / 2.0;
        draw_text("ASTEROID DODGER", cx - 220.0, cy - 80.0, 52.0, WHITE);
        draw_text("[Enter] Play", cx - 70.0, cy, 28.0, GREEN);
        draw_text("[S] Settings", cx - 70.0, cy + 40.0, 28.0, LIGHTGRAY);
        draw_text("[Esc] Exit", cx - 60.0, cy + 80.0, 28.0, GRAY);
        if self.high_score > 0 {
            draw_text(
                &format!("High Score: {}", self.high_score),
                cx - 90.0, cy + 140.0, 24.0, YELLOW,
            );
        }
    }

    fn draw_game_over(&self) {
        let cx = screen_width() / 2.0;
        let cy = screen_height() / 2.0;
        let final_score = self.score as u32;
        let msg = format!("Game Over — Score: {}", final_score);
        draw_text(&msg, cx - 180.0, cy, 40.0, WHITE);
        if final_score >= self.high_score {
            draw_text("NEW HIGH SCORE!", cx - 110.0, cy + 40.0, 28.0, YELLOW);
        }
        draw_text("[R] Restart   [Esc] Menu", cx - 150.0, cy + 80.0, 24.0, GRAY);
    }

    fn draw_settings(&self) {
        let cx = screen_width() / 2.0;
        let mut y = 100.0;

        draw_text("SETTINGS", cx - 80.0, y, 40.0, WHITE);
        y += 60.0;

        draw_text("Volume", cx - 180.0, y, 24.0, LIGHTGRAY);
        let bar_x = cx - 20.0;
        let bar_w = 200.0;
        draw_rectangle(bar_x, y - 14.0, bar_w, 16.0, DARKGRAY);
        draw_rectangle(bar_x, y - 14.0, bar_w * self.sounds.volume, 16.0, GREEN);
        draw_rectangle_lines(bar_x, y - 14.0, bar_w, 16.0, 1.0, WHITE);
        draw_text("[<-/->]", cx + 200.0, y, 18.0, GRAY);
        y += 50.0;

        draw_text("Keybindings", cx - 180.0, y, 24.0, WHITE);
        y += 30.0;

        for (i, action) in Action::ALL.iter().enumerate() {
            let key = self.keybindings.key_for(*action);
            let key_name = format!("{:?}", key);
            let label = format!("[{}] {} — {}", i + 1, action.label(), key_name);

            let color = if self.state == GameState::AwaitingKey(*action) {
                YELLOW
            } else {
                LIGHTGRAY
            };

            draw_text(&label, cx - 180.0, y, 20.0, color);

            if self.state == GameState::AwaitingKey(*action) {
                draw_text("press any key...", cx + 120.0, y, 18.0, YELLOW);
            }

            y += 30.0;
        }

        y += 20.0;
        draw_text("[Esc] Back", cx - 180.0, y, 20.0, GRAY);
    }

    fn draw_shop(&self) {
        let cx = screen_width() / 2.0;
        let mut y = screen_height() / 2.0 - 160.0;

        draw_text("SHOP", cx - 50.0, y, 48.0, YELLOW);
        y += 30.0;
        draw_text(
            &format!("Score: {}", self.score as u32),
            cx - 60.0, y, 24.0, WHITE,
        );
        y += 50.0;

        let score = self.score as u32;
        for (i, item) in SHOP_ITEMS.iter().enumerate() {
            let affordable = score >= item.cost;
            let color = if affordable { WHITE } else { DARKGRAY };
            let num = i + 1;
            draw_text(
                &format!("[{}] {} [{}pts]", num, item.name, item.cost),
                cx - 180.0, y, 24.0, color,
            );
            draw_text(item.description, cx - 180.0, y + 22.0, 18.0, GRAY);
            y += 60.0;
        }

        if self.shop_flash_timer > 0.0 {
            draw_text("Purchased!", cx - 60.0, y, 24.0, GREEN);
            y += 30.0;
        }

        y += 10.0;
        draw_text("[Enter/Esc] Continue playing", cx - 140.0, y, 20.0, LIGHTGRAY);
    }

    pub fn draw(&self) {
        clear_background(BLACK);
        match &self.state {
            GameState::Menu => { self.draw_menu(); return; }
            GameState::Settings | GameState::AwaitingKey(_) => {
                self.draw_settings();
                return;
            }
            GameState::Shop => { self.draw_shop(); return; }
            _ => {}
        }

        self.particles.draw();

        for asteroid in &self.asteroids {
            asteroid.draw();
        }
        for bullet in &self.bullets {
            bullet.draw();
        }
        for bomb in &self.bombs {
            bomb.draw();
        }
        for powerup in &self.powerups {
            powerup.draw();
        }
        self.player.draw();

        draw_text(
            &format!("FPS: {}", get_fps()),
            screen_width() - 90.0, 24.0, 20.0, DARKGREEN,
        );
        draw_text(&format!("Score: {}", self.score as u32), 20.0, 24.0, 24.0, WHITE);
        draw_text(
            &format!("Best: {}", self.high_score),
            screen_width() - 120.0, 48.0, 20.0, GRAY,
        );

        draw_rectangle(20.0, 36.0, 200.0, 16.0, DARKGRAY);
        let fill_width = 200.0 * (self.player.hp / self.player.max_hp).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);

        draw_text(
            &format!("[{}] {}",
                WeaponType::ALL.iter().position(|w| *w == self.current_weapon).unwrap() + 1,
                self.current_weapon.label()
            ),
            20.0, 70.0, 20.0, self.current_weapon.color(),
        );

        draw_text(
            &format!("Bombs: {}", self.bomb_count),
            20.0, 88.0, 20.0, RED,
        );

        if self.score_boost_timer > 0.0 {
            draw_text(
                &format!("2x SCORE {:.0}s", self.score_boost_timer),
                20.0, 106.0, 20.0, YELLOW,
            );
        }

        if self.state == GameState::GameOver {
            self.draw_game_over();
        }
    }
}

collision.rs, asteroid.rs, bullet.rs, bomb.rs, weapon.rs, sound.rs, particle.rs, and grid.rs are unchanged from the previous tutorial.

Next steps