DEV Community

Cover image for What does "balanced" mean for a board game?
Sharang Parnerkar
Sharang Parnerkar

Posted on

What does "balanced" mean for a board game?

Let's start with a dice game called Pig. Two players race to 100 points. On your turn you roll a die as often as you like and add up the faces. If you roll a 1, you lose everything you rolled this turn and the turn passes. If you stop first (you "hold"), the points are banked and safe. The first player to bank 100 wins.

Here's a question a game designer would ask about it: does going first help? And if it does, by how much is too much?

People call a game "balanced" or "unbalanced", but that word doesn't tell you what to measure. Here we'll turn it into something we can measure, measure it with a short Rust program, and then find out how much the answer depends on who is playing. That last part is the surprise, and it shapes everything else in this series.

I'm building a harness called gameforge that does this for my own board games (L.U.C.A., Samudra Manthana and Kölnisch Wasser) at a larger scale. Pig is small enough that we can do the whole thing by hand first.

Turning "balanced" into a target

"Is going first fair?" has no yes-or-no answer until we say what fair means. Let's write it down as a number we're aiming for, plus how far off we'll accept:

The first player wins 50% of games, give or take 2 points.

That's a target (50%) and a tolerance (2 points). Why a tolerance at all? Because no game is exactly even, and a designer has to decide how much edge is too little to matter. The 2 points is my choice for this example; a tournament game might want 1, a party game might accept 5. gameforge calls such a written-down target a declared intent, and every balance claim it makes is about intents like this one.

Now we need a win rate to compare against it. Where does that come from?

Who plays the 10,000 games?

A win rate means many games. People can't play ten thousand games of Pig, so we'll let the computer play both sides, following a fixed rule. I'll call such a rule a simulated player.

A sensible rule for Pig is "hold at 20": keep rolling until this turn's points reach 20, then hold (and hold early if that wins the game). Why 20? Say you have t points this turn and roll once more. Five times in six you gain a face from 2 to 6, on average 4; once in six you lose the t. So one more roll is worth (5 * 4 - t) / 6 = (20 - t) / 6 points on average, positive below 20 and negative above. "Hold at 20" stops exactly when another roll stops paying, on average.

That's a reasonable player. Keep the word "reasonable" in mind, because we'll come back to it.

The rules, as a program

The program has two parts. The first is the game: a small random number generator, and play, which runs one game where both players hold at the same limit and returns who won. Look for two things: the while loop is one turn, and a roll of 1 empties turn before it's banked.

/// A small random number generator that repeats exactly from its seed (SplitMix64).
struct Rng(u64);

impl Rng {
    fn die(&mut self) -> u64 {
        self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
        let mut z = self.0;
        z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
        z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
        1 + (z ^ (z >> 31)) % 6
    }
}

/// One game where both players "hold at `limit`": keep rolling until this
/// turn's points reach `limit`, or holding would win. Returns the winner (0 or 1).
fn play(limit: u64, rng: &mut Rng) -> usize {
    let mut scores = [0, 0];
    let mut current = 0;
    loop {
        let mut turn = 0;
        while turn < limit && scores[current] + turn < 100 {
            match rng.die() {
                1 => { turn = 0; break; }
                face => turn += face,
            }
        }
        scores[current] += turn;
        if scores[current] >= 100 {
            return current;
        }
        current = 1 - current;
    }
}
Enter fullscreen mode Exit fullscreen mode

Player 0 always moves first, so "player 0 won" is our event. The generator takes a seed, and the same seed gives the same dice every time. That will matter in a moment.

The second part plays many games and prints how often the first player won:

/// Plays `games` games from `seed` and prints the first player's win rate.
fn measure(limit: u64, games: u64, seed: u64) {
    let mut rng = Rng(seed);
    let wins = (0..games).filter(|_| play(limit, &mut rng) == 0).count();
    let p = wins as f64 / games as f64;
    let margin = 1.96 * (p * (1.0 - p) / games as f64).sqrt();
    println!("hold at {limit}, seed {seed}: first player wins {:.1}% ({:.1} to {:.1})",
        100.0 * p, 100.0 * (p - margin), 100.0 * (p + margin));
}

