Skip to content

Space Shooter

Manuever in zero G, shoot the baddies.

Turbo game window with the finished Space Shooter game in action.

Overview

Walkthrough

Initialize the Project

Begin by creating a new project called space-shooter:

Terminal
turbo init space-shooter

This initializes a Rust project with the following structure:

space-shooter/          # Your project's root directory.
β”œβ”€β”€ src/                # The directory of your code. 
β”‚   └── lib.rs          # The main file for the game. 
β”œβ”€β”€ Cargo.toml          # Rust project manifest. 
└── turbo.toml          # Turbo configuration. 

Setup the turbo.toml

With the project initialized, open the turbo.toml and change the height of the [canvas] property. This will adjust the aspect ratio and resolution of the game.

turbo.toml
[canvas]
width = 256
height = 144
height = 512

You can also change the description and authors properties while here.

Add Sprite and Audio Assets

Inside your project directory, create a folder named sprites and a folder named audio. These folders will contain all of the game's sprites and audio files.

space-shooter/       # Your project's root directory.
β”œβ”€β”€ audio/              # The directory of your audio assets. 
β”œβ”€β”€ sprites/            # The directory of your sprite assets. 
β”œβ”€β”€ src/                # The directory of your code.
β”‚   └── lib.rs          # The main file for the game.
β”œβ”€β”€ Cargo.toml          # Rust project manifest.
└── turbo.toml          # Turbo configuration.

Add the following files to the audio folder:

Add the following files to the sprites folder:

Player Sprites

Enemy sprites

Game State Initialization

Add this code to the top of your lib.rs file.

src/lib.rs
use turbo::*;
 
#[turbo::game]
struct GameState {
 
}
 
impl GameState {
    fn new() -> Self {
        Self {
 
        }
    }
 
    fn update(&mut self) {
        self.draw();
    }
 
    fn draw(&self) {
 
    }
}

We haven't added any structs or data yet, but we will soon.

All we did so far is set up a draw() function to keep our drawing and updating logic separate. This helps keep our code organized as we start adding more complex logic.

Set Up Project Structure

To manage the size of this project, we will utilize sub files to organize our code. Sub files must be accessible throughout the project, so we will set up a model for files to reference each other.

Create a new folder named model inside the src/ folder in your project directory and create two files inside it, player.rs and mod.rs.

space-shooter/       # Your project's root directory.
β”œβ”€β”€ audio/              # The directory of your audio assets.
β”œβ”€β”€ sprites/            # The directory of your sprite assets.
β”œβ”€β”€ src/                # The directory of your code.
β”‚   β”œβ”€β”€ model/          # The directory for sub files. 
β”‚   β”‚   └── mod.rs      # A reference to all sub files in this folder. 
β”‚   β”‚   └── player.rs   # A sub file to contain the Player struct. 
β”‚   └── lib.rs          # The main file for the game.

At the top of the lib.rs file, add the following lines:

src/lib.rs
use turbo::*;
mod model; 
pub use model::*; 

This code allows lib.rs to reference any files mod.rs references.

Add the following code to the mod.rs file:

src/model/mod.rs
use super::*;
 
mod player;
pub use player::*;

This code allows mod.rs to reference both lib.rs and player.rs, in turn allowing lib.rs to access player.rs as well. Whenever we create a new subfile we have to add mod and use statements to mod.rs.

Finally, add the following line to the top of player.rs

src/model/player.rs
use super::*;

This lets the subfile reference everything defined in mod.rs, including definitions in lib.rs like GameState, because mod.rs includes a use super::* as well.

Now that the project is set up, we can start defining our game structs!

Player

We'll start by creating a Player Struct and adding some variables to update and draw it. Copy the following code into the player.rs file:

src/model/player.rs
use super::*;
 
#[turbo::serialize]
pub struct Player {
    pub hitbox: Bounds,
    x: f32,
    y: f32,
    dx: f32, // dx and dy used for velocity
    dy: f32,
    
    pub hp: u32,
    
    pub hit_timer: u32, // used for invincibility frames and drawing
    shoot_timer: u32, // used for rate of fire
    shooting: bool, // used for shooting animation
 
    // variables used by the HUD to display information
    pub score: u32,
}
 
