Rust's borrow checker: Not just a nuisance

Over the past couple of months, I've been developing a video game in Rust. A lot of interesting and mostly positive things could be said about this programming journey. In this post, I want to briefly highlight one particular series of events.

To provide some context, the game I'm developing is a 2D side-view shooter, similar to Liero and Soldat. The first weapon I implemented was a laser. Due to its lack of a ballistic projectile and its line-based hit test, it was a low-hanging fruit.

During an initial quick-and-dirty implementation of the laser, I had a run-in with the borrow checker. We iterate over all the players to check if a player fires his laser. Within this block, we iterate over all the other players and perform a hit test. The player who is hit will have his health points reduced by 5. If this is a lethal blow, he will die and respawn. It's a very simple logic, but there is one problem. In the outer loop, the player collection is already borrowed, so it cannot be mutably borrowed in the inner loop:

#[derive(Clone, Copy)]
struct Player {
    firing: bool,
    health: u8,
}

fn main() {
    let mut players = [Player { firing: true, health: 100, }; 8];

    for (shooter_idx, shooter) in players.iter().enumerate() {
        if shooter.firing {
            // Fire laser
            for (other_idx, other) in players.iter_mut().enumerate() { // <-- Cannot borrow mutably here
                if shooter_idx == other_idx {
                    // Cannot hit ourselves
                    continue;
                }
                // For simplicity, we omit actual hit test caluclations
                let hits_target = true; // Suppose we hit this player
                if hits_target {
                    let damage = 5;
                    if other.health <= damage {
                        // Handle death, respawn, etc.
                    } else {
                        other.health -= 5;
                    }
                    break;
                }
            }
        }
    }
}
Try it on Rust Playground

This problem cannot be solved by simply massaging the code or uttering the right Rust incantations. Well, technically, it can - but doing so would result in undefined behavior and is strongly discouraged:

#[derive(Clone, Copy)]
struct Player {
    firing: bool,
    health: u8,
}

fn main() {
    let mut players = [Player { firing: true, health: 100, }; 8];

    for (shooter_idx, shooter) in players.iter().enumerate() {
        if shooter.firing {
            // Fire laser
            for (other_idx, other) in players.iter().enumerate() {
                if shooter_idx == other_idx {
                    // Cannot hit ourselves
                    continue;
                }
                // For simplicity, we omit actual hit test caluclations
                let hits_target = true; // Suppose we hit this player
                if hits_target {
                    let damage = 5;
                    unsafe {
                        #[allow(invalid_reference_casting)]
                        let other = &mut *(other as *const Player as *mut Player);
                        if other.health <= damage {
                            // Handle death, respawn, etc.
                        } else {
                            other.health -= 5;
                        }
                    }
                    break;
                }
            }
        }
    }
}
Try it on Rust Playground

To emphasize again, this is broken code that should never, ever be used. However, because I needed quick results and had other parts to finish first, I went with it for a day or two. While it seemed to work in practice, I begrudgingly refactored the code as soon as I could:

#[derive(Clone, Copy)]
struct Player {
    firing: bool,
    health: u8,
}

struct Laser {
    shooter_idx: usize,
    // Also store position here, used for hit test
}

fn main() {
    let mut players = [Player { firing: true, health: 100, }; 8];
    let mut lasers = vec![];

    for (shooter_idx, shooter) in players.iter().enumerate() {
        if shooter.firing {
            // Fire laser
            lasers.push(Laser { shooter_idx });
        }
    }

    for laser in lasers.iter() {
        for (other_idx, other) in players.iter_mut().enumerate() {
            if laser.shooter_idx == other_idx {
                // Cannot hit ourselves
                continue;
            }
            // For simplicity, we omit actual hit test caluclations
            let hits_target = true; // Suppose we hit this player
            if hits_target {
                let damage = 5;
                if other.health <= damage {
                    // Handle death, respawn, etc.
                } else {
                    other.health -= 5;
                }
                break;
            }
        }
    }
}
Try it on Rust Playground

At this point, the entire process may seem like a rigmarole to satisfy Rust's overly restrictive memory model. We removed the nested player loop at the cost of introducing a new vector to store all the laser shots. This change also introduced additional memory allocations - a minor performance penalty. Otherwise, the logic didn't change... or did it?

