Skip to main content

Command Palette

Search for a command to run...

Borrowing Without the Lecture.

Let a Player Attack an Enemy in Rust

Updated
•12 min read•View as Markdown
Borrowing Without the Lecture.
M
Web developer building full-stack applications with JavaScript, React, Node.js, and modern web APIs.

Our game has a Player and an Enemy

Then, the moment they fight, we strip them back down for parts:

enemy.health = attack(
    enemy.health,
    player.base_damage,
    player.bonus_damage,
);

We built two real game types.

The moment they interact, we go back to passing loose numbers around.

In this article, we are going to change attack() so it takes the player and the enemy themselves.

Along the way, Rust will refuse to compile our code three times.

Every time, it will be right.

By the end, our combat loop will say what the game actually does:

attack(&player, &mut enemy);

A player attacks an enemy.

Nothing gets torn apart.


Our starting point

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(
    health: u32,
    base_damage: u32,
    bonus_damage: u32,
) -> u32 {
    let damage =
        click_damage(base_damage, bonus_damage);

    health_after_hit(health, damage)
}

At the bottom of main.rs, the tests module holds three tests for the small rules and one test that calls attack() directly:

#[test]
fn attack_reduces_enemy_health() {
    assert_eq!(attack(10, 5, 2), 3);
}

We will come back to that test once attack() changes.

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) {
        enemy.health = attack(
            enemy.health,
            player.base_damage,
            player.bonus_damage,
        );

        println!(
            "{} health: {}",
            enemy.name,
            enemy.health
        );
    }

    println!("{} defeated!", enemy.name);
}

It works.

But attack() does not know what a player is.

It does not know what an enemy is.

It takes three numbers and hands back a fourth, and main() has to put that number back where it belongs.

What we want to write is closer to:

attack(player, enemy);

So let us try that.


Hand the player to attack()

Let us start with the player and leave the enemy alone for a moment.

Change attack() so it takes a whole Player instead of two loose damage values:

fn attack(
    player: Player,
    health: u32,
) -> u32 {
    let damage = click_damage(
        player.base_damage,
        player.bonus_damage,
    );

    health_after_hit(health, damage)
}

The body barely changed.

We read the damage values from the player with the same dot syntax we used in main().

Now update the call inside the loop:

enemy.health =
    attack(player, enemy.health);

That reads much better.

Run it:

cargo run

Rust refuses to build the program.

Trimmed down, the error says:

error[E0382]: use of moved value: `player`

value moved here, in previous
iteration of loop

The full message is longer, and it suggests a few fixes, like cloning the player.

Some of them would work.

None of them is what we want, so let us figure out what actually happened.

The first attack would have been fine.

The second attack is the problem.

Where did the player go?


The player moved

In Rust, every value has an owner.

Right now, main() owns player.

It created it, so it is responsible for it.

When we write:

attack(player, enemy.health)

and attack() asks for a Player, we are not lending the player to attack().

We are giving it away.

Ownership moves into attack().

When attack() finishes, the Player it owns is no longer available back in main().

So on the next trip around the loop, main() tries to hand over a player it no longer owns.

Rust catches that before the program ever runs.

In game terms, our player attacked once and then vanished.

That is a bold balancing decision.

It is not ours.

The attack was supposed to defeat the enemy.

Not the player.


Why did health never move?

We have been passing enemy.health into functions all along, and it never disappeared.

That is because u32 implements Rust's Copy trait.

When we pass a u32 by value, Rust can automatically make a copy, so the original remains available.

Our Player does not implement Copy, so passing it by value moves ownership instead.

You do not need to understand traits yet.

For now, the useful difference is:

u32     → copied
Player  → moved

A u32 gets photocopied.

A Player gets handed over.

We do not want to give the player away.

We want attack() to look at it.


Borrow the player with &

Rust lets a function borrow a value instead of owning it.

Add one character to the parameter type:

fn attack(
    player: &Player,
    health: u32,
) -> u32 {
    let damage = click_damage(
        player.base_damage,
        player.bonus_damage,
    );

    health_after_hit(health, damage)
}

&Player means:

a reference to a Player that someone else owns

A reference lets attack() reach the player without taking ownership of it.

When the call finishes, the player is still sitting in main() where we left it.

Notice that the body did not change.

The dot works the same way through a reference, so:

player.base_damage

still reads the field.

The call needs the same &:

enemy.health =
    attack(&player, enemy.health);

&player says:

let attack() borrow the player for this call

Run it:

cargo run

The Training Slime goes down in three hits again.

The player survives every one of them.

There is a bonus hiding in that &.

A plain & reference gives attack() read-only access to the player's fields.

If attack() tried to assign a new value to:

player.base_damage

Rust would refuse to compile it.

That is exactly the promise we want.

An attack needs to read the player.

It does not need to change the player.


Now the enemy

The player is solved.

The enemy is still split apart:

enemy.health =
    attack(&player, enemy.health);

attack() gets a copy of the enemy's health, calculates a new number, and returns it.