impl Player {
    pub fn new() -> Self {
        let x = ((screen().w() / 2) - 8) as f32;
        let y = (screen().h() - 64) as f32;
        Player {
            // Initialize all fields with default values
            hitbox: Bounds::new(x, y, 16, 16),
            x,
            y,
            dx: 0.0,
            dy: 0.0,
            hp: 3,
            
            hit_timer: 0,
            shoot_timer: 0,
            shooting: false,
            
            score: 0,
        }
    }
 
    pub fn update(&mut self) {
        
    }
 
    pub fn draw(&self) {
 
    }
}

These are all the variables we need to track the player as they move and shoot onscreen.

We also outline new(), update() and draw() functions. These will be common functions across our game structs. We call new() when we want to create a new instance of this struct, in this case during initialization of the GameState, and call update() and draw() once per frame in the GameState update() scope.

Updating and Drawing the Player

Next, we will add some code to the update() and draw() functions of the Player struct. This code will handle player movement and rendering the player sprite.

Replace the empty functions in the player.rs file with the following functions:

src/model/player.rs
pub fn update(&mut self) {
    if self.hp > 0 {
        // Player movement
        let deceleration = 0.9; // Adjust this value to control deceleration speed
        self.dx *= deceleration; // Reduce xy delta by deceleration factor
        self.dy *= deceleration;
 
        // Record keyboard input
        let mut x_input = 0.0;
        let mut y_input = 0.0;
        if gamepad::get(0).up.pressed() {
            y_input = -1.0;
        }
        if gamepad::get(0).down.pressed() {
            y_input = 1.0;
        }
        if gamepad::get(0).left.pressed() {
            x_input = -1.0;
        }
        if gamepad::get(0).right.pressed() {
            x_input = 1.0;
        }
 
        // Apply input to dx and dy, normalizing diagonal movement
        let magnitude = ((x_input * x_input + y_input * y_input) as f32).sqrt();
        if x_input != 0.0 {
            self.dx = x_input / magnitude;
        }
        if y_input != 0.0 {
            self.dy = y_input / magnitude;
        }
 
        let speed = 2.0;
        self.x = (self.x + self.dx * speed) // Translate position by input delta multiplied by speed
            .clamp(0.0, (screen().w() - self.hitbox.w() - 2) as f32); // Clamp to screen bounds
        self.y = (self.y + self.dy * speed)
            .clamp(0.0, (screen().h() - self.hitbox.h() - 2) as f32);
 
        // Set hitbox position based on float xy values
        self.hitbox = self.hitbox.position(self.x, self.y);
    }
}
src/model/player.rs
pub fn draw(&self) {
    if self.hp > 0 {
        // Get reference to SpriteAnimation for player
        let anim = animation::get("p_key");
        // Begin to construct the string for which sprite to use
        let mut sprite = "player".to_string();
        // Assign the sprite string to the SpriteAnimation
        anim.use_sprite(&sprite);
        sprite!(
            animation_key = "p_key",
            x = self.x,
            y = self.y,
        );
    }
}

Great! The Player struct is now ready to be used in the game.

Our final step before seeing it in action is to return to our lib.rs file to add a player variable to the GameState struct and call these functions we've just defined!

src/lib.rs
use turbo::*;
mod model;
pub use model::*;
 
#[turbo::game]
struct GameState {
    player: Player, 
}
 
impl GameState {
    fn new() -> Self {
        Self {
            player: Player::new(), 
        }
    }
 
    fn update(&mut self) {
        self.draw();
 
        self.player.update(); 
    }
 
    fn draw(&self) {
        self.player.draw(); 
    }
}

After saving all your changes, return to the Turbo game window to fly your player ship around! Use the arrow keys to move. Try changing the speed and deceleration values to find what feels best. Player flying around empty game scene

Projectiles

Now that we have a start on our player controller, let's add projectiles for it to shoot!

We'll start by creating a new file in our model folder called projectile.rs and reference it in mod.rs.

space-shooter/           # Your project's root directory.
β”œβ”€β”€ audio/                  # The directory of your audio assets.
β”œβ”€β”€ sprites/                # The directory of your sprite assets.
β”œβ”€β”€ src/                    # The directory of your code.
β”‚   β”œβ”€β”€ model/              # The directory for sub files. 
β”‚   β”‚   └── mod.rs          # A reference to all sub files in this folder.
β”‚   β”‚   └── player.rs       # A sub file to contain the Player struct.
β”‚   β”‚   └── projectile.rs   # A sub file to contain the Projectile struct. 
β”‚   └── lib.rs              # The main file for the game.
src/model/mod.rs
use super::*;
 
