Understanding board games with Monte Carlo simulations
We look at several simple situations occurring in board games and analyse them with Monte Carlo simulations.
Author
Paweł Czyż
Published
July 9, 2023
I like playing board games, but I never remember the probabilities of different interesting events. Let’s code a very simple Monte Carlo simulation to evaluate probabilities used in them, so I can revisit to this website and use it to (maybe eventually) win.
Fight or flight?
In the rare days when I find time to play Runebound, I find myself in situations fighting monsters and trying to decide whether I should try to fight them or escape. I know a monster’s strength (high), I know my strength (low), but I don’t know how likely it is that the difference can be compensated by throwing two ten-sided dice.
Let’s estimate the chances of getting at least\(X\) points due to the dice throw.
Code
import numpy as npn_simulations: int=100_000dice: int=10rng = np.random.default_rng(42)occurrences = np.zeros(2* dice +1, dtype=float)throws = rng.integers(1, dice, endpoint=True, size=(n_simulations, 2))total = throws.sum(axis=1)for t in total: occurrences[:t+1] +=1occurrences /= n_simulationsfor i, p inenumerate(occurrences):if i <1:continueprint(f"{i}: {100*p:.1f}%")
The exact solution requires \(O(K^2)\) operations, where one uses two dice with \(K\) sides1. For a larger number of dice this solution may not be as tractable, so Monte Carlo approximations may shine.
Where should my cheese be?
In Cashflow one way to win the end-game is to quickly get to the tile with a cheese-shaped token. As this token can be placed in advance, I was wondering what the optimal location of it should be.
If I put the token on the first tile, I need to throw exactly one in my first throw or I will need to travel across the whole board to close the loop and have another chance (or try to win the game in another way).
Let’s use Monte Carlo simulation to estimate where I should put the token so I can win in at most five moves:
Code
import numpy as np N_SIMULATIONS: int=100_000N_THROWS: int=5DICE: int=6# Number of sides on the dicerng = np.random.default_rng(101)visitations = np.zeros(N_THROWS * DICE +1)for simulation inrange(N_SIMULATIONS): position =0for throw_index inrange(N_THROWS): result = rng.integers(1, DICE, endpoint=True) position += result visitations[position] +=1for i inrange(N_THROWS * DICE +1): percentage =100* visitations[i] / N_SIMULATIONSprint(f"{i}: {percentage:.1f}")
Again, we could do this in the exact fashion — for example, for 30 we know that the probability is exactly \(6^{-5}\approx 0.013\%\), but it’s quite clear that the sixth tile gives decent chances of winning in the first few moves.
Footnotes
The implemented solution works in \(O(K^3)\) due to the probabilities[:total + 1] operation. If the performance did really matter here, we could store the occurrences and then calculate cumulative sums only once in the end.↩︎