One Attack Is Not a Fight.
Build a Combat Loop From Small Rust Functions

Our game can now calculate damage, reduce health, and decide whether an enemy has been defeated.
There is a problem, though.
Those rules do not actually attack anything yet.
In this article, we are going to combine our small Rust functions into a larger attack() function, let enemy health change during combat, and repeat attacks until the enemy is defeated.
By the end, we will have our first actual game mechanic: a combat loop.
Our starting point
You only need three small functions to follow along:
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
}
Each one has a single job.
click_damage() calculates how much damage the player deals.
health_after_hit() applies that damage to an enemy's health.
is_defeated() tells us whether the enemy has reached zero health.
Right now, however, something else has to coordinate all three.
That something is main().
main() knows too much
We could attack an enemy like this:
fn main() {
let enemy_health = 10;
let damage = click_damage(5, 2);
let remaining_health =
health_after_hit(enemy_health, damage);
println!("Enemy health: {remaining_health}");
if is_defeated(remaining_health) {
println!("Enemy defeated!");
}
}
Technically, this does work.
But look at everything that main() knows about.
It knows how damage is calculated.
It knows how damage affects health.
It knows how to determine whether the enemy has been defeated.
Those first two steps describe one larger idea:
an attack.
So let us give that idea a function.
Build an attack from smaller functions
Add this beneath the functions we already have:
fn attack(
health: u32,
base_damage: u32,
bonus_damage: u32,
) -> u32 {
let damage =
click_damage(base_damage, bonus_damage);
health_after_hit(health, damage)
}
Notice how we are not adding new Rust ideas here.
That is the point.
We are taking functions we already understand and using them to build something more complex.
The first line inside attack():
let damage =
click_damage(base_damage, bonus_damage);
uses our existing damage rule.
Then:
health_after_hit(health, damage)
uses the result to calculate the enemy's remaining health.
Because it is the final expression and has no semicolon, that remaining health becomes the value returned by attack().
Our functions now fit together like this:
click_damage() ───────┐
├──→ attack()
health_after_hit() ───┘
We did not replace our small functions.
We composed a larger behavior from them.
Instead of teaching attack() every rule from scratch, it delegates those jobs to functions that already know how to calculate damage and reduce health.
We already know what each piece does, so understanding attack() does not require us to understand an entirely new system.
Try the attack
Replace main() with:
fn main() {
let enemy_health = 10;
let remaining_health =
attack(enemy_health, 5, 2);
println!("Enemy health: {remaining_health}");
}
Run the game:
cargo run
You should see:
Enemy health: 3
Our player dealt seven damage to an enemy with ten health.
That means attack() works.
We can test it too.
Add this to the test module:
#[test]
fn attack_reduces_enemy_health() {
assert_eq!(attack(10, 5, 2), 3);
}
Then run:
cargo test
We now have a test for behavior that depends on several smaller rules working together.
One attack is not much of a fight
We can attack an enemy once.
An idle game probably needs a little more combat than that.
Suppose our enemy starts with twenty health:
let enemy_health = 20;
The first attack reduces it to thirteen.
The next should reduce it to six.
The next should reduce it to zero.
There is a problem, though.
This:
let enemy_health = 20;
creates a variable that Rust does not allow us to change.
Variables are immutable by default in Rust.
That means this is not allowed:
let enemy_health = 20;
enemy_health = attack(enemy_health, 5, 2);
If you try this, Rust will refuse to compile it and tell us that we cannot assign to an immutable variable.
That error is useful. We have to explicitly tell Rust which values we expect to change.
For most values, that is a useful default.
For health during combat, it is not.
Health is supposed to change.
Let health change with mut
Rust uses the mut keyword when we want a variable to be mutable.
Change:
let enemy_health = 20;
to:
let mut enemy_health = 20;
Now we can assign a new value to it:
enemy_health =
attack(enemy_health, 5, 2);
The word mut is short for mutable, meaning the value bound to this variable may change.
We are not making everything mutable just because we can.
We are using mutability because enemy health represents something that changes as part of the game.
That distinction will become increasingly useful as our state grows.
Attack again
We can now do this:
fn main() {
let mut enemy_health = 20;
enemy_health =
attack(enemy_health, 5, 2);
println!("Enemy health: {enemy_health}");
enemy_health =
attack(enemy_health, 5, 2);
println!("Enemy health: {enemy_health}");
enemy_health =
attack(enemy_health, 5, 2);
println!("Enemy health: {enemy_health}");
}
Run it:
cargo run
and we get:
Enemy health: 13
Enemy health: 6
Enemy health: 0
It works.
It is also repetitive.
We know what should happen:
Keep attacking while the enemy is still alive.
Rust has a way to express exactly that.
Repeat with while
A while loop repeats a block of code as long as a condition remains true.
For example:
while enemy_health > 0 {
// repeat something
}
We could use that.
But we already wrote a function that knows whether the enemy has been defeated:
is_defeated(enemy_health)
We should use the rule we already have.
What we really want to say is:
Keep attacking while the enemy is not defeated.
Rust uses ! to mean not.
So:
!is_defeated(enemy_health)
means:
the enemy has not been defeated
Put that inside a while loop:
while !is_defeated(enemy_health) {
enemy_health =
attack(enemy_health, 5, 2);
println!("Enemy health: {enemy_health}");
}
Now Rust keeps running the block until is_defeated() returns true.
Our first combat loop
Update main():
fn main() {
let mut enemy_health = 20;
while !is_defeated(enemy_health) {
enemy_health =
attack(enemy_health, 5, 2);
println!(
"Enemy health: {enemy_health}"
);
}
println!("Enemy defeated!");
}
Run:
cargo run
You should see:
Enemy health: 13
Enemy health: 6
Enemy health: 0
Enemy defeated!
We have a combat loop.
The player attacks.
Health changes.
The game checks whether combat should continue.
Then it attacks again.
Our little system now looks something like this:
click_damage() ───────┐
├──→ attack()
health_after_hit() ───┘
↓
repeat while
enemy has not been defeated
The individual pieces are still small.
The behavior is getting larger.
Notice what Rust made us say
We encountered three small pieces of Rust syntax in this article:
mut → health needed to change
while → combat needed to repeat
! → we needed to express "not defeated"
But we did not add them because they appeared next on a Rust checklist.
The game gave us reasons to need them.
Enemy health needed to change:
let mut enemy_health = 20;
Combat needed to repeat until the enemy is defeated:
while !is_defeated(enemy_health) {
That is how we are going to keep learning Rust throughout this project.
When the game creates a problem, we will learn the Rust needed to solve it.
Testing the larger rule
Our existing tests still protect the smaller rules:
#[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));
}
Now we also have:
#[test]
fn attack_reduces_enemy_health() {
assert_eq!(attack(10, 5, 2), 3);
}
This test is slightly different.
attack() is not doing one tiny calculation by itself.
It relies on other functions.
That means we are beginning to test not only individual rules, but also the larger behavior created when those rules work together.
The complete program
At this point, src/main.rs should look like this:
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(
health: u32,
base_damage: u32,
bonus_damage: u32,
) -> u32 {
let damage =
click_damage(base_damage, bonus_damage);
health_after_hit(health, damage)
}
fn main() {
let mut enemy_health = 20;
while !is_defeated(enemy_health) {
enemy_health =
attack(enemy_health, 5, 2);
println!(
"Enemy health: {enemy_health}"
);
}
println!("Enemy defeated!");
}
#[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() {
assert_eq!(attack(10, 5, 2), 3);
}
}
Run both:
cargo run
cargo test
Our game now fights until the enemy is defeated, and all four functions we have written are covered by tests.
What we learned
Along the way, we encountered:
- functions calling other functions
- local variables inside functions
mut- reassignment
while!- tests for composed behavior
You do not need to memorize all of that yet.
The important change is what happened to our game.
We started with three independent rules:
damage
health
defeat
We combined them into:
attack
Then repeated that attack until we had:
combat
Our program became more capable without any individual function becoming particularly complicated.
There is another problem hiding in our code
Look at this:
attack(enemy_health, 5, 2)
We know what those values mean because we just wrote them.
enemy_health belongs to the enemy.
5 is the player's base damage.
2 is the player's bonus damage.
Rust does not know any of that.
As far as Rust is concerned, they are just three u32 values being passed into a function.
And our game is about to need more information:
player level
player gold
enemy name
enemy maximum health
enemy rewards
Passing more and more unrelated-looking numbers around is not going to age well.
The next problem is no longer about making our functions stronger.
It is about giving our data some shape.
Next, we will turn the player and enemy into real Rust types of their own.
Small function. Bigger game.