mod player;
pub use player::*;
 
mod projectile; 
pub use projectile::*; 

Copy the following code into the new projectile.rs file:

src/model/projectile.rs
use super::*;
 
// Enum to determine who fired the projectile
#[turbo::serialize]
#[derive(PartialEq)]
pub enum ProjectileOwner {
    Enemy,
    Player,
}
 
#[turbo::serialize]
pub struct Projectile {
    pub hitbox: Bounds,
    x: f32, 
    y: f32, // use f32s to track xy positions for more precise movement
    pub velocity: f32,
    angle: f32,
 
    anim_key: String, // unique, randomly generated key to be used for SpriteAnimations
 
    pub collided: bool, // Used to control the sprite and update state
    pub destroyed: bool, // Used to remove projectile from game
 
    pub damage: u32,
    pub projectile_owner: ProjectileOwner,
}
 
impl Projectile {
    pub fn new(x: f32, y: f32, velocity: f32, angle: f32, projectile_owner: ProjectileOwner) -> Self {
        let audio = match projectile_owner {
            ProjectileOwner::Enemy => "projectile_enemy",
            ProjectileOwner::Player => "projectile_player",
        };
        audio::play(audio);
        Projectile {
            // Initialize all fields with default values
            hitbox: Bounds::new(x, y, 6, 6),
            x,
            y,
            velocity,
            angle,
 
            anim_key: random::u32().to_string(),
            
            destroyed: false,
            collided: false,
            
            damage: 1,
            projectile_owner,
        }
    }
 
    pub fn update(&mut self, player: &mut Player) {
 
    }
 
    pub fn draw(&self) {
 
    }
}

Once again, we outline all the variables we will need to update and draw our projectiles.

Unlike the Player, when we create a new() Projectile we call audio::play() to play a sound effect, determined by the projectile_owner.

Updating and Drawing Projectiles

Now we can fill out our update() and draw() functions for the Projectile struct like we did for Player:

src/model/projectile.rs
 
pub fn update(&mut self, player: &mut Player) {
    // If the projectile hasn't collided, update it as normal
    if !self.collided {
        // update projectile position
        let radian_angle = self.angle.to_radians();
        self.x += self.velocity * radian_angle.cos();
        self.y += self.velocity * radian_angle.sin();
 
        // flag the projectile to be destroyed if it goes off screen
        if self.y < -(self.hitbox.h() as f32)
        && self.x < -(self.hitbox.w() as f32)
        && self.x > screen().w() as f32
        && self.y > screen().h() as f32
        {
            self.destroyed = true;
        }
    // if the projectile has collided, 
    } else {
        // get reference to the SpriteAnimation of the projectile
        let anim = animation::get(&self.anim_key);
        // flag projectile as destroyed when the hit animation is done
        if anim.done() {
            self.destroyed = true;
        }
    }
 
    // Set hitbox position based on float xy values
    self.hitbox = self.hitbox.position(self.x, self.y);
}
src/model/projectile.rs
pub fn draw(&self) {
    // Get reference to SpriteAnimation for projectile
    let anim = animation::get(&self.anim_key);
    // Begin to construct the string for which sprite to use
    let owner = match self.projectile_owner {
        ProjectileOwner::Enemy => "enemy",
        ProjectileOwner::Player => "player",
    };
    if !self.collided {
        anim.use_sprite(&format!("projectile_{}", owner));
    } else {
        anim.use_sprite(&format!("projectile_{}_hit", owner));
        anim.set_repeat(0);
        anim.set_fill_forwards(true);
    }
    
    sprite!(
        animation_key = &self.anim_key,
        x = self.x,
        y = self.y
    );
}

Integrate Projectiles into the Game

Now that the Projectile struct is ready, we can integrate it into our game!

First, return to lib.rs and create a projectiles variable in the GameState struct. Then, add a loop for the GameState to update and draw all projectiles in the update() function.

src/lib.rs
use turbo::*;
mod model;
pub use model::*;
 
#[turbo::game]
struct GameState {
    player: Player, 
    projectiles: Vec<Projectile>, 
}
 