It was only when a friend and I actually played the game that I realized what had happened. My friend and I go back almost 20 years with this kind of game, and we are very evenly matched. It just so happened that we killed each other at exactly the same time, frame-perfectly. The game handled it perfectly: we both died, scored a kill, and respawned simultaneously. Now, let's return to the earlier example with the unsafe block. What would happen if the code were structured like that, as it would have been if I were using a language without a borrow checker? The player that comes first in the vector kills the player that comes later in the vector. After that, the player who was hit is either dead or has respawned somewhere else, thus he cannot retaliate in the same frame. Consequently, the order of the players - something that should be completely irrelevant for gameplay purposes - becomes decisive in close calls.

In my opinion, something very interesting happened here. By forcing me to get object ownership in order and separate concerns, the borrow checker prevented a logic bug. I wasn't merely jumping through hoops; the code structure improved noticeably, and the logic changed ever so slightly to become more robust. It wasn't the first time that this happened to me, but it was the most illustrative case. This experience bolsters my view that the borrow checker isn't merely a pesky artifact of Rust's safety mechanisms but a codification of productive and sensible design principles.

For those who are interested in the game, while it is not yet released, you can find some videos of our playtests on YouTube: https://www.youtube.com/watch?v=H3k7xbzuTnA

Post comment

CAPTCHA
* required field

Comments

Matthias Goetzke wrote on 09 January 2025
There is no problem really using an index here though and for better design use interior mutability (UnsafeCell) which limits the spread of unsafe to a function on Player.

https://play.rust-lang.org/?version=stable&mode=debug&edition=2021&gist=9848e84616c9f9ce0a3f1071ae00d8ba

UnsafeCell is not copy or clone and eq needs to be implemented comparing addresses absent an id (but thats just and example anyway i guess, adding a player id would make sense i guess)

Assembly in this version looks not too bad at first glance either.

reply

_fl_o wrote on 07 January 2025
You can very easily get the original version compiling by using indicies instead of iterators. No need for unsafe code!
https://play.rust-lang.org/?version=stable&mode=debug&edition=2021&gist=4aa2834dbf941b386ccca814e76c0e94
reply

Colin Dean wrote on 06 January 2025
This is a great realization for a game designer. All games must have rules. All rules must be processed in an order; no rules are ever processed simultaneously. This new refactor helped you solidify the rules for the game in an explainable and deterministic way.

I've not actively played Magic: The Gathering for almost 25 years, but I still remember some of the teachings of that game and others similar to it at the time. There is an order of actions and resolution or precedence when two actions may occur perceptibly simultaneously. The arguments always occurred when players didn't know or didn't understand that order. Computers automate the execution but explaining that execution to players engaging in the meta is a necessary step in growing from a person who builds games to a game designer with a community of players.
reply

wt wrote on 06 January 2025
Yes, the borrow checker made you rethink about your code and change the logic, while... I don't think the bug is really relevant to borrow checker. Actually, if you search online, you would likely be suggested to use `split_at_mut`, which would have the same bug.
reply

nh wrote on 04 January 2025
This is a perfect sample of what is wrong with using Rust everywhere.

Every decent C++ programmer would make this loop without bugs/issues that Rust supposedly prevents.

With Rust, you needed to solve nontrivial code structure problems caused by Rust itself.

If you have no issue with creating a temporary array ('a minor performance penalty' you say), maybe C# should have been the language of your choice...

This is just silly...

reply

bux wrote on 10 January 2025
> Every decent C++ programmer would make this loop without bugs/issues that Rust supposedly prevents

It's precisely because we want to think that, that the software world is so buggy.
reply

Benjamin (admin) wrote on 05 January 2025
I believe you misunderstood the blog post. The point is precisely that if I were using a language like C++, I would have opted for the solution that uses nested loops, which would have resulted in unfair gameplay when players try to land fatal blows on each other in the exact same frame.

To address the 'minor performance penalty': the vector can be allocated once and then reused. Since the maximum number of players is low (8-12), a fixed-size array on the stack could be used, making the solution fully allocation-free. I didn't mention this because it's an irrelevant implementation detail and I wanted to keep the examples as simple as possible.
reply