fn main() {
    measure(20, 10_000, 1);
    measure(20, 10_000, 2);
    measure(60, 10_000, 1);
}
Enter fullscreen mode Exit fullscreen mode

Those two blocks together are the whole src/main.rs, with no dependencies. The margin line computes a range around the measured rate, which I'll explain with the output in front of us.

What does the first player get?

I ran it in a new project (cargo new pig, edition 2024) with cargo run --release on Rust 1.97, x86-64 Linux. The first line it prints:

hold at 20, seed 1: first player wins 53.2% (52.2 to 54.2)
Enter fullscreen mode Exit fullscreen mode

So with two "hold at 20" players, the first player wins 53.2% of 10,000 games. What are the two numbers in brackets? Any 10,000 games are a sample; another 10,000 with different dice would give a slightly different rate. The bracket is a 95% interval: roughly, the range in which the true rate for this game and these players is likely to lie, given how many games we played. (Post 4 of this series is about exactly that, and about how many games are enough.)

We can check the "different dice" part directly. The second line uses seed 2, a fresh set of 10,000 games:

hold at 20, seed 2: first player wins 53.4% (52.4 to 54.4)
Enter fullscreen mode Exit fullscreen mode

Close to the first, inside its interval, and nowhere near 50. Now compare with our intent, 50% give or take 2. Even the low end of the interval, 52.2%, is above 52. For these players, the first player's edge is too big: the intent is not met.

Is Pig unbalanced, then? Before you answer, look at the third line.

Same game, different players

The third line changes only the players. Both now hold at 60, pushing their luck three times as far:

hold at 60, seed 1: first player wins 50.8% (49.8 to 51.8)
Enter fullscreen mode Exit fullscreen mode

The rules and the number of games are unchanged, and now the intent is met: the whole interval sits inside 48 to 52. Nothing about Pig changed. Only the players did.

That's not a quirk of two hand-picked numbers. The figure runs measure for holding limits from 5 to 60, 10,000 games each:

Animation that ends on the full figure. For hold at 20, seed 1, the first player's running win rate is measured game by game, and its 95% interval narrows to 52.2 to 54.2, settling at 53.2%, above the band from 48 to 52 that the intent allows: the intent is not met. Then hold at 60 settles at 50.8% (49.8 to 51.8), inside the band: the intent is met. Then the other limits appear: from 5 to 60, 10,000 games each, about 54% at limits 5 and 10 and down to 50.8% at 60; from limit 30 on, every point but one (52.2% at 40) lies inside the band.

The points wobble by about a point from one limit to the next, which is the noise the intervals describe, but the trend is clear: the more the players push their luck, the smaller the first player's edge. So "is Pig balanced?" has no answer until we also say who is playing. A balance verdict is about a game and its players together.

Remember "reasonable"? "Hold at 20" is a good rule, and real people don't play exactly like it either. A simulation can't tell us how humans will play, or whether a 53% edge feels unfair at the table. Those questions belong to playtesting.

What a balance claim can promise

Here's what we can honestly say, and it's the shape every result in this series will take:

Balanced against declared intents, for simulated players of stated strength, with a reproducible log.

Each part maps onto something we did above:

  • Declared intents. "First player wins 50%, give or take 2 points." Without it, "balanced" has nothing to be measured against.
  • Simulated players of stated strength. Both seats hold at 20. Change that and the verdict changed with it.
  • A reproducible log. Seed 1, 10,000 games, and the program above. Anyone who runs it gets 53.2% again, to the decimal, because the seed fixes every die.

What it never says is "Pig is balanced", full stop. gameforge's own run of Pig, with its own random numbers and 9,604 games, gives the first player 53.1% (52.1 to 54.1) for two "hold at 20" players: the same verdict, from a much bigger program.

That bigger program has to handle games far longer than Pig, with many more rules. To make every game replayable from its seed the way ours was, the rules need a particular shape. In the next post, we'll write Pig again in that shape: a game as four functions.

The program in this post is complete as shown: paste both blocks into src/main.rs of a new Cargo project.

Top comments (0)