impl GameState {
    fn new() -> Self {
        Self {
            player: Player::new(),
            projectiles: vec![], 
        }
    }
 
    fn update(&mut self) {
        self.draw();
 
        self.player.update();
        self.projectiles.retain_mut(|projectile| { 
            projectile.update(&mut self.player); 
            !projectile.destroyed 
        }); 
    }
 
    fn draw(&self) {
        self.player.draw();
        for projectile in self.projectiles.iter() { 
            projectile.draw(); 
        } 
    }
}

The GameState now has a vec, or list, of projectiles. Every projectile we create in the game will be added to this list so that the GameState can then update and draw them.

But, we still need to add projectiles to this list! Time to return to our Player struct and add shooting functionality.

Add a parameter to the update() function and paste the following code at the end:

src/model/player.rs
pub fn update(&mut self, projectiles: &mut Vec<Projectile>) { 
    ...
        // Set hitbox position based on float xy values
        self.hitbox = self.hitbox.position(self.x, self.y);
 
        // Shooting projectiles 
        // check if shoot button is pressed 
        if gamepad::get(0).start.pressed() || gamepad::get(0).a.pressed() { 
            self.shooting = true; // flag shooting state for animation 
            // if shoot timer is 0, shoot a projectile 
            if self.shoot_timer == 0 { 
                let fire_rate = 15; 
                self.shoot_timer += fire_rate; // add cooldown to shoot timer 
                let projectile_speed = 5.0; 
                for i in 0..=1 { 
                    projectiles.push( 
                        Projectile::new( 
                            self.x + i as f32 * 13.0, 
                            self.y - 8.0, 
                            projectile_speed, 
                            -90.0, 
                            ProjectileOwner::Player, 
                        ) 
                    ); 
                } 
            } 
        // if not shooting 
        } else { 
            self.shooting = false; // flag shooting state for animation 
        } 
        // decrement shoot timer 
        self.shoot_timer = self.shoot_timer.saturating_sub(1); 
    }
}

While we're editing the Player struct, let's modify its draw function to swap sprites when shooting:

src/model/player.rs
pub fn draw(&self) {
    // Get reference to SpriteAnimation for player
    let anim = animation::get("p_key");
    // Begin to construct the string for which sprite to use
    let mut sprite = "player".to_string();
    if self.shooting { 
        sprite.push_str("_shooting"); 
    } 
    // Assign the sprite string to the SpriteAnimation
    anim.use_sprite(&sprite);
    // Draw sprite
    sprite!(
        animation_key = "p_key",
        x = self.x,
        y = self.y,
    );
}

Now, if the shooting bool is true, we add "_shooting" to the end of our sprite string, making it "player_shooting".

Finally, we need to update the call to the Player's update() function in lib.rs in order to pass our projectile list:

src/lib.rs
self.player.update() 
self.player.update(&mut self.projectiles); 

Save all your work, and you should now be able to shoot projectiles in the game window by pressing Space or Z!

Player shooting projectiles

Play around with the values for fire_speed and projectile_speed to give the game your own spin.

Enemies

Now that we have a framework for combat, it's time to add enemies!

Start once again by creating a new file in our model folder called enemy.rs and reference it in mod.rs.

space-shooter/              # Your project's root directory.
β”œβ”€β”€ audio/                  # The directory of your audio assets.
β”œβ”€β”€ sprites/                # The directory of your sprite assets.
β”œβ”€β”€ src/                    # The directory of your code.
β”‚   β”œβ”€β”€ model/              # The directory for sub files. 
β”‚   β”‚   └── enemy.rs        # A sub file to contain the Enemy struct. 
β”‚   β”‚   └── mod.rs          # A reference to all sub files in this folder.
β”‚   β”‚   └── player.rs       # A sub file to contain the Player struct.
β”‚   β”‚   └── projectile.rs   # A sub file to contain the Projectile struct.
β”‚   └── lib.rs              # The main file for the game.
src/model/mod.rs
use super::*;
 
mod enemy; 
pub use enemy::*; 
 
mod player;
pub use player::*;
 
mod projectile;
pub use projectile::*;

Copy the following code into the new enemy.rs file:

src/model/enemy.rs
use super::*;
 
