Probability and Games: Damage Rolls

 from Red Blob Games
22 Jan 2012

Paper-and-dice role playing games like Dungeons & Dragons use damage rolls to calculate attack damage[1]. This makes sense for a game based on dice. Many computer RPGs calculate damage and other attributes (strength, magic points, agility, etc.) with a similar system.

Typically you’ll write some code to call random(). You’ll adjust the numbers and tweak the results to get the behavior you want in your game. This tutorial will cover three topics:

  1. Basic adjustments — average value and variance
  2. Adding asymmetry — dropping dice rolls or adding critical hits
  3. Complete freedom in designing your random numbers, not limited by what dice provide

Basics#

For this article I assume you have a function random(N) that returns a random integer from 0 to N-1. In Python, use random.randrange(N) ; in Javascript, use Math.floor(N * Math.random()) ; in C, the standard library has rand() % N but it behaves poorly so use a different random number generator ; in C++, attach a uniform_int_distribution(0,N-1) to a random number generator object[2]; in Java, make a random number generator object with new Random() and then call .nextInt(N) on it. The standard libraries in many languages don’t have great random number generators but there are lots of third party libraries such as PCG[3] for C and C++.

Let’s start with a single die. This histogram shows the results of rolling a single 12-sided die: 1+random(12). Since random(12) returns a number from 0 to 11, and we want a number from 1 to 12, we add 1 to it. The x-axis is the damage; the y-axis shows how often that damage occurs. With a single die, a damage roll of 2 or 12 is just as likely as a damage roll of 7.

1+random(12)

For multiple dice rolls, it’ll help to use a bit of notation from dice games: NdS means roll an S sided die N times. Rolling the single 12-sided die above would be written 1d12; 3d4[4] means to roll a 4-sided die three times; we’d code it as 3 + random(4) + random(4) + random(4).

Let’s roll  -sided dice (d) and add up the results:

damage = 0
for each 0 ≤ i < :
    damage += 1+random()

The outcomes could be anywhere from ( ) to ( ). It’s more likely to roll than or .

d

What happens if we increase the number of dice, but decrease their size? Try these ways to generate random numbers up to 12:

The main effect is that the distribution goes from wide to narrow. There’s also a second effect, where the peak shifts to the right. Let’s first investigate the uses of shifts.

Constant Shifts

Some weapons in Dungeons & Dragons give bonus damage. We could write 2d6+1 to indicate +1 bonus damage. In some games, armor or shields negate some damage. We could write 2d6-3 to indicate that 3 points of damage are blocked; I’ll assume in this example that the minimum damage is 0.

Try shifting the damage positive (for a damage bonus) or negative (for damage blocking):

2d6

Adding bonus damage or subtracting blocked damage simply shifts the entire distribution left or right.

Distribution variance

As we moved from 2d6 to 6d2 the distribution both got narrower and shifted to the right. As we saw in the previous section, shifting is just an offset. Let’s look at distribution variance.

Let’s define a function for N repeated rolls of random(S+1), returning a number from 0 to N*S:

function rollDice(N, S):
    # Sum of N dice each of which goes from 0 to S
    value = 0
    for 0 ≤ i < N:
        value += random(S+1)
    return value

Generating random numbers from 0 to 24 using dice produces this distribution of outcomes:

rollDice(, )

Try changing the number of dice — — to see how it affects the distribution. As the number of rolls goes up, while holding the range 0 to N*S fixed, the distribution becomes narrower (lower variance). More of the outcomes will be near the center of the range.

Side note: if you increase the number of sides S (see the playground below), while dividing the total by S, the distribution will approach a normal distribution[5]. A simpler way to choose randomly from a normal distribution is the Box-Muller transform[6].

Asymmetry#

The distributions for rollDice(N, S) are symmetric. Lower than average values are just as likely as higher than average values. Is that what you wanted for your game? If not, there are several techniques for creating asymmetry.

Dropping or redoing rolls

Suppose you’d like higher-than-average values to be more common than lower-than-average values. This is less common for damage, but can be used for attributes like strength, intelligence, etc. One way to do this is to roll several times and pick the best roll.

Let’s try rollDice(, ) twice and picking the higher roll:

roll1 = rollDice(, )
roll2 = rollDice(, )
damage = max(roll1, roll2)
Higher of two rolls

When we pick the higher of rollDice(2, 12) and rollDice(2, 12), we end up with a number from 0 to 24. Another way to get a number from 0 to 24 is to use rollDice(1, 12) three times and pick the best two of three. The shape is even more asymmetric than picking the better of two of rollDice(2, 12):

roll1 = rollDice(, )
roll2 = rollDice(, )
roll3 = rollDice(, )
damage = roll1 + roll2 + roll3
# now drop the lowest:
damage = damage - min(roll1, roll2, roll3)
Drop the lowest of three rolls

Another approach would be to reroll the lowest outcome. The shape is similar overall to the previous approaches, but slightly different in its details:

roll1 = rollDice(, )
roll2 = rollDice(, )
roll3 = rollDice(, )

damage = roll1 + roll2 + roll3
# now drop the lowest and roll it again:
damage = damage - min(roll1, roll2, roll3)
                + rollDice(, )
Reroll the lowest outcome

