Asteroid Dodger: Traits and Generics

intermediate Rusttraitsgenericsgame dev
0 / 0

This tutorial picks up where the weapons and bombs tutorial left off. The game has four entity types with nearly identical method signatures — pos, update, draw, is_off_screen — duplicated across separate files. This tutorial extracts those patterns into a trait, then writes generic functions that work with any entity type.

What you’ll change

Milestones overview

Milestone 1TraitsDefine Entity and implement it
Milestone 2GenericsGeneric functions and default methods

Prerequisites


Milestone 1 — Traits

Milestone 1 of 2

Step 1 — Define the Entity trait (src/entity.rs)

Look at the existing structs. Asteroid, Bullet, and Bomb all share the same shape:

pub pos: Vec2,
pub fn update(&mut self, dt: f32) { ... }
pub fn draw(&self) { ... }
pub fn is_off_screen(&self) -> bool { ... }

That repeated pattern is exactly what traits capture. Create src/entity.rs:

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

pub trait Entity {
    fn pos(&self) -> Vec2;
    fn update(&mut self, dt: f32);
    fn draw(&self);
}

Register the module in main.rs:

mod entity;

Three methods, no implementations — just the contract. Any type that implements Entity promises it can report its position, update its state, and draw itself.

Why not Player?

Player::update takes &Keybindings in addition to dt, so it doesn’t fit the Entity signature. Forcing it to conform would mean either ignoring the keybindings parameter or adding it to the trait — neither is a good fit. Traits should capture genuine shared behavior, not force every type into the same mold.

If you’ve used Go or Java, traits look like interfaces. The key difference: Rust traits can have default implementations, and when used with generics (<T: Entity>), the compiler generates specialized code for each concrete type. There’s no virtual function table and no runtime cost — this is called static dispatch.

Now implement the trait for each type.

src/asteroid.rs — add at the bottom:

use crate::entity::Entity;

impl Entity for Asteroid {
    fn pos(&self) -> Vec2 {
        self.pos
    }

    fn update(&mut self, dt: f32) {
        self.update(dt);
    }

    fn draw(&self) {
        self.draw();
    }
}

There’s a subtlety here. The trait’s update and draw methods have the same signatures as the inherent methods already on Asteroid. Rust resolves this without ambiguity: inside the impl Entity for Asteroid block, self.update(dt) calls the inherent method because inherent methods take priority. From outside, you call the trait method via the trait — Entity::draw(&asteroid) or through a generic bound.

src/bullet.rs — add at the bottom:

use crate::entity::Entity;

impl Entity for Bullet {
    fn pos(&self) -> Vec2 {
        self.pos
    }

    fn update(&mut self, dt: f32) {
        self.update(dt);
    }

    fn draw(&self) {
        self.draw();
    }
}

src/bomb.rs — add at the bottom:

use crate::entity::Entity;

impl Entity for Bomb {
    fn pos(&self) -> Vec2 {
        self.pos
    }

    fn update(&mut self, dt: f32) {
        self.update(dt);
    }

    fn draw(&self) {
        self.draw();
    }
}
Run it

cargo run — everything should compile and behave identically. Adding trait implementations is purely additive; nothing changes until we start using the trait.

Step 2 — Use the trait with a generic function

The Game::draw method has three identical loops:

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

Write a generic function that replaces all three. Add this to src/entity.rs:

pub fn draw_entities<T: Entity>(entities: &[T]) {
    for e in entities {
        e.draw();
    }
}

The <T: Entity> syntax means “any type T that implements the Entity trait.” The compiler generates a specialized version of draw_entities for each type you call it with — one for Asteroid, one for Bullet, one for Bomb. This is called monomorphization. At runtime, each version calls the concrete draw method directly, with zero overhead.

Now update Game::draw in src/game.rs. Add the import:

use crate::entity::draw_entities;

Replace the three draw loops with:

draw_entities(&self.asteroids);
draw_entities(&self.bullets);
draw_entities(&self.bombs);

Three loops become three calls. If you add a new entity type later, you implement Entity for it and pass its Vec to draw_entities — no new loop needed.

Generics vs trait objects

There’s another way to use traits: trait objects with dynamic dispatch. You’d write &dyn Entity instead of <T: Entity>. The compiler produces one version of the function and uses a vtable pointer to call the right method at runtime. It’s more flexible (you can mix different types in one collection) but has a small runtime cost per call. For game entity drawing, static dispatch with generics is the better fit — each Vec holds one type, and we want zero overhead in the draw loop.

Run it

cargo run — the game looks and plays exactly the same. The draw code is just shorter and more uniform.


Milestone 2 — Generics

Milestone 2 of 2

Step 3 — Generic retain function with where clause

The game has several places that remove entities based on distance or screen bounds. Write a generic helper that retains only entities within range of a point. Add to src/entity.rs:

pub fn retain_in_range<T>(entities: &mut Vec<T>, center: Vec2, max_dist: f32)
where
    T: Entity,
{
    entities.retain(|e| e.pos().distance(center) <= max_dist);
}

This does the same thing as <T: Entity> on the function signature, but uses a where clause instead. Both forms are equivalent — the where clause is preferred when you have multiple bounds or longer signatures because it keeps the function name readable.

For example, if you needed multiple bounds:

fn process<T>(items: &mut Vec<T>)
where
    T: Entity + Clone + std::fmt::Debug,
{
    // ...
}