// Different types of enemies
#[turbo::serialize]
#[derive(PartialEq)]
pub enum EnemyType {
    Meteor,
    Shooter,
    Tank,
    Turret,
    Zipper,
}
 
#[turbo::serialize]
// Struct for Enemies
pub struct Enemy {
    enemy_type: EnemyType,
    
    pub hitbox: Bounds,
    x: f32,
    y: f32, 
    pub angle: f32,
 
    pub hp: u32,
 
    hit_timer: u32, // used for hit animation
    pub destroyed: bool,
}
 
impl Enemy {
    // Initialize different enemy types with different properties
    pub fn new(enemy_type: EnemyType) -> Self {
        let (x,y) = ((random::u32() % screen().w()).saturating_sub(32) as f32, -32.0);
        // Set initial properties based on enemy type
        Self {
            enemy_type: enemy_type,
            
            hitbox: Bounds::new(x, y, 16, 16),
            x, 
            y,
            angle: 0.0,
 
            hp: 8,
 
            hit_timer: 0,
            destroyed: false,
        }
    }
 
    pub fn update(&mut self, projectiles: &mut Vec<Projectile>) {
 
    }
 
    pub fn draw(&self) {
 
    }
}

Again, we outline the struct for updating and drawing. This time, we add some logic to the new() function to randomly position enemies when we spawn them. And we include a destroyed bool, like we do with projectiles.

The EnemyType enum will be used to add variety to our enemies in the future. For now, we will only implement one simple enemy, the Turret variant.

Updating and Drawing Enemies

Same as before, fill out the blank update() and draw() functions:

src/model/enemy.rs
pub fn update(&mut self, projectiles: &mut Vec<Projectile>) {
    // Move down
    let speed = 0.5;
    self.y += speed;
    // Random chance to fire projectile
    if random::u32() % 250 == 0 {
        // Create and shoot projectiles from enemy towards the player
        projectiles.push(Projectile::new(
            self.x + (self.hitbox.w() as f32 * 0.5) - (self.hitbox.w() as f32 * 0.5),
            self.y + (self.hitbox.h() as f32),
            2.5,
            90.0,
            ProjectileOwner::Enemy,
        ));
    }
    // Flag to destroy if moved offscreen 
    if self.y > (screen().h() + self.hitbox.h()) as f32 {
        self.destroyed = true;
    }
    // Set hitbox position based on float xy values
    self.hitbox = self.hitbox.position(self.x, self.y);
}
src/model/enemy.rs
pub fn draw(&self) {
    // Construct the string for which sprite to use
    let sprite = match self.enemy_type {
        EnemyType::Tank => "tank",
        EnemyType::Shooter => "shooter",
        EnemyType::Turret => "turret",
        EnemyType::Zipper => "zipper",
        EnemyType::Meteor => "meteor",
    };
    // Draw sprite
    sprite!(
        &sprite,
        x = self.hitbox.x(),
        y = self.hitbox.y(),
    );
}

Integrate Enemies into the Game

Now we have an Enemy struct that updates and draws, so let's add some logic to the GameState to spawn them on an interval and call those functions.

First, add new properties to the GameState, then define a functions to spawn enemies. Then, call the spawning function, as well as updating and drawing all spawned enemies.

src/lib.rs
use turbo::*;
mod model;
pub use model::*;
 
#[turbo::game]
struct GameState {
    player: Player, 
    enemies: Vec<Enemy>, 
    projectiles: Vec<Projectile>, 
}
 
impl GameState {
    fn new() -> Self {
        Self {
            player: Player::new(),
            enemies: vec![], 
            projectiles: vec![], 
        }
    }
 
    fn update(&mut self) {
        self.draw();
 
        self.player.update(&mut self.projectiles);
        self.spawn_enemies(); 
        self.enemies.retain_mut(|enemy| { 
            enemy.update(&mut self.projectiles); 
            !enemy.destroyed 
        }); 
        self.projectiles.retain_mut(|projectile| { 
            projectile.update(); 
            !projectile.destroyed
        }); 
    }
 
    fn draw(&self) {
        self.player.draw();
        for enemy in self.enemies.iter() { 
            enemy.draw(); 
        } 
        for projectile in self.projectiles.iter() {
            projectile.draw();
        } 
    } 
 
