8th Jan 2025
A stochastic game of rock-paper-scissors.
There exists dice where A rolls higher than B more than 50% of the time, B rolls higher than C more than 50% of the time, and C rolls higher than A more than 50% of the time. These are known as Intransitive Dice.
For example, here are 3-sided dice with this property, which you can modify to see the win percentages update:
I found this really fascinating, and wanted to discover more cases. I was interested in whether I could use them to create a game, similar to how type advantages work in games like Pokemon (water beats fire etc.).
I approached this as an optimisation problem, trying to maximise the differences in win percentages. If my “water” dice only has a 51% win rate against your “fire” dice, that’s not very satisfying.
For constraints, I wanted all the dice to be fair, in that no dice had a net advantage over the others. The sum of all advantages and all disadvantages for a particular dice should be 0%.
First, I wanted to get a feel for what intransitive dice looked like. I started with a quick Python script that used recursion to select the next face.
To minimise ties (and to make the code easier to write), I opted to not use the same value twice1. This means that N dice with M sides would consist of the numbers from 1 to N*M.
Here’s the best solution for 3 dice with 4 sides:
Viewing a few solutions, I noticed something interesting. The sum of each dice were equal:
In fact, every solution I could find had this property. Assuming it held true for all cases (and maybe it doesn’t), this meant I could discard any permutations where the sum did not add up to this total. And importantly, I could discard them early in the search. I didn’t need to populate all N*M faces to invalidate a permutation.
Looking at more solutions. I found that simplicity was also a very important objective. Consider these two cases of 6 dice with 4 sides:
The first case has bigger differences, but the second case is easier to understand. There are only 3 chances: advantage (62.5% win rate), disadvantage (37.5% win rate) and neutral (50.0% win rate).
I now had a second objective, which I handled by storing the solutions as a Pareto front.
Note that the percentages in this graph is win percentage minus loss percentage.
I also had to spend a lot of time optimising my search algorithm, as processing was taking too long even for small numbers of dice and faces. I migrated from Python to Rust, introduced multithreading, cached comparisons, and used an O(M) comparison algorithm. This brought the processing time for 7 dice with 4 sides down from 5 minutes to 0.5 seconds. Source code is here.
My favourite case2, and the one I ultimately created custom dice with, is this one. It uses 6 sided dice, has 1 neutral dice and 4 intransitive dice:
I also like this solution for 3 dice with 8 sides:
And 3 dice with 5 sides:
If I wanted to bring duplicates back, this could be a post-processing step where incremental values are joined together. For example, a dice with [1, 2, 7] could become [1, 1, 7]. ↩
To increase the advantage, multiple rounds of rolling could be performed. The winner is whoever wins the majority of rounds. Applying 3 rounds to this case adjusts the win chance pleasingly close to 2/3.