Compare with the inline form: fn process<T: Entity + Clone + std::fmt::Debug>(items: &mut Vec<T>). The where version is easier to scan.

You can use retain_in_range anywhere you need distance-based cleanup. For example, in game.rs you could replace manual retain logic:

use crate::entity::retain_in_range;
// In update, after bomb explosions — keep only nearby fragments
retain_in_range(&mut self.asteroids, self.player.pos, 2000.0);

This is optional — the existing is_off_screen retain calls work fine. The point is that generic functions let you write the pattern once and apply it to any entity type.

Step 4 — Default methods and derive

The is_off_screen method is nearly identical across Asteroid, Bullet, and Bomb — check if the position is outside the screen with some margin. That’s a perfect candidate for a default method.

Update the Entity trait in src/entity.rs:

use macroquad::prelude::*;

pub trait Entity {
    fn pos(&self) -> Vec2;
    fn update(&mut self, dt: f32);
    fn draw(&self);

    fn is_off_screen(&self) -> bool {
        let (sw, sh) = (screen_width(), screen_height());
        let p = self.pos();
        p.x < -200.0 || p.x > sw + 200.0 || p.y < -200.0 || p.y > sh + 200.0
    }
}

pub fn draw_entities<T: Entity>(entities: &[T]) {
    for e in entities {
        e.draw();
    }
}

pub fn retain_in_range<T>(entities: &mut Vec<T>, center: Vec2, max_dist: f32)
where
    T: Entity,
{
    entities.retain(|e| e.pos().distance(center) <= max_dist);
}

The is_off_screen method has a body — that makes it a default method. Any type implementing Entity gets this behavior for free. If a type needs different bounds (like Bullet, which currently uses a tighter margin of 0.0 instead of 200.0), it can override the default by providing its own implementation.

In src/bullet.rs, the current is_off_screen uses zero margin — bullets disappear the moment they leave the screen. Override the default:

impl Entity for Bullet {
    fn pos(&self) -> Vec2 {
        self.pos
    }

    fn update(&mut self, dt: f32) {
        self.update(dt);
    }

    fn draw(&self) {
        self.draw();
    }

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

For Asteroid and Bomb, the default 200px margin matches their existing logic, so those implementations don’t need to override it. They get is_off_screen automatically.

Now you can use the trait method for cleanup. In game.rs, the retain calls can use the trait:

self.asteroids.retain(|a| !Entity::is_off_screen(a));
self.bombs.retain(|b| !b.exploded && !Entity::is_off_screen(b));

Or keep calling the inherent is_off_screen methods — both work. The default method just means you no longer need to write the same bounds-checking logic in every new entity type you add.

Derive macros — automatic trait implementations

You’ve been writing #[derive(Clone, Copy, PartialEq)] on structs like WeaponType throughout this series. Each one is a derive macro that auto-generates a trait implementation. Clone generates a clone() method, PartialEq generates == comparison — all by inspecting the struct’s fields at compile time. You can’t #[derive(Entity)] because Entity needs custom drawing logic, but for data-oriented traits like Clone, Debug, and PartialEq, derive macros eliminate boilerplate entirely.

Run it

cargo run — same game, cleaner code. The Entity trait captures the shared interface, generic functions eliminate duplicate loops, and default methods reduce copy-paste across entity types.


Complete listing

New file: entity.rs. Modified: asteroid.rs, bullet.rs, bomb.rs, game.rs, main.rs.

src/entity.rs (new)

use macroquad::prelude::*;

pub trait Entity {
    fn pos(&self) -> Vec2;
    fn update(&mut self, dt: f32);
    fn draw(&self);

    fn is_off_screen(&self) -> bool {
        let (sw, sh) = (screen_width(), screen_height());
        let p = self.pos();
        p.x < -200.0 || p.x > sw + 200.0 || p.y < -200.0 || p.y > sh + 200.0
    }
}

pub fn draw_entities<T: Entity>(entities: &[T]) {
    for e in entities {
        e.draw();
    }
}

pub fn retain_in_range<T>(entities: &mut Vec<T>, center: Vec2, max_dist: f32)
where
    T: Entity,
{
    entities.retain(|e| e.pos().distance(center) <= max_dist);
}

src/asteroid.rs — append impl Entity:

use crate::entity::Entity;

impl Entity for Asteroid {
    fn pos(&self) -> Vec2 { self.pos }
    fn update(&mut self, dt: f32) { self.update(dt); }
    fn draw(&self) { self.draw(); }
}

src/bullet.rs — append impl Entity (overrides is_off_screen):

use crate::entity::Entity;

impl Entity for Bullet {
    fn pos(&self) -> Vec2 { self.pos }
    fn update(&mut self, dt: f32) { self.update(dt); }
    fn draw(&self) { self.draw(); }

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

src/bomb.rs — append impl Entity:

use crate::entity::Entity;

impl Entity for Bomb {
    fn pos(&self) -> Vec2 { self.pos }
    fn update(&mut self, dt: f32) { self.update(dt); }
    fn draw(&self) { self.draw(); }
}

src/main.rs — add mod entity; after the other module declarations.

src/game.rs — add use crate::entity::draw_entities; and replace the three draw loops in Game::draw with:

draw_entities(&self.asteroids);
draw_entities(&self.bullets);
draw_entities(&self.bombs);

All other files are unchanged from the previous tutorial.

Next steps