    fn spawn_enemies(&mut self) { 
        let spawn_rate = 100; 
        // Spawn a new enemy if the tick is a multiple of the spawn rate and there are less than 24 enemies already spawned 
        if time::tick() % spawn_rate == 0 && self.enemies.len() < 24 { 
            // Spawn a new Turret enemy 
            self.enemies.push( 
                Enemy::new(EnemyType::Turret) 
            ); 
        } 
    } 
}

Save all your work, and you should see a swarm of enemies flooding your game window!

The last step to finishing our combat system is to check for collisions between ships and projectiles, and manage their hit points!

Collisions and HP

Before we register collisions between ships and projectiles, we'll define damage functions for the Player and Enemy structs. Add these functions inside of the impl scope for each struct:

src/model/player.rs
pub fn take_damage(&mut self, damage: u32) {
    self.hp = self.hp.saturating_sub(damage); // reduce HP by damage amount
    camera::shake(5.0); // camera shake
    self.hit_timer = 30; // invincibility frame timer and drawing flag
}
src/model/enemy.rs
pub fn take_damage(&mut self, player: &mut Player, damage: u32) {
    // reduce HP by damage amount and set hit timer for hit effect
    self.hp = self.hp.saturating_sub(damage);
    self.hit_timer = 15; // frames to show hit effect
    // Destroy enemy and increase player score if hp is 0
    if self.hp == 0 {
        self.destroyed = true;
        player.score += 20;
    }
}

Also add these lines to the end of the Player update() and Enemy update() functions:

src/model/player.rs
self.hit_timer = self.hit_timer.saturating_sub(1);
// Remove the camera shake
if self.hit_timer == 0 {
    camera::remove_shake();
}
src/model/enemy.rs
self.hit_timer = self.hit_timer.saturating_sub(1);

These functions will decrease hp values for both Player and Enemy when called. Player will shake the camera, while Enemy will flag destroyed and increase the Player's score. Both structs have a hit_timer variable we will use for animating a damaged state, which needs to be decreased every frame.

Now we can expand our Projectile struct. Add some logic to check for collisions, and call these damage functions:

src/model/projectile.rs
 
pub fn update(&mut self, player: &mut Player, enemies: &mut Vec<Enemy>) { 
    // If the projectile hasn't collided, update it as normal
    if !self.collided {
        // update projectile position
        let radian_angle = self.angle.to_radians();
        self.x += self.velocity * radian_angle.cos();
        self.y += self.velocity * radian_angle.sin();
 
        // flag the projectile to be destroyed if it goes off screen
        if self.y < -(self.hitbox.h() as f32)
        && self.x < -(self.hitbox.w() as f32)
        && self.x > screen().w() as f32
        && self.y > screen().h() as f32
        {
            self.destroyed = true;
        }
 
        // Checking for collisions with player or enemies based on projectile owner 
        match self.projectile_owner { 
            // Check collision with player 
            ProjectileOwner::Enemy => { 
                if self.hitbox.intersects(&player.hitbox) 
                && player.hp > 0
                && player.hit_timer == 0 { // player doesn't have i-frames
                    player.take_damage(self.damage);  
                    audio::play("projectile_hit");  
                    self.collided = true; 
                } 
            } 
            // Check collision with enemies 
            ProjectileOwner::Player => { 
                for enemy in enemies.iter_mut() { 
                    if self.hitbox.intersects(&enemy.hitbox) && !enemy.destroyed { 
                        enemy.take_damage(player, self.damage); 

                        audio::play("projectile_hit"); 
                        self.collided = true; 
                        break; // Exit loop after first collision 
                    } 
                } 
            } 
        } 
    // if the projectile has collided, 
    } else {
        // get reference to the SpriteAnimation of the projectile
        let anim = animation::get(&self.anim_key);
        // flag projectile as destroyed when the hit animation is done
        if anim.done() {
            self.destroyed = true;
        }
    }
 
    // Set hitbox position based on float xy values
    self.hitbox = self.hitbox.position(self.x, self.y);
}

And for our last step, return to lib.rs to update the call to our projectiles' update() function:

src/lib.rs
fn update(&mut self) {
    self.draw();
 
    self.player.update(&mut self.projectiles);
    self.spawn_enemies(); 
    self.enemies.retain_mut(|enemy| { 
        enemy.update(&mut self.projectiles); 
        !enemy.destroyed 
    }); 
    self.projectiles.retain_mut(|projectile| { 
        projectile.update(&mut self.player, &mut self.enemies); 
        !projectile.destroyed
    }); 
}

