Three Numbers Are Not a Player.
Give Your Game Data Shape With Rust Structs

Look at this line:
attack(enemy_health, 5, 2)
Three values. Two belong to the player. One belongs to the enemy.
Nothing in the code says which is which.
In this article, we are going to fix that by grouping those values into two new Rust types: a Player and an Enemy.
By the end, our code will start describing the things that actually exist in our game instead of asking us to remember what those numbers represent.
Our starting point
You only need four 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
}
fn attack(
health: u32,
base_damage: u32,
bonus_damage: u32,
) -> u32 {
let damage =
click_damage(base_damage, bonus_damage);
health_after_hit(health, damage)
}
Our combat loop currently looks like this:
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!");
}
This code works.
The problem is not what it does.
The problem is what it says.
Three numbers with three different meanings
Look at the attack again:
attack(enemy_health, 5, 2)
The first value represents the enemy.
The other two belong to the player.
But nothing in the function call makes those relationships clear.
Someone reading this code has to remember:
enemy_health → the enemy's current health
5 → the player's base damage
2 → the player's bonus damage
That is manageable today.
It will become harder when our player also has:
a level
experience
gold
upgrade levels
passive damage
The enemy will need more information too:
a name
maximum health
gold rewards
experience rewards
Passing every single value as an individual number just isn't going to scale.
We need a way to tell Rust which values belong together.
Give the player some shape
Rust lets us create our own types with struct.
Add this near the top of main.rs:
struct Player {
base_damage: u32,
bonus_damage: u32,
}
A struct is a type that groups related values together.
Our new type is called:
Player
It contains two named values:
base_damage
bonus_damage
The named values inside a struct are called fields.
Each field has a type. The colon syntax is the same as a function parameter—it connects the name to the type:
base_damage: u32
bonus_damage: u32
So every Player has a field named base_damage that contains a u32, and a field named bonus_damage that also contains a u32.
The struct definition describes the shape of a player.
It does not create one yet.
Create a player
Now that Rust knows what a Player looks like, we can create one:
let player = Player {
base_damage: 5,
bonus_damage: 2,
};
This creates a value with a type of: Player.
The values line up with the fields from our definition:
Player
├── base_damage: 5
└── bonus_damage: 2
We can access a field with a dot:
player.base_damage
That gives us the player's base damage.
We can do the same for bonus damage:
player.bonus_damage
Our attack call can now become:
attack(
enemy_health,
player.base_damage,
player.bonus_damage,
)
That does happen to be longer than:
attack(enemy_health, 5, 2)
But it is also clearer.
We no longer have to remember what 5 and 2 represent.
The code tells us.
Give the enemy some shape
Our enemy is still represented by one loose variable:
let mut enemy_health = 20;
Health is not the only thing an enemy needs.
Even giving it a name creates another related value.
Add another struct:
struct Enemy {
name: String,
health: u32,
}
Our new Enemy type contains two fields:
Enemy
├── name
└── health
We already know the type of health:
health: u32
The name introduces a new type:
name: String
Give the enemy a name
String is a Rust type for storing owned text.
That fits our enemy: it should keep its own name for as long as it exists.
We can create a String from text like this:
String::from("Training Slime")
For now, read that as:
Create a
Stringcontaining "Training Slime."
Our game only needs enough text to name an enemy.
Now we can create one:
let enemy = Enemy {
name: String::from("Training Slime"),
health: 20,
};
Rust now understands that the name and health belong to the same enemy.
The enemy still needs to change
There is one problem with our new enemy:
let enemy = Enemy {
name: String::from("Training Slime"),
health: 20,
};
Combat changes health.
But enemy is immutable by default.
If we try to assign a new value to:
enemy.health
Rust will refuse to compile the program.
We have already solved this problem once.
The enemy needs to be mutable:
let mut enemy = Enemy {
name: String::from("Training Slime"),
health: 20,
};
The mut applies to the enemy value, which allows us to change its fields:
enemy.health = 13;
We are not adding mutability everywhere.
The player does not need to change yet:
let player = Player {
The enemy does:
let mut enemy = Enemy {
Once again, the game tells us which values need to be mutable.
Put the player and enemy into combat
We can now update main():
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) {
enemy.health = attack(
enemy.health,
player.base_damage,
player.bonus_damage,
);
println!(
"{} health: {}",
enemy.name,
enemy.health
);
}
println!("{} defeated!", enemy.name);
}
There is one small change in our println!() calls.
The {} placeholders are filled from left to right by the values that follow the format string:
println!(
"{} health: {}",
enemy.name,
enemy.health
);
The first {} displays enemy.name.
The second displays enemy.health.
Run the game:
cargo run
You should see:
Training Slime health: 13
Training Slime health: 6
Training Slime health: 0
Training Slime defeated!
The combat still works.
But the code now contains an actual player and enemy.
Notice what the structs made visible
Before, our game looked like this:
let mut enemy_health = 20;
enemy_health =
attack(enemy_health, 5, 2);
Now it looks like this:
let player = Player {
base_damage: 5,
bonus_damage: 2,
};
let mut enemy = Enemy {
name: String::from("Training Slime"),
health: 20,
};
enemy.health = attack(
enemy.health,
player.base_damage,
player.bonus_damage,
);
The behavior is the same.
The meaning is not.
Before:
20
5
2
Now:
Player
├── base_damage
└── bonus_damage
Enemy
├── name
└── health
We gave related values names and grouped them into types.
Rust can now help us keep player data with the player and enemy data with the enemy.
The behavior did not change
Notice what we did not touch:
click_damage()
still calculates damage.
health_after_hit()
still reduces health.
is_defeated()
still checks for defeat.
attack()
still combines the smaller rules.
We changed how the application organizes its data.
We did not rewrite the behavior we already trust.
Run the tests:
cargo test
All four should still pass.
No new combat rule was added in this article, so we do not need to invent a new behavior test just to prove that a struct can hold the values we typed into it.
The compiler checks that our struct fields exist and contain the expected types.
Our existing tests continue checking the game rules.
The complete program
At this point, src/main.rs should look like this:
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(
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 player = Player {
base_damage: 5,
bonus_damage: 2,
};
let mut enemy = Enemy {
name: String::from("Training Slime"),
health: 20,
};
while !is_defeated(enemy.health) {
enemy.health = attack(
enemy.health,
player.base_damage,
player.bonus_damage,
);
println!(
"{} health: {}",
enemy.name,
enemy.health
);
}
println!("{} defeated!", enemy.name);
}
#[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
The game should defeat the Training Slime, and all four tests should pass.
What we learned
Along the way, we encountered:
structcustom Rust types
struct fields
creating struct values
field access with
.Stringmutable struct values
You do not need to memorize all of that yet.
The important change is what happened to our data.
We started with:
enemy_health
5
2
We turned those values into:
Player
Enemy
Our program now has a vocabulary that matches the game we are building.
There is still something awkward
We solved one problem.
But look at our attack again:
enemy.health = attack(
enemy.health,
player.base_damage,
player.bonus_damage,
);
We created a Player.
We created an Enemy.
Then, the moment they need to interact, we pull them apart into individual fields.
attack() does not accept a player.
It does not accept an enemy.
It still accepts three numbers and returns another number that we manually assign back to the enemy.
What we really want to express is:
player attacks enemy
That means our attack needs to:
read the player
change the enemy
leave both available for the next attack
Rust has a way to let a function temporarily use values without taking them away from the code that already owns them.
That is called borrowing.
Next, we will let our player attack an enemy without consuming either one.
Small function. Bigger game.





