Stochastic Battles in MathHammer using Python
This article builds on the previous article; now that we can simulate (both analytically and stochastically) a single shooting phase, we’ll extend that to 10 GEQs vs. 10 GEQ battles to the death. As before, Python code is included mostly for reference.
An Analytic Approach, Again
We saw last time how most Mathhammer tools work out the expect number of models killed in each a single phase of shooting. Let’s extend that to a simple battle: we’ll have two sides, Red and Blue, with 10 GEQs on each side. The two sides will shoot at each other without moving (Red always going first, and outside rapid fire range) for as many battle rounds as it takes to kill all the models on one side.
As we’re taking an analytical approach it’s OK for a side to have a fractional number of models (as we saw last time, in the first battle round when the Red GEQs shoot first they’ll kill an expected $1\frac{2}{3}$ Blue GEQs). We’ll just assume $\frac{1}{3}$ of a GEQ will do $\frac{1}{3}$ of the damage a whole GEQ model will do.
The Python code below calculates the expected results, reusing the shooting_a function to run the shooting phase rules:
r, b = 10, 10
table = [(r, b)]
while r > 1e-5 and b > 1e-5:
b = max(b - shooting_a(4, 3, 0, 1, 3, 5, 1, n_shots=r).loc['# Models Killed', 'Expected'], 0)
r = max(r - shooting_a(4, 3, 0, 1, 3, 5, 1, n_shots=b).loc['# Models Killed', 'Expected'], 0)
table.append((r, b))
table = pd.DataFrame(
table,
columns=['# Red', '# Blue'],
index=pd.Index(['Initial', *range(1, len(table))], name='Battle Round'))
The table it produces (below) tracks the number of models alive on each side at the end of each battle round. It takes 10 battle rounds for the Reds to eliminate all the Blues, and the Reds are expected to have just over 4 GEQs left.
| # Red | # Blue | |
|---|---|---|
| Initial | 10.000 | 10.000 |
| 1 | 8.611 | 8.333 |
| 2 | 7.461 | 6.898 |
| 3 | 6.519 | 5.655 |
| 4 | 5.758 | 4.568 |
| 5 | 5.156 | 3.608 |
| 6 | 4.698 | 2.749 |
| 7 | 4.370 | 1.966 |
| 8 | 4.164 | 1.238 |
| 9 | 4.073 | 0.544 |
| 10 | 4.073 | 0.000 |
Plotting this table shows the evolution of the battle; each arrow represents the (expected) results of a Red or Blue shooting round:
The results might not be a suprise if you’re familiar with Lanchester’s laws: Red wins by a large margin, even though both sides have exactly the same number of points. This is a critical insight: if you go first, you are expected to win. Clearly you don’t always win (!); reasons this model doesn’t match up with reality include:
- The dice rolls don’t produce the numbers you’d statistically expect.
- You don’t actually have fractional numbers (expected values) of models on your side - when you play you have a concrete number of models that either inflict wounds or don’t.
- You can’t bring all your force’s strength to bear on the opposing army, for example:
- You have troops that excel in close combat but they’re out of charge range of any enemy.
- You can’t see the enemy troops as they’re behind cover, or in reserve.
- The points value of the armies don’t correspond to their effective strength, so one side is actually stronger that the other.
We’ll need a different modelling approach to overcome the first two of these limitations, which brings us to:
A Stochastic Approach, Again
Instead, we can use a stochastic approach to simulate a million battles and see how often each side wins.
For each simulated battle we’ll play it exactly as you would on the tabletop. We’ll keep track of the number of GEQs left on each side (the r and b variables in the Python code below). We’ll then use the shooting_s function from the previous article to roll some dice each shooting phase and see how many models are killed. We’ll update the number of models remaining on each side and declare a winner when only one side has models remaining.
n_iterations = 1_000_000
r = pd.Series(10, index=range(n_iterations))
b = pd.Series(10, index=range(n_iterations))
while ((r != 0) & (b != 0)).any():
r_shots = shooting_s(4, 3, 0, 1, 3, 5, 1, n_shots=r, n_iterations=len(r))
b = b.subtract(r_shots.groupby('run').models.sum(), fill_value=0).clip(lower=0).astype(int)
b_shots = shooting_s(4, 3, 0, 1, 3, 5, 1, n_shots=b, n_iterations=len(b))
r = r.subtract(b_shots.groupby('run').models.sum(), fill_value=0).clip(lower=0).astype(int)
We have to aggregate the results of these battles to calcuate the implied probabilities of winning. When I ran the code Red won (that is, had models left alive when all Blue’s were dead) 637,169 of the battles to Blue’s 362,831 - which gives Red an approximately 63.7% chance of winning a nominally even 10 vs. 10 GEQ (40 point) battle.
table = pd.concat([
r[r > 0].value_counts().rename('Red Wins'),
b[b > 0].value_counts().rename('Blue Wins'),
], axis=1)
table = table.fillna(0).astype(int)
table /= table.values.sum()
The table below summaries the million battles I ran to illustrate the range of outcomes. Each row corresponds to a different number of models left alive on the winning team, so for example the first row in the table shows that in 1.56% of the one million battles Red won with but had exactly one GEQ left, and in another 1.58% of the million battles Blue won with exactly one GEQ remaining. The total column of the first row sums these two percentages, showing there is only a 3.14% chance of a 10 vs. 10 GEQ battle ending with exactly one GEQ left standing on the battlefield.
| # Models | P(Red Wins) | P(Blue Wins) | Total |
|---|---|---|---|
| 1 | 1.56% | 1.58% | 3.14% |
| 2 | 3.21% | 3.09% | 6.29% |
| 3 | 5.02% | 4.46% | 9.48% |
| 4 | 6.80% | 5.44% | 12.25% |
| 5 | 8.65% | 6.02% | 14.68% |
| 6 | 10.10% | 5.79% | 15.89% |
| 7 | 10.50% | 4.84% | 15.34% |
| 8 | 9.33% | 3.19% | 12.52% |
| 9 | 6.23% | 1.49% | 7.72% |
| 10 | 2.32% | 0.37% | 2.69% |
| Total | 63.72% | 36.28% | 100.00% |
The probabilities are in line with the analytical results above; although the sides are evenly matched points-wise the first player advantage is hugely significant, and very few battles end in a close contest.
The chart below is a way to visualise this table, which makes Red’s advantage clear - but also shows that even if Blue do win they’re likely to end up with fewer models on their side than a victorious Red side (5.08 on average vs 5.99 models left if Red win).
I hope I have convinced you a roll-off for first turn is one of the most important dice rolls of the game!
Caveats
Both approaches described here have made a set of assumptions that may not hold in practice:
- Both sides shoot at each other without moving out of range (or into Rapid Fire range).
- Neither side charges enters close combat.
- All models have unobstructed line-of-sight of to each other.
- No leadership checks fail.
- The battle lasts an infinite number of Battle Rounds (so if your game stops after five Battle Rounds then you may be losing, even if you’d eventually win if you kept playing).
Later articles will explore how to relax these assumptions.
Connecting the Stochastic and Analytic Approaches
A 10 vs. 10 GEQ battle gives Red a 63.7%, but if you consider a 1-on-1 battle then intuitively the odds are much closer to 50-50; the first model that inflicts a wound wins. These two datapoints imply the first-player advantage isn’t a fixed number - but instead depends on the size of the battle.
The chart below shows the win probability for various force strengths. The red dot highlights the 10 vs. 10 GEQ battle we covered in detail above, and the 1-on-1 case is the black dot (where going first gives only a 4.5% advantage). The right-most point on the $x$-axis is 500 GEQs, or 2,000 points, on each side:
The relationship looks logarithmic, and shows that as the army sizes grow the stochastic model converges to the analytical model. The key differences between the analytic and stochastic approaches described above is the granularity of the simulation; in the 10 vs. 10 GEQ example each army is divided into ten individual models that can either shoot or not shoot, & be alive or be dead. By comparison the analytical model doesn’t have a concept of individual models, instead allowing a (expected) fraction of a GEQ to shoot an die exactly like a (expected) whole GEQ would.
But this is just the same as saying the analytical model is a stochastic model with an infinite number of infinitessimally small GEQs on each side, each with $\frac{1}{\infty}$ wounds and Lasguns that inflict $\frac{1}{\infty}$ damage. The chart above just shows how the probability of winning changes as you go from each model being $\frac{1}{1}$ of your army (the 1-vs-1 black dot) to $\frac{1}{10}$ of your army (the 10-vs-10 red dot) to $\frac{1}{\infty}$ of your army (the analytical model, where Red always wins).
This is the second conclusion of this article: as the size of the battle grows, the uncertainty from dice rolls has less of an impact and so the systematic advantages (like going first) magnify.
Further Reading
- Lanchester combat models by N. J. MacKay.
- A stochastic salvo model for naval surface combat by MJ Armstrong
- The salvo combat model with a sequential exchange of fire by MJ Armstrong