Save everything, and click back into your game window. Move around, shoot some enemies, and take some damage.

Completed Game Loop!

Game State Machine

Now that our core gameplay is running, let's add some logic to set up a start and end, and a way to replay the game.

Start by creating a new enum in lib.rs and properties in GameState:

src/lib.rs
use turbo::*;
mod model;
pub use model::*;
 
#[turbo::serialize] 
#[derive(PartialEq)] 
enum Screen { 
    Menu, 
    Game, 
} 
 
#[turbo::game]
struct GameState {
    screen: Screen, 
    start_tick: usize, 
 
    player: Player, 
    enemies: Vec<Enemy>,
    projectiles: Vec<Projectile>, 
}
 
impl GameState {
    fn new() -> Self {
        Self {
            screen: Screen::Menu, 
            start_tick: 0, 
 
            player: Player::new(),
            enemies: vec![],
            projectiles: vec![], 
        }
    }
...

We'll use this enum to manage what we call in our update() scope, and the tick keeps track of when the game started for future timing logic:

src/lib.rs
fn update(&mut self) {
    self.draw();
    // Menu
    if self.screen == Screen::Menu { 
        if gamepad::get(0).start.just_pressed() || gamepad::get(0).a.just_pressed() { 
            self.screen = Screen::Game; // transition scene
            self.start_tick = time::tick(); 
        } 
    // Game
    } else { 
        self.player.update(&mut self.projectiles);
        self.spawn_enemies(); 
        self.enemies.retain_mut(|enemy| { 
            enemy.update(&mut self.projectiles); 
            !enemy.destroyed 
        }); 
        self.projectiles.retain_mut(|projectile| {
            projectile.update(&mut self.player, &mut self.enemies);
            !projectile.destroyed
        });
        // Game Over
        if self.player.hp == 0 { 
            if gamepad::get(0).start.just_pressed() || gamepad::get(0).a.just_pressed() { 
                *self = GameState::new(); 
            } 
        } 
    } 
}

Now our game has a simple, built-in loop. Let's just add some text to show the player what's happening. Add this function to the impl scope of GameState:

src/lib.rs
fn draw_screen(&self) {
    // If in menu or game over state
    if self.screen == Screen::Menu || self.player.hp == 0 {
        // Determine title string
        let title = 
            if self.screen == Screen::Menu { "SPACE SHOOTER" } 
            else { "GAME OVER" };
        // Draw title
        text!(
            &title,
            x = (screen().w() as i32 / 2) - (title.chars().count() as i32 * 4),
            y = (screen().h() as i32 / 2) - 16,
            font = "large"
        );
        // Draw prompt to start game, blinking every half second
        if time::tick() / 4 % 8 < 4 {
            text!(
                "Press A or Start",
                x = (screen().w() as i32 / 2) - 38,
                y = (screen().h() as i32 / 2) + 8,
            );
        }
    }
}

And lastly, call the new function at the end of GameState's draw() function:

src/lib.rs
fn draw(&self) {
    self.player.draw();
    for projectile in self.projectiles.iter() { 
        projectile.draw(); 
    } 
    for enemy in self.enemies.iter() { 
        enemy.draw(); 
    } 
 
    self.draw_screen(); 
}

Displaying Game Info

Next, let's give the player some info about their status by adding a simple heads up display! We'll create a UI to display the player's HP, score, and notifications.

Before we get to drawing, add some properties in the Player struct to store notifications:

src/model/player
#[turbo::serialize]
pub struct Player {
    pub hitbox: Bounds,
    x: f32,
    y: f32,
    dx: f32, // dx and dy used for velocity
    dy: f32,
    
    pub hp: u32,
    
    pub hit_timer: u32, // used for invincibility frames and drawing
    shoot_timer: u32, // used for rate of fire
    shooting: bool, // used for shooting animation
 