Any of these approaches can be used to reverse the asymmetry, making lower-than-average values more common than higher-than-average values. A different way of looking at it is that this distribution produces occasional bursts of high values. Such a distribution is often used for damage, and rarely used for attributes. Here’s max() reversed to min():

roll1 = rollDice(, )
roll2 = rollDice(, )
damage = min(roll1, roll2)
Drop the higher of two rolls

Critical hits

Another way to create occasional bursts of high damage is to implement it more directly. In some games, a “critical hit” provides some bonus. The simplest bonus is extra damage. In the following code the critical hit damage is added of the time:

damage = rollDice(3, 4)
if random(100) < :
    damage += rollDice(3, 4)
Regular damage plus critical bonus damage
Try changing the crit bonus chance:

Other approaches to adding asymmetry include affecting further attacks: make the critical hits have a chance to trigger further critical hits; make critical hits trigger a second attack that skips defenses; or make critical hits cause the opponent to miss an attack. However I’m not going to analyze multi-attack damage distributions here.

Try designing your own

For each use of randomness (damage, attributes, etc.), start by describing the characteristics of the distribution that you want for your gameplay:

Use the playground to play with some of these parameters:

value =  + rollDice(, )
# No minMin with another roll:
value = min(value,  + rollDice(, ))
# No maxMax with another roll:
value = max(value,  + rollDice(, ))
# No critical bonusCritical bonus:
if random(100) < :
    value +=  + rollDice(, )
Playground

There are lots more ways to structure your random numbers than the playground offers, but I hope it gives you a sense of how much flexibility there already is. Sometimes though combining dice rolls is not enough to give you what you want.

Arbitrary shapes#

We’ve been starting with the input algorithms and looking at the corresponding output distributions. We’re going through lots of different inputs until we find an output that matches what we want. Is there a more direct way of getting the right algorithm? Yes!

Let’s go backwards, starting with the desired output expressed as a histogram. Let’s try this with a simple example.

Suppose I want to pick 3, 4, 5, and 6 in these proportions:   :  :  : . This doesn’t correspond to anything you’d normally get from dice rolls.

Desired outcomes

How would I write the code for this?

x = random(+++)
if      x < :        value = 3
else if x < +:     value = 4
else if x < ++:  value = 5
else:                   value = 6

Study that code and make sure you understand how it works before the next step. Try picking different values of x to see which result you get. Let’s generalize this code into something reusable for different probability tables. The first step is to write out the table:

damage_table = [   # array of (weight, damage)
    (, 3),
    (, 4),
    (, 5),
    (, 6),
];

In the hand-written code, each if statement compared x to the cumulative sum of the probabilities. Instead of writing separate if statements with manually-expressed sums, we can loop over the entries of the table:

cumulative_weight = 0
for (weight, result) in table:
    cumulative_weight += weight
    if x < cumulative_weight:
        value = result
        break

The final thing we need to generalize is the sum of the table entries. Let’s compute the sum and use that for choosing a random x:

sum_of_weights = 0
for (weight, value) in table:
    sum_of_weights += weight

x = random(sum_of_weights)

Putting it all together, we can write a function to look up the results from the table, and a function to choose a random result (you might turn these into methods on a damage table class):

function lookup_value(table, x):
    # assume 0 ≤ x < sum_of_weights
    cumulative_weight = 0
    for (weight, value) in table:
        cumulative_weight += weight
        if x < cumulative_weight:
            return value

function roll(table):
    sum_of_weights = 0
    for (weight, value) in table:
        sum_of_weights += weight

    x = random(sum_of_weights)
    return lookup_value(damage_table, x)

The code to generate numbers from the table is simple. This code has been fast enough for my own needs, but if your profiler says it’s too slow, speed up the linear search by trying binary/interpolation search, lookup tables, or the alias method (see Keith Schwarz’s explanation[7] and Bruce Hill’s interactive explanation[8]). Also see inverse transform sampling[9].

Draw your own distribution

The nice thing about this approach is that it allows any shape. Try drawing in this playground to see what the code will be:

Playground: arbitrary distributions

With this approach you can choose a distribution to match the gameplay experience you want, without being constrained by the distributions produced by dice rolls.

Conclusion

Random damage rolls and random attributes are easy to implement. As a game designer, you should consider what properties you want the resulting distribution to have. If you want to use dice rolls:

Think about how you want the distribution to vary throughout the game. Attack bonuses, damage blocking, and critical hits are some ways to vary the distribution with simple parameters. Those parameters can then be assigned to items in the game. Use the playground above to see how those parameters affect the distributions. Think about how you want the distribution to change as the player levels up; see this page[10] for increasing the mean and decreasing the variance over time, with distributions calculated using AnyDice[11] (which has a nice blog[12]).

Unlike paper & dice games, you aren’t limited to a distribution based on the sums of random numbers. You can use any distribution you want by using the code we developed in the “Designing your own distribution” section. You can make a visual tool that lets you draw a histogram, save the data to a table, and then draw random numbers from that distribution. Or you can edit tables in JSON or XML format. Or you can edit tables in Excel and export them as CSV. Nonparameteric distributions give you a great deal of flexibility, and using data tables instead of code allows quick iteration without recompiling code.

There are lots of ways to make interesting probability distributions with simple code. First decide what kinds of properties you want, and then pick code to match.

Email me , or tweet @redblobgames, or comment: