article banner

Simulating dice casting, to calculate Risc game fairness

As a kid, I really enjoyed a strategy game called Risc. It was a blutal game, that often led to some conflicts, but its strategy seemed quite clear.However, there was one problem, that kept me thinking. When one army attackes another in Rics, the number of lost soldiers is resolved by dices. The attacking player throws three dices, and the defender throws two. Then they compare the the dices with biggest results, and the dices with the second biggest results. For each of those pairs, if attacker have a higher number, defender looses one soldier, otherwise attacker looses one soldier.

Take a look at an example: Attacker casted 4, 2 and 5, defender casted 5 and 3. We the biggest numbers for each of them is 5, so attacker loosed one soldier (this is defender's adventage). Then we compare the second biggest, that is 4 for attacker, and 3 for defender, so defender looses one soldier.

I could not stop thinking about this algorithm. Is this fair? Surely not, but how unfair it is?

From our observations, is seemed, that the attacker had a minor adventage. My reflection was, that this algorithm should also lead to common soldier loosing on both sides. This were my thoughs, but back then I haven't got tools to verify that. Now, with a bit of programming, I can finally answer all my questions.

We could use a Monte Carlo algorithm, and simulate a huge number of games, but we do not need to. We can simulate all possible states, and have precise results. We only need to consider 6 possible results, for 5 different dices, what gives us 46 656 possible states. That is a piece of cake for modern computers. I will use a for-loop, and name my next dices after next alphabet characters.

fun main() { for (a in 1..6) { for (b in 1..6) { for (c in 1..6) { for (d in 1..6) { for (e in 1..6) { // One possible state } } } } } }

So the first question is: Is this algorithm fair? My idea to answer this question is calculating, how much soldiers will each player loose in all the games. For that, we need to simulare our fighting algorithm. Let's say, that attacker uses dices from a to c. We need to sort them, and assign to a properties, so that a1 is the highest attacker result, and a2 is the second highest. Similarly with defender, who will use dices d and e, and b1 and b2 will be his highest and second highest result.

val (a1, a2) = listOf(a, b, c).sortedDescending() val (b1, b2) = listOf(d, e).sortedDescending()

Now, to resolve who is loosing soldiers, we only need to compare those results.

fun main() { var attackerLoses = 0 var defenderLoses = 0 for (a in 1..6) { for (b in 1..6) { for (c in 1..6) { for (d in 1..6) { for (e in 1..6) { val (a1, a2) = listOf(a, b, c).sortedDescending() val (b1, b2) = listOf(d, e).sortedDescending() if (a1 > b1) { defenderLoses++ } else { attackerLoses++ } if (a2 > b2) { defenderLoses++ } else { attackerLoses++ } } } } } } println(attackerLoses) // 7161 println(defenderLoses) // 8391 println(attackerLoses.toFloat() / (6 * 6 * 6 * 6 * 6)) // 0.9209105 println(defenderLoses.toFloat() / (6 * 6 * 6 * 6 * 6)) // 1.0790895 }

Our simulation shows us, that indeed the algorithm is not fair. In every fight, attacker will loose on average 0.92 soldier, but defender will loose 1.08 slidier. That is a significant difference. Let's say, that hoth sides have 100 soldiers. Calculating that on a napkin expected result is that defender will be out of soldiers after 92 fights, but attacker should still have 11 soldiers. Lets simulate such a situation.

We need to make a simulation of multiple fights, until one side looses all the soldiers, so we will place our game simulation in a whil-loop. We now need to use random numbers. We also need to be prepared for a situation, when there is less then the maximal number of soldiers on one of the sides. We need to limit the number of dices to make it less then the number of soldiers. Also, when there is only one soldier on either side, we should have only one fight. All that, is presented in the below code, that simulates a single battle:

import kotlin.random.Random fun main() { val r = Random var attackerSoldiers = 100 var defenderSoldiers = 100 while (attackerSoldiers > 0 && defenderSoldiers > 0) { val attackerBest = listOf( r.nextInt(1, 6), r.nextInt(1, 6), r.nextInt(1, 6) ).take(attackerSoldiers) // There cannot be more dices than soldiers .sortedDescending() val defenderBest = listOf( r.nextInt(1, 6), r.nextInt(1, 6) ).take(defenderSoldiers) // There cannot be more dices than soldiers .sortedDescending() if (attackerBest[0] > defenderBest[0]) { defenderSoldiers-- } else { attackerSoldiers-- } val attackerSecondBest = attackerBest.getOrNull(1) val defenderSecondBest = defenderBest.getOrNull(1) if (attackerSecondBest != null && defenderSecondBest != null) { if (attackerSecondBest > defenderSecondBest) { defenderSoldiers-- } else { attackerSoldiers-- } } } println(attackerSoldiers) println(defenderSoldiers) }

To have a better view of what is the result of such a situation, let's repeat it million times, see how often each side wins, and how many soldiers are they left with.

import kotlin.random.Random fun main() { val gamesNumber = 1_000_000 var attackerLeftSoldiers = 0 var defenderLeftSoldiers = 0 var attackerWon = 0 var defenderWon = 0 for (game in 1..gamesNumber) { val r = Random var attackerSoldiers = 100 var defenderSoldiers = 100 while (attackerSoldiers > 0 && defenderSoldiers > 0) { val attackerBest = listOf( r.nextInt(1, 6), r.nextInt(1, 6), r.nextInt(1, 6) ).take(attackerSoldiers) // There cannot be more dices than soldiers .sortedDescending() val defenderBest = listOf( r.nextInt(1, 6), r.nextInt(1, 6) ).take(defenderSoldiers) // There cannot be more dices than soldiers .sortedDescending() if (attackerBest[0] > defenderBest[0]) { defenderSoldiers-- } else { attackerSoldiers-- } val attackerSecondBest = attackerBest.getOrNull(1) val defenderSecondBest = defenderBest.getOrNull(1) if (attackerSecondBest != null && defenderSecondBest != null) { if (attackerSecondBest > defenderSecondBest) { defenderSoldiers-- } else { attackerSoldiers-- } } } if (attackerSoldiers > 0) { attackerWon++ attackerLeftSoldiers += attackerSoldiers } if (defenderSoldiers > 0) { defenderWon++ defenderLeftSoldiers += defenderSoldiers } } println(attackerWon) // 640106 println(defenderWon) // 359894 println(attackerLeftSoldiers.toFloat() / attackerWon) // 15.627774 println(defenderLeftSoldiers.toFloat() / defenderWon) // 11.593953 }

As you can see, this algorithm is far from being fair. Both sides start with the same number of soldiers, and the attacker wins two times more often than the defender. I guess, it is this way, to make game more dynamic. When attacker wins, he is on average left with over 15 soldiers. If defender wins, he is on average left with over 11 soldiers (what is a higher number than I expected).

Now the second reflection of mine: I always had a feeling, that this algorithm is designed to make both sides loose one soldier. More concretely, attacker thanks to 3 dices, have adventage in the highest dice, but defender with winning on the same result, have adventage on the second highest result. Let's verify it.

Since in every battle two soldiers must be lost, in perfectly balanced algorithm where the fate of each soldier is considered separately, both sides should loose one soldier in 50% of cases (just like if you have two childs, there is 50% chance of having one girl and one boy, because there are two configurations of such a situation: a girl can be younger or older than the boy). Now, let's see what are the numbers for our algorithm.

fun main() { data class Result(val attackerLost: Int, val defenderLost: Int) var result = listOf<Result>() for (a in 1..6) { for (b in 1..6) { for (c in 1..6) { for (d in 1..6) { for (e in 1..6) { val (a1, a2) = listOf(a, b, c).sortedDescending() val (b1, b2) = listOf(d, e).sortedDescending() var attackerLost = 0 var defenderLost = 0 if (a1 <= b1) { attackerLost++ } else { defenderLost++ } if (a2 <= b2) { attackerLost++ } else { defenderLost++ } result += Result(attackerLost, defenderLost) } } } } } println(result.groupingBy { it }.eachCount()) // {Result(attackerLost=2, defenderLost=0)=2275, // Result(attackerLost=1, defenderLost=1)=2611, // Result(attackerLost=0, defenderLost=2)=2890} }

The actual result is different then expected. Both sides loose soldiers only in 33% of situations. That explains, why so often one of the sides is left with a significant army, when other is defeated. This also support game randomness, and makes it more dynamic.

So I know everything I need about this algorithm. I hope I inspired you, how you can make simulations, to verify random games fairness. If you want to see more such articles, let me know in comments, and I wish you a lot of fun with programming.