    // variables used by the HUD to display information
    pub score: u32,
    notifications: Vec<String>, 
    notification_timer: usize, 
}
 
impl Player {
    pub fn new() -> Self {
        let x = ((screen().w() / 2) - 8) as f32;
        let y = (screen().h() - 64) as f32;
        Player {
            // Initialize all fields with default values
            hitbox: Bounds::new(x, y, 16, 16),
            x,
            y,
            dx: 0.0,
            dy: 0.0,
            hp: 3,
            
            hit_timer: 0,
            shoot_timer: 0,
            shooting: false,
            
            score: 0,
            notifications: vec![ 
                "Use arrow keys to move.".to_string(), 
                "Press SPACE or A to shoot.".to_string(), 
                "Defeat enemies and collect powerups.".to_string(), 
                "Try to not die. Good luck!".to_string(), 
            ], 
            notification_timer: 0, 
        }
    }

This vec and usize timer will keep track of the notifications we'll display to the player. The list will act as a queue, where the first element is displayed, and the timer will increment whenever the queue is not empty.

Add the following lines to the end of the Player update() function:

src/model/player
pub fn update(&mut self, projectiles: &mut Vec<Projectile>) { 
    ...
    self.hit_timer = self.hit_timer.saturating_sub(1);
    // Remove the camera shake
    if self.hit_timer == 0 {
        camera::remove_shake();
    }
 
    // Notifications timer 
    if self.notifications.len() > 0 { 
        self.notification_timer += 1; 
        // Remove current notification if timer expires 
        if self.notification_timer >= 120 - 1 { 
            self.notification_timer = 0; 
            let _ = self.notifications.remove(0); 
        } 
    } 
}

This scope runs when there are notifications to display, increasing the notifications timer, resetting it and removing the first displayed notification from the list.

We aren't going to send any notifications in this tutorial, but you could use them when significant events happen in the game, like if the player finds a new power up or gets an achievement.

Now we can create a function to draw all of our heads up display information! Add the following function to the impl scope of the Player struct:

src/model/player
pub fn draw_hud(&self) {
    let hud_height = 16; // Height of the HUD panel
    let hud_padding = 4; // Padding inside the HUD
 
    // Background rectangle
    rect!(
        x = -1,
        y = -1,
        w = screen().w() + 2,
        h = hud_height + 2,
        border_size = 1,
        color = 0x000000ff,
        border_color = 0xffffffff,
    ); 
    
    // Display Health
    let health_text = format!("HP: {}", self.hp);
    text!(
        &health_text,
        x = hud_padding,
        y = hud_padding,
        font = "large",
        color = 0xffffffff
    );
 
    // Display Score
    let score_text = format!("SCORE: {:0>5}", self.score);
    let score_text_x = // anchor to the right side of the screen
        screen().w() as i32 - (score_text.chars().count() as i32 * 8) - hud_padding;
    text!(
        &score_text,
        x = score_text_x,
        y = hud_padding,
        font = "large",
        color = 0xffffffff
    );
 
    // Draw notifications
    for notif in self.notifications.iter() {
        // center the text based on width of characters
        let len = notif.chars().count();
        let w = len * 5;
        let x = (screen().w() as usize / 2) - (w / 2);
        rect!(
            x = x as i32 - 4,
            y = 24 - 2,
            w = w as u32 + 4,
            h = 12,
            color = 0x5fcde4ff
        );
        text!(
            &notif,
            x = x as i32,
            y = 24,
            font = "medium",
            color = 0xffffffff
        );
        break;
    }
}

This function simply uses rect!()s and text!()s with some math for centering to display information already stored in the Player struct.

So now that we have all our info in one place, we can call our new function in the GameState's draw() function:

src/lib.rs
fn draw(&self) {
    self.player.draw();
    for projectile in self.projectiles.iter() { 
        projectile.draw(); 
    } 
    for enemy in self.enemies.iter() { 
        enemy.draw(); 
    } 
 
    self.player.draw_hud(); 
    self.draw_screen();
}

Save your files and check your game window for a more informed playthrough!

Conclusion

Wow. After all that code, you have a playable game loop with a solid foundation. Great work!

We created Player, Enemy, and Projectile structs, are updating and drawing them using the GameState, checking for collisions between them all, and organized 400+ lines of code into easily readable sub files!

Next Steps

  • Add more enemy types. Give them new sprites and movement patterns.
  • Create power-ups for the player to collect
  • Change the speed, fire_rate or enemy.hp to personalize the game
  • Use turbo -export to export your game and host it on the web