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

One Fight Is Not a Game.
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 borrows the enemy to change it.
&Player is read-only access.
&mut Enemy is permission to change the enemy without taking it away from its owner.
We will lean on both of those again today.
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);
}
At the bottom of main.rs, a tests module holds four tests: one for each small rule, and one for attack(). You will see all of them in the complete program at the end.
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, next to our other small rules:
fn enemy_health_for_stage(stage: u32) -> u32 {
stage.saturating_mul(10).saturating_add(10)
}
Every stage adds ten more health.
stage 1 → 20 health
stage 2 → 30 health
stage 3 → 40 health
Stage 1 gives us 20 health, which happens to be the same Training Slime we have been fighting all along.
saturating_mul() is the multiplication version of the saturating_add() and saturating_sub() we already use.
It is the same game-design choice we made for damage and health: a number that goes wrong should stop at a sensible edge, not wrap around into nonsense.
If the stage ever gets absurdly high, the health stops at the largest u32 instead of overflowing.
The exact balance does not matter yet. We can tune it later.
What matters is that the rule has a name and a test.
Add it to the tests module:
#[test]
fn later_stages_have_tougher_enemies() {
assert_eq!(enemy_health_for_stage(1), 20);
assert_eq!(enemy_health_for_stage(2), 30);
}
If you build now, Rust warns that enemy_health_for_stage is never used.
That is a warning, not an error. Rust is pointing out code that nothing calls yet.
We are about to call it.
Try it in main() first
We have a rule for how tough the next enemy is.
Now we need the game to actually use it.
The quickest way is to put it straight into main().
We need a stage counter, and when an enemy is defeated, the stage goes up and a new enemy replaces the old one:
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.
Instead of announcing that an enemy was defeated, we now announce that the stage was cleared.
Run it:
cargo run
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
decides what happens next
builds the next enemy
prints everything
Only that last line is really the job of main().
Everything else is the game.
And this is the small version.
Soon, defeating an enemy will also need to hand out rewards.
Every one of those rules would land right here, tangled up with the code that prints to the terminal.
There is a bigger problem coming, too.
Later in this series, main() goes away completely.
A browser takes over.
If the rules of our game live inside main(), they leave with it.
We need somewhere else for them to live.
Give the game a shape
We have done this 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,
}
Put it under Enemy, with the other structs.
This struct is a little different from our first two.
Its fields are not just numbers and text.
Two of them are entire structs:
Game
├── stage: u32
├── player: Player
└── enemy: Enemy
A struct can hold any type, including the types we made ourselves.
When a Game holds a Player, it does not point at a player that lives somewhere else.
The game owns it.
Same for the enemy.
That is the whole point.
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 build a game the same way we built players and enemies:
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 look at the enemy.
We wrote this exact enemy in our messy main() already, once at the start and once after every defeat:
Enemy {
name: String::from("Training Slime"),
health: enemy_health_for_stage(stage),
}
Now it shows up a third time.
Every time we need an enemy, we spell out how to build one from scratch.
What we want to say is much shorter:
Give me the enemy for this stage.
That sounds like a function.
But this function is not a combat rule like attack().
It answers a question about enemies: how is one made?
So it should live 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 some functions that belong to
Enemy
Put it right after the structs. impl blocks usually sit close to the types they belong to.
Inside, for_stage() looks like every function we have written so far.
It takes a stage.
It returns an Enemy.
The only difference is where it lives, and that changes how we call it:
Enemy::for_stage(1)
The :: reaches into Enemy and finds the function that belongs to it.
We have actually been using this syntax since we first gave our slime a name:
String::from("Training Slime")
from() is a function that belongs to String.
for_stage() is a function that belongs to Enemy.
A function attached to a type like this is called an associated function.
Notice that for_stage() did not replace our small rule.
It calls it:
health: enemy_health_for_stage(stage),
enemy_health_for_stage() still decides how tough an enemy is.
Enemy::for_stage() decides how to put an enemy together.
Two small jobs, two small functions.
A new game
Game gets its own impl block, and its first associated function creates a fresh game:
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 the name new.
Rust has no special constructor keyword.
new is just a regular associated function, and naming it new is a convention Rust programmers use for "make me a fresh one of these."
Starting a game is now one line:
let mut game = Game::new();
Until main() actually uses Game, Rust will warn that parts of it are never used.
Those warnings disappear once we rewrite main().
Game setup has a name.
It also has one home, and it is not main().
One click
Now the part we have been building toward.
What happens when a player clicks?
Add this inside the same impl Game block, right under new():
fn click(&mut self) {
attack(&self.player, &mut self.enemy);
}
There is a new word in that signature:
&mut self
self means:
the game 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.
Enemy::for_stage() and Game::new() do not take self. They make new values, so there is no existing value to work on yet.
click() does take self. It needs a game that already exists.
Now look at the &mut in front of self.
We already know what that means.
It is the same &mut we used to let attack() change an enemy.
click() borrows the game mutably for as long as the call lasts.
It can change the game.
It does not own the game.
When click() finishes, the borrow ends, and main() still owns the game.
A method that only needs to read the game would take &self instead, the same way attack() takes &Player.
We do not need one of those yet.
At the call site, we do not have to write &mut game ourselves.
The dot in game.click() takes care of the borrow.
The rule from last time still applies, though.
Rust will not mutably borrow something whose owner never agreed it could change.
Leave the mut off:
let game = Game::new();
game.click();
and Rust refuses:
error[E0596]: cannot borrow `game`
as mutable, as it is not declared
as mutable
click() changes the game, so the game has to be mutable:
let mut game = Game::new();
Two borrows, one game
Look inside click() again:
attack(&self.player, &mut self.enemy);
That line looks like it breaks the rule we learned last time.
It borrows part of the game to read it.
At the same time, it borrows another part of the game to change it.
Last time, our borrowing rule was:
For the same data at the same time: many readers, or one writer, but not both.
So why is Rust fine with this?
The key words are the same data.
self.player and self.enemy are different fields.
They are both inside the game, but they do not overlap.
Rust can see that reading the player cannot interfere with changing the enemy, so it lets attack() have both.
Our attack() from last time did not need to change at all.
The borrowing did not go away.
It moved inside the game, where it belongs.
What happens when the enemy dies?
Right now, click() only attacks.
When the enemy's health hits zero, the next click attacks a corpse.
That is grim, and it is not much of a game.
After every attack, click() should check whether the enemy is defeated:
fn click(&mut self) {
attack(&self.player, &mut self.enemy);
if is_defeated(self.enemy.health) {
self.defeat_enemy();
}
}
Rust will not compile that yet.
defeat_enemy() does not exist.
Add it inside impl Game too, right under click():
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 by the enemy for the new stage.
The game owns its enemy, so when the old one is replaced, Rust cleans it up for us.
Notice how click() calls defeat_enemy():
self.defeat_enemy();
A method can call another method on the same game through self.
We could have put those two lines straight into click().
It would work.
But defeating an enemy is its own event, and that event is about to grow.
Soon, it will do more than swap in a new enemy.
Giving it a name now means click() stays readable no matter how much happens after a win.
Read click() out loud:
Attack the enemy. If it went down, handle the defeat.
Read defeat_enemy():
Go to the next stage. Bring in the enemy for that stage.
No arithmetic to decode.
Each one coordinates smaller rules that already know how to do their own jobs.
main() gets boring
Here is our 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 print something.
Before each click, it remembers the stage.
After the click, if the stage went up, it knows the enemy was defeated.
That is not a game rule.
That is main() deciding what to show.
It does not build enemies.
It does not decide when a stage is cleared.
It does not know how tough the next enemy is.
It creates a game, clicks, and prints.
Boring is exactly what we want here.
Run it:
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 five tests each check one rule.
Game lets us test something bigger: what actually happens when a player plays.
A new game should start at stage 1 with a stage 1 enemy:
#[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 the important one: defeating an enemy should move the game forward.
We do not want to click several times just to set up the test, so we put the enemy one hit away from defeat:
#[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);
}
This test does not care how damage is calculated.
It does not care how health is subtracted.
Those rules already have their own tests.
This test asks a bigger question:
When a click defeats an enemy, does the game move on?
Run them:
cargo test
All eight should pass.
The complete program
If you are only reading along, you can skip to the last two sections.
If you have been following along in your editor, here is everything in one place.
At this point, src/main.rs should look like this:
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 naming convention, not a keyword- methods
selfand&mut self- borrowing two different fields at the same time
saturating_mul()- tests that check what a player experiences
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 three functions we started this series with.
They have not changed.
They are just 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.