Then main() writes that number back into the enemy.

What we really want is for attack() to change the enemy itself.

We just learned that borrowing keeps a value where it is.

Let us borrow the enemy too:

fn attack(
    player: &Player,
    enemy: &Enemy,
) {
    let damage = click_damage(
        player.base_damage,
        player.bonus_damage,
    );

    enemy.health =
        health_after_hit(enemy.health, damage);
}

Two things changed besides the enemy parameter.

There is no:

-> u32

anymore.

attack() is not handing back a number for someone else to store.

The result lives in the enemy.

Inside the function, we assign the new health directly to:

enemy.health

Update the call in main():

attack(&player, &enemy);

Run it:

cargo run

Rust refuses again:

error[E0594]: cannot assign to
`enemy.health`, which is behind
a `&` reference

You may also see a warning that enemy does not need to be mutable.

Ignore it for a moment.

We are about to need that mut again.

This is the promise from the last section working against us.

&Enemy lets us look at the enemy.

We did not come here to look.

A plain & reference is read-only.

That was perfect for the player.

It is wrong for the enemy because the entire point of an attack is that the enemy's health changes.


Borrow the enemy with &mut

We have already met mut.

It is how we told Rust that our enemy is allowed to change.

References can express the same idea.

Change:

&Enemy

to:

&mut Enemy

Our attack becomes:

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

&mut Enemy means:

a mutable reference to an Enemy that someone else owns

In game terms:

attack() may temporarily change this enemy, but it does not own the enemy.

The call needs to ask for the same thing:

attack(&player, &mut enemy);

This only works because enemy was already declared with:

let mut enemy

Rust will not let us borrow something mutably if the owner never agreed that it could change.

Run it:

cargo run

You should see:

Training Slime health: 13
Training Slime health: 6
Training Slime health: 0
Training Slime defeated!

The same fight as before.

But now the signature tells the whole story:

&Player     → attack reads the player
&mut Enemy  → attack changes the enemy

Nobody gets consumed.

Nobody gets torn apart.

Both are still in main(), ready for the next attack.


One rule worth knowing now

There is one borrowing rule you will run into early, so here it is in game terms.

Suppose that, instead of the combat loop, main() grabs a reference to the enemy's name before attacking:

let name = &enemy.name;

attack(&player, &mut enemy);

println!("{name} took a hit!");

Rust refuses for the third time:

error[E0502]: cannot borrow `enemy`
as mutable because it is also
borrowed as immutable

A borrow remains active until its last use.

name is used by the println!() after the attack, so that borrow is still active when attack() asks for permission to change the enemy.

Rust will not hand out a mutable borrow while that reading borrow is still being used.

Our combat loop never hits this problem because each borrow is finished before the next conflicting access begins:

attack(&player, &mut enemy);

println!(
    "{} health: {}",
    enemy.name,
    enemy.health
);

In this case, the mutable borrow is finished when attack() returns.

Then println!() is free to read the enemy.

That is the beginner version of the rule:

For the same data at the same time: many readers, or one writer, but not both.

You do not need more than that today.


Update the test

Our attack() test still passes three loose numbers, so it needs the new signature:

#[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);
}

The rule it checks has not changed.

An attack with five base damage and two bonus damage takes ten health down to three.

What changed is how the test says it.

It builds a player.

It builds an enemy.

It lets one attack the other.

Then it checks the enemy afterward.

That reads like the game.

The other three tests do not touch attack(), so they stay exactly as they were.

Run them:

cargo test

All four should pass.

We do not need a separate test proving that attack() does not change the player.

With our current Player type, an &Player reference does not allow attack() to assign new values to the player's fields.

The compiler checks that for us every time we build.


The complete program

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

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

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

#[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);
    }
}

Run both:

cargo run
cargo test

The Training Slime should fall in three hits, and all four tests should pass.


What we learned

Along the way, we encountered:

  • ownership

  • moves

  • Copy values

  • &T references

  • &mut T references

  • functions that change a value instead of returning a new one

  • when borrows stop being used

  • why Rust prevents conflicting access

You do not need to memorize all of that yet.

The important change is what our attack says.

We started with:

enemy.health = attack(
    enemy.health,
    player.base_damage,
    player.bonus_damage,
);

We ended with:

attack(&player, &mut enemy);

The player is read.

The enemy is changed.

Both stay where they belong.

And the compiler now checks that attack() keeps its side of that deal.


There is a bigger shape missing

Look at what main() is holding now:

player
enemy
the combat loop

That is a fight.

It is not a game.

When the Training Slime dies, the program simply ends.

There is no next enemy.

There is no stage counter.

Nobody earns anything for winning.

And every one of those things would become another loose variable inside main():

player
enemy
stage
gold
experience

We have seen this problem before.

Three numbers were not a player.

A pile of variables in main() is not a game.

The pieces that make up our game need one place to live, and one place that decides what happens when a player clicks.

Next, we will put the game in one place.

Small function. Bigger game.

More from this blog