My favourite Python library for calculating dice probabilities

Permalink

Posted on . Reading time: 5 mins. Tags: python, games.

Dice Stats is my go-to library for calculating dice probabilities, and I this is how I've used it in a real example.

Sergi Pons Freixes

Disclaimer: I am not a mathematician, and it has been a long time since I studied statistics and probability, so I'm probably botching or misusing some terms.

Every time I get into some miniatures wargame like Marvel Crisis Protocol or Dropfleet Commander, one of the first things I do is checking if there exists some dice roll simulator for that game. I use that to get a better feel of how some rules translate to probabilities of success in the game.

If the game is very niche or very new, that utility doesn't exist yet, so sometimes I build my own thing in Python. If I get very excited about the game, I even make a web app to share with the community. An example of that is Arcanestats for Warcaster Neo-Mechanika.

I used to build these simulators using Python's fractions and crunching the numbers myself, until I discovered Dice Stats. This library is great! You might think "hey, this thing is unmaintained, it hasn't had a commit since 2020!" Well, no, it's just done. It does what it needs to do, and it's bug free — AFAIK. So why would they be pushing more commits? It even has a Read the Docs page!

The documentation is enough to get you started, but I am going to share some code from Arcanestats as a real example. Warcaster Neo-Mechanika uses custom 6-sided dice, where each side has a number of successes, called strikes. There are two types of dice, and the number of strikes is different for each. An Action die has 3 sides with 0 strikes, 2 sides with 1 strike, and 1 side with 2 strikes. A Power die has 1 side with 0 strikes, 4 sides with 1 strike, and 1 side with 2 strikes. As you can see, Power dice are better than Action dice, as they have more strikes on average. With Dice Stats, these dice can be modeled as:

from fractions import Fraction
from dice_stats import Dice

ACTION_DIE = Dice({0: Fraction(3, 6), 1: Fraction(2, 6), 2: Fraction(1, 6)})
POWER_DIE = Dice({0: Fraction(1, 6), 1: Fraction(4, 6), 2: Fraction(1, 6)})

On a single roll, you can roll a few Action dice and a few Power dice together. To calculate the outcome of that, you can write a function like this:

from typing import NamedTuple

Roll = NamedTuple("Roll", [("action", int), ("power", int)])


def get_dice(roll: Roll) -> Dice:
    return ACTION_DIE * roll.action + POWER_DIE * roll.power

The outcome is an instance of Dice, because a Dice is like a mapping of each outcome — in this case, number of strikes — and their probability.

Let's see something more complex: the whole attack sequence. In the game, when one model attacks another model, the attacker player rolls a set of attack dice, and the defender player rolls a set of defense dice. Each player rolls a combination of Action and Power dice. The amount of dice depends on the models' attack/defense stats, game circumstances like being in cover, etc. Then, we calculate the difference between the two rolls — strikes on attack minus strikes on defense. If the result is 0 or negative, the attack completely misses. Otherwise, we need to calculate how much damage the defender takes.

To calculate the damage, the attacker rolls a new set of dice — now the amount of dice depends on how deadly the weapon is, how many strikes were rolled on the attack roll, and other factors. The defender does not roll dice this time. Instead, each model has an armor value. A model with "armor N" takes 1 damage point for each N strikes on the damage roll. For example, if the result or the roll is 5 strikes, and the defender has armor 2, the defender takes 2 damage points. If it was 6 strikes, it would take 3 damage points. Now, imagine that we want to calculate what's the distribution of damage that a defender will take against multiple attacks — in other words, what's the chance that it will take 0 damage, or 1 damage, or 2 damage, etc. Let's see this in code:

from typing import Dict, List, Tuple


def prob_attack(
        attacks: List[Tuple[Roll, Roll]], defense_roll: Roll, armor: int
) -> Dice:
    damages = []
    defense_dice = get_dice(defense_roll)
    for attack_roll, damage_roll in attacks:
        attack_dice = get_dice(attack_roll)
        hits = (attack_dice - defense_dice).max(Dice.from_empty())
        if not hits:
            continue

        damage_dice = Dice.sum(
            Dice.from_empty() @ c
            if v == 0
            else get_dice(Roll(action=damage_roll.action, power=damage_roll.power + v))
                 @ c
            for v, c in hits.items()
        )

        damages.append(damage_dice / armor // 1)

    if not damages:
        damages = [Dice.from_empty()]

    return sum(damages)

The multiple attacks are represented as a List[Tuple[Roll, Roll]], where each attack is a tuple with an attack Roll and a damage Roll. Remember that a Roll is not a sample result of rolling dice, it's the amount of dice rolled. A defender will roll the same number of defense dice against all the attacks, so we only need one Roll for that.

Computing the distribution of the defense dice is a simple attack_dice = get_dice(attack_roll), using the function shown before.

Computing the distribution of the difference between attack dice and defense dice is hits = (attack_dice - defense_dice).max(Dice.from_empty()). It's important to calculate this distribution because, when calculating damage, the attacker rolls an extra power dice for each strike on the difference between attack and defense dice. You can see that when we iterate on each possible outcome of the attack/defense roll with for v, c in hits.items(), we modify the original damage roll to add v to it: Roll(action=damage_roll.action, power=damage_roll.power + v).

Probably you got a bit confused with the @ operator, which the library overloads, and it's basically a nice looking replacement for the Dice.as_total_chance method. We are doing this because the outcome of the damage roll is conditioned by the probability of rolling that amount of strikes on the attack/defense roll.

Well, that's all I wanted to say. I think this is an awesome library and wanted to share it with you.

Happy rolling!