One Fight Is Not a Game.
Put Your Rust Game in One Place With impl and Methods

Every fight in our game ends the same way.
The slime dies.
Then the program dies with it.
So far, winning has been how our game ends.
In an idle game, winning is how the game keeps going.
One enemy falls, and the next one walks in.
In this article, we are going to give our game a next stage, a tougher enemy waiting there, and one place to keep all of it.
By the end, a whole turn of our game will fit in one line:
game.click();
That one call will attack the enemy, notice when it dies, advance the stage, and send in the next one.
Our starting point#
So far, we have a player, a slime, and a way for one to hit the other.
You need two structs and four small functions to follow along:
struct Player {
base_damage: u32,
bonus_damage: u32,
}
struct Enemy {
name: String,
health: u32,
}
fn click_damage(base: u32, bonus: u32) -> u32 {
base.saturating_add(bonus)
}
fn health_after_hit(
health: u32,
damage: u32,
) -> u32 {
health.saturating_sub(damage)
}
fn is_defeated(health: u32) -> bool {
health == 0
}
fn attack(
player: &Player,
enemy: &mut Enemy,
) {
let damage = click_damage(
player.base_damage,
player.bonus_damage,
);
enemy.health =
health_after_hit(enemy.health, damage);
}
attack() borrows the player to read it and the enemy to change it.
Our combat loop looks like this:
fn main() {
let player = Player {
base_damage: 5,
bonus_damage: 2,
};
let mut enemy = Enemy {
name: String::from("Training Slime"),
health: 20,
};
while !is_defeated(enemy.health) {
attack(&player, &mut enemy);
println!(
"{} health: {}",
enemy.name,
enemy.health
);
}
println!("{} defeated!", enemy.name);
}
Run it and the Training Slime goes down in three hits.
Then the program is over.
The next enemy needs to be tougher#
Before we worry about where the next enemy comes from, we need to decide what it looks like.
If every enemy had 20 health, stage 50 would feel exactly like stage 1.
The next enemy should be tougher.
That is a game rule, and we know what to do with game rules by now.
Give it a small function:
fn enemy_health_for_stage(stage: u32) -> u32 {
stage.saturating_mul(10).saturating_add(10)
}
That gives us:
stage 1 → 20 health
stage 2 → 30 health
stage 3 → 40 health
saturating_mul() is the multiplication version of the saturating arithmetic we already use.
The exact balance does not matter yet.
What matters is that the rule has a name and a test:
#[test]
fn later_stages_have_tougher_enemies() {
assert_eq!(enemy_health_for_stage(1), 20);
assert_eq!(enemy_health_for_stage(2), 30);
}
Try it: change the numbers in the rule, or ask for a much later stage, and run it again.
You should see 20, 30 and 40 health, then Checks passed.
The Playground only runs #[test] functions in its Test mode, so these checks use assert_eq! inside main() instead.
If you change the formula, an assert_eq! fails and tells you which numbers disagreed.
fn enemy_health_for_stage(stage: u32) -> u32 {
stage.saturating_mul(10).saturating_add(10)
}
fn main() {
println!("stage 1 -> {} health", enemy_health_for_stage(1));
println!("stage 2 -> {} health", enemy_health_for_stage(2));
println!("stage 3 -> {} health", enemy_health_for_stage(3));
assert_eq!(enemy_health_for_stage(1), 20);
assert_eq!(enemy_health_for_stage(2), 30);
println!("Checks passed.");
}
If Rust warns that the function is never used yet, that is fine.
We are about to use it.
Try it in main() first#
The quickest way to make stages work is to put everything straight into main():
fn main() {
let player = Player {
base_damage: 5,
bonus_damage: 2,
};
let mut stage = 1;
let mut enemy = Enemy {
name: String::from("Training Slime"),
health: enemy_health_for_stage(stage),
};
while stage <= 3 {
attack(&player, &mut enemy);
if is_defeated(enemy.health) {
println!("Stage {stage} cleared!");
stage = stage.saturating_add(1);
enemy = Enemy {
name: String::from("Training Slime"),
health: enemy_health_for_stage(stage),
};
} else {
println!(
"{} health: {}",
enemy.name,
enemy.health
);
}
}
}
We stop after stage 3 so the terminal does not scroll forever.
Run it.
It works.
The slime falls, a tougher one takes its place, and the stages keep coming.
It is also a mess.
Look at what main() is doing now:
owns the player
owns the enemy
owns the stage
decides when a stage is cleared
builds the next enemy
prints everything
Only that last job really belongs in main().
Everything else is the game.
Soon, defeating an enemy will also hand out rewards.
If all of those rules stay here, main() becomes the game engine and the terminal UI at the same time.
There is another reason to fix that now.
Later, the browser will replace main().
The game rules need somewhere else to live.
Give the game a shape#
We have solved this kind of problem before.
Three loose numbers were not a player, so we built a Player.
A stage, a player, and an enemy sitting loose in main() are not a game.
So we build a Game:
struct Game {
stage: u32,
player: Player,
enemy: Enemy,
}
This struct contains other structs:
Game
├── stage: u32
├── player: Player
└── enemy: Enemy
A struct can hold types we made ourselves.
The Game owns its Player.
It owns its Enemy.
There is now one value we can point at and say:
This is the current state of the game.
Where does a new enemy come from?#
We could create a game by spelling out every field manually:
let mut game = Game {
stage: 1,
player: Player {
base_damage: 5,
bonus_damage: 2,
},
enemy: Enemy {
name: String::from("Training Slime"),
health: enemy_health_for_stage(1),
},
};
That works.
But we have already written this enemy more than once:
Enemy {
name: String::from("Training Slime"),
health: enemy_health_for_stage(stage),
}
What we really want to say is:
Give me the enemy for this stage.
That sounds like a function.
And because the function is specifically about creating an Enemy, it belongs with Enemy.
Attach functions to a type with impl#
Rust lets us attach functions to a type with an impl block:
impl Enemy {
fn for_stage(stage: u32) -> Enemy {
Enemy {
name: String::from("Training Slime"),
health: enemy_health_for_stage(stage),
}
}
}
impl Enemy means:
here are functions that belong to
Enemy
Now we can write:
Enemy::for_stage(1)
You have already seen this style of call:
String::from("Training Slime")
A function attached to a type that does not need an existing value is called an associated function.
Enemy::for_stage() does not replace our small health rule.
It uses it:
health: enemy_health_for_stage(stage),
One function decides how tough the enemy is.
The other decides how to build one.
A new game#
Game gets an impl block too:
impl Game {
fn new() -> Game {
Game {
stage: 1,
player: Player {
base_damage: 5,
bonus_damage: 2,
},
enemy: Enemy::for_stage(1),
}
}
}
There is nothing magical about new.
Rust has no special constructor keyword.
new is simply a common name for an associated function that creates a fresh value.
Starting the game is now:
let mut game = Game::new();
Game setup has one home.
And it is no longer main().
One click#
Now the part we have been building toward.
What happens when the player clicks?
Add this inside impl Game:
fn click(&mut self) {
attack(&self.player, &mut self.enemy);
}
There is one new name in that signature:
self
self means:
the value this method was called on
When we write:
game.click();
inside click(), self refers to game.
That makes click() a method: a function attached to a type that works on a specific value of that type.
Game::new() does not take self because there is no game yet.
click() does because it works on a game that already exists.
And &mut self should look familiar.
It means click() may temporarily change the game without taking ownership of it.
That is why this needs:
let mut game = Game::new();
Without mut, Rust refuses to let click() borrow the game mutably.
Two borrows, one game#
Look inside click():
attack(&self.player, &mut self.enemy);
Last time, we learned:
For the same data at the same time: many readers, or one writer, but not both.
So why can we borrow part of the game to read and another part to change?
Because they are not the same data.
self.player and self.enemy are different fields.
They do not overlap.
Rust can see that reading the player does not interfere with changing the enemy.
Our attack() did not need to change.
The borrowing simply moved inside the game.
What happens when the enemy dies?#
Right now, click() only attacks.
When the enemy reaches zero health, the next click would attack a corpse.
That is grim.
After every attack, click() should check whether the enemy was defeated:
fn click(&mut self) {
attack(&self.player, &mut self.enemy);
if is_defeated(self.enemy.health) {
self.defeat_enemy();
}
}
Now we need defeat_enemy():
fn defeat_enemy(&mut self) {
self.stage = self.stage.saturating_add(1);
self.enemy = Enemy::for_stage(self.stage);
}
The stage goes up.
The defeated enemy is replaced with the enemy for the new stage.
We could put both lines directly inside click().
But defeating an enemy is its own event, and that event is going to grow.
Soon it will also hand out rewards.
Keeping it named means click() can stay readable:
Attack the enemy. If it went down, handle the defeat.
And defeat_enemy() says:
Go to the next stage. Bring in the next enemy.
Try it: here is the game so far, clicked three times.
You should see Stage 1: Training Slime health: 13, then Stage 1: Training Slime health: 6, then Stage 2: Training Slime health: 30.
Then try two experiments:
- delete the
ifblock inclick()and the third click leaves the slime athealth: 0on stage 1, because nothing handles the defeat - put the
ifblock back and removemutfromlet mut game, and Rust refuses withE0596
struct Player {
base_damage: u32,
bonus_damage: u32,
}
struct Enemy {
name: String,
health: u32,
}
struct Game {
stage: u32,
player: Player,
enemy: Enemy,
}
impl Enemy {
fn for_stage(stage: u32) -> Enemy {
Enemy {
name: String::from("Training Slime"),
health: enemy_health_for_stage(stage),
}
}
}
impl Game {
fn new() -> Game {
Game {
stage: 1,
player: Player {
base_damage: 5,
bonus_damage: 2,
},
enemy: Enemy::for_stage(1),
}
}
fn click(&mut self) {
attack(&self.player, &mut self.enemy);
if is_defeated(self.enemy.health) {
self.defeat_enemy();
}
}
fn defeat_enemy(&mut self) {
self.stage = self.stage.saturating_add(1);
self.enemy = Enemy::for_stage(self.stage);
}
}
fn click_damage(base: u32, bonus: u32) -> u32 {
base.saturating_add(bonus)
}
fn health_after_hit(health: u32, damage: u32) -> u32 {
health.saturating_sub(damage)
}
fn is_defeated(health: u32) -> bool {
health == 0
}
fn enemy_health_for_stage(stage: u32) -> u32 {
stage.saturating_mul(10).saturating_add(10)
}
fn attack(player: &Player, enemy: &mut Enemy) {
let damage = click_damage(player.base_damage, player.bonus_damage);
enemy.health = health_after_hit(enemy.health, damage);
}
fn main() {
let mut game = Game::new();
game.click();
println!("Stage {}: {} health: {}", game.stage, game.enemy.name, game.enemy.health);
game.click();
println!("Stage {}: {} health: {}", game.stage, game.enemy.name, game.enemy.health);
game.click();
println!("Stage {}: {} health: {}", game.stage, game.enemy.name, game.enemy.health);
}
main() gets boring#
Here is the new main():
fn main() {
let mut game = Game::new();
while game.stage <= 3 {
let stage = game.stage;
game.click();
if game.stage > stage {
println!("Stage {stage} cleared!");
} else {
println!(
"{} health: {}",
game.enemy.name,
game.enemy.health
);
}
}
}
main() still reads the game so it can decide what to print.
But it does not:
- build enemies
- calculate their health
- decide when a stage advances
- handle a defeat
It creates a game, clicks, and prints.
Boring is exactly what we want.
Run:
cargo run
You should see:
Training Slime health: 13
Training Slime health: 6
Stage 1 cleared!
Training Slime health: 23
Training Slime health: 16
Training Slime health: 9
Training Slime health: 2
Stage 2 cleared!
Training Slime health: 33
Training Slime health: 26
Training Slime health: 19
Training Slime health: 12
Training Slime health: 5
Stage 3 cleared!
Three stages.
Each slime tougher than the last.
And the game, not main(), decided all of that.
Test what a player experiences#
Our first tests check individual rules.
Game lets us start testing something larger: what happens when those rules work together.
A new game should start at stage 1:
#[test]
fn a_new_game_starts_at_stage_one() {
let game = Game::new();
assert_eq!(game.stage, 1);
assert_eq!(game.enemy.health, 20);
}
A click should hurt the enemy:
#[test]
fn clicking_damages_the_enemy() {
let mut game = Game::new();
game.click();
assert_eq!(game.enemy.health, 13);
}
And defeating an enemy should move the game forward:
#[test]
fn defeating_an_enemy_advances_the_stage() {
let mut game = Game::new();
game.enemy.health = 1;
game.click();
assert_eq!(game.stage, 2);
assert_eq!(game.enemy.health, 30);
}
That last test does not care how damage is calculated.
Those smaller rules already have their own tests.
It asks a larger question:
When a click defeats an enemy, does the game move on?
Run:
cargo test
All eight should pass.
Coding challenge: Click many times
Add a method to Game called click_many that calls self.click() once for every click requested. Keep the name click_many and its clicks argument, because the checks call it. Press Check my code and it will tell you how you are doing.
Expected result: the starter prints Stage 1 - Training Slime health: 20, and your finished version prints Stage 2 - Training Slime health: 16.
struct Player {
base_damage: u32,
bonus_damage: u32,
}
struct Enemy {
name: String,
health: u32,
}
struct Game {
stage: u32,
player: Player,
enemy: Enemy,
}
impl Enemy {
fn for_stage(stage: u32) -> Enemy {
Enemy {
name: String::from("Training Slime"),
health: enemy_health_for_stage(stage),
}
}
}
impl Game {
fn new() -> Game {
Game {
stage: 1,
player: Player {
base_damage: 5,
bonus_damage: 2,
},
enemy: Enemy::for_stage(1),
}
}
fn click(&mut self) {
attack(&self.player, &mut self.enemy);
if is_defeated(self.enemy.health) {
self.defeat_enemy();
}
}
// TODO: call self.click() once for every click requested
fn click_many(&mut self, clicks: u32) {}
fn defeat_enemy(&mut self) {
self.stage = self.stage.saturating_add(1);
self.enemy = Enemy::for_stage(self.stage);
}
}
fn click_damage(base: u32, bonus: u32) -> u32 {
base.saturating_add(bonus)
}
fn health_after_hit(health: u32, damage: u32) -> u32 {
health.saturating_sub(damage)
}
fn is_defeated(health: u32) -> bool {
health == 0
}
fn enemy_health_for_stage(stage: u32) -> u32 {
stage.saturating_mul(10).saturating_add(10)
}
fn attack(player: &Player, enemy: &mut Enemy) {
let damage = click_damage(player.base_damage, player.bonus_damage);
enemy.health = health_after_hit(enemy.health, damage);
}
fn main() {
let mut game = Game::new();
game.click_many(5);
println!("Stage {} - {} health: {}", game.stage, game.enemy.name, game.enemy.health);
}
The complete program#
If you are only reading along, you can skip to the last two sections.
If you are coding along, here is everything in one place:
struct Player {
base_damage: u32,
bonus_damage: u32,
}
struct Enemy {
name: String,
health: u32,
}
struct Game {
stage: u32,
player: Player,
enemy: Enemy,
}
impl Enemy {
fn for_stage(stage: u32) -> Enemy {
Enemy {
name: String::from("Training Slime"),
health: enemy_health_for_stage(stage),
}
}
}
impl Game {
fn new() -> Game {
Game {
stage: 1,
player: Player {
base_damage: 5,
bonus_damage: 2,
},
enemy: Enemy::for_stage(1),
}
}
fn click(&mut self) {
attack(&self.player, &mut self.enemy);
if is_defeated(self.enemy.health) {
self.defeat_enemy();
}
}
fn defeat_enemy(&mut self) {
self.stage = self.stage.saturating_add(1);
self.enemy = Enemy::for_stage(self.stage);
}
}
fn click_damage(base: u32, bonus: u32) -> u32 {
base.saturating_add(bonus)
}
fn health_after_hit(
health: u32,
damage: u32,
) -> u32 {
health.saturating_sub(damage)
}
fn is_defeated(health: u32) -> bool {
health == 0
}
fn enemy_health_for_stage(stage: u32) -> u32 {
stage.saturating_mul(10).saturating_add(10)
}
fn attack(
player: &Player,
enemy: &mut Enemy,
) {
let damage = click_damage(
player.base_damage,
player.bonus_damage,
);
enemy.health =
health_after_hit(enemy.health, damage);
}
fn main() {
let mut game = Game::new();
while game.stage <= 3 {
let stage = game.stage;
game.click();
if game.stage > stage {
println!("Stage {stage} cleared!");
} else {
println!(
"{} health: {}",
game.enemy.name,
game.enemy.health
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn click_damage_adds_base_and_bonus() {
assert_eq!(click_damage(3, 4), 7);
}
#[test]
fn health_never_drops_below_zero() {
assert_eq!(
health_after_hit(3, 10),
0
);
}
#[test]
fn zero_health_is_defeated() {
assert!(is_defeated(0));
assert!(!is_defeated(1));
}
#[test]
fn attack_reduces_enemy_health() {
let player = Player {
base_damage: 5,
bonus_damage: 2,
};
let mut enemy = Enemy {
name: String::from("Training Slime"),
health: 10,
};
attack(&player, &mut enemy);
assert_eq!(enemy.health, 3);
}
#[test]
fn later_stages_have_tougher_enemies() {
assert_eq!(enemy_health_for_stage(1), 20);
assert_eq!(enemy_health_for_stage(2), 30);
}
#[test]
fn a_new_game_starts_at_stage_one() {
let game = Game::new();
assert_eq!(game.stage, 1);
assert_eq!(game.enemy.health, 20);
}
#[test]
fn clicking_damages_the_enemy() {
let mut game = Game::new();
game.click();
assert_eq!(game.enemy.health, 13);
}
#[test]
fn defeating_an_enemy_advances_the_stage() {
let mut game = Game::new();
game.enemy.health = 1;
game.click();
assert_eq!(game.stage, 2);
assert_eq!(game.enemy.health, 30);
}
}
Run both:
cargo run
cargo test
The game should clear three stages, and all eight tests should pass.
What we learned#
Along the way, we encountered:
- structs that own other structs
implblocks- associated functions like
Enemy::for_stage() new()as a convention, not a keyword- methods
selfand&mut self- borrowing different fields at the same time
saturating_mul()- tests that describe larger game behavior
You do not need to memorize all of that yet.
The important change is where the game lives.
We started with a main() that owned everything and decided everything.
We ended with:
let mut game = Game::new();
game.click();
One click is now one full turn:
game.click()
↓
attack(&self.player, &mut self.enemy)
↓
click_damage()
↓
health_after_hit()
↓
is_defeated()?
↓
defeat_enemy()
↓
next stage + Enemy::for_stage()
Look at the bottom of that chain.
click_damage(), health_after_hit(), and is_defeated() are the same small functions we started the series with.
They have not changed.
They are simply a few layers deeper now, inside something that finally looks like a game.
Winning does not pay yet#
Our game has a real loop now.
Click. The slime falls. The next one walks in tougher than the last.
But look at what the player gets for all that clicking:
gold: nothing
experience: nothing
levels: nothing
We just cleared three stages.
Our player is exactly as strong as when the game started.
Every slime gets tougher.
Our player never does.
Keep going long enough, and every new slime takes more clicks than the last one.
Forever.
That is not progression.
That is a treadmill.
An idle game needs a reason to keep clicking: rewards for winning, levels to reach, and something to spend those rewards on.
And this time, we already know where those rewards will be handed out:
fn defeat_enemy(&mut self)
Next, we will make winning worth something.
Small function. Bigger game.
Comments require JavaScript.