Paper-and-dice role playing games like Dungeons & Dragons use damage rolls to calculate attack damage. 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. Let’s start with damage based on dice.
First, a bit of notation: NdS means roll an S sided die N times. For example, 3d4 means to roll a 4-sided die three times. With a function random(low,high) that returns a random integer from low to high, we could write this as random(1,4) + random(1,4) + random(1,4).
Let’s start with a single die. This histogram shows see the results of rolling a single 12-sided die: random(1,12). 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.
Consider rolling -sided dice, d, and summing them:
damage = 0;
for (i = 0; i < ; i++) {
damage += random(1,);
}The outcomes could be anywhere from ( ) to ( ). It’s more likely to roll than or .
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):
Adding bonus damage or subtracting blocked damage simply shifts the entire distribution left or right. We can simplify random(low, high) + offset to random(0,
high-low) + (offset+low). Thus, NdS is S + N of
random(0,S-1).
For next sections, I’ll eliminate the offsets by using random(0,max). This will make the analysis simpler. It’s easy to add the offset back in later.
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(0, S), 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 (i = 0; i < N; i++) {
value += random(0,S);
}
return value;
}Generating random numbers from 0 to 24 using dice produces this distribution of outcomes:
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. A simpler way to choose randomly from a normal distribution is the Box-Muller transform.
Simple 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.
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);
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);
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(, );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);
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(1,100) <= ) {
damage += rollDice(3, 4);
}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.
Picking a distribution
For each use of randomness (damage, attributes, etc.), start by describing the characteristics of the distribution that you want for your gameplay:
- Range: what are the minimum and maximum values, if any? Use scaling and shifting to adjust your distribution to fit this range.
- Variance: how often do you want the values to be close to the average? Add a small number of rolls for large variance, or a large number of rolls for small variance.
- Asymmetry: do you want higher-than-average or lower-than-average values to be more common? Use min, max, or critical bonuses to add asymmetry to your distribution.
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(1,100) <= ) { value += + rollDice(, ); }
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 it’s not enough.
Designing your own distribution
We’re 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!
You can directly describe the desired output distribution with a histogram or other function (called “nonparametric distributions” in statistics). Let’s try this with a simple example.
Suppose I want to pick 3 30% of the time, 4 20% of the time, 5 10% of the time, and 6 40% of the time. This doesn’t correspond to anything you’d normally get from dice rolls.
How would I write the code for this?
x = random(1,100);
if (x <= 30) { value = 3; }
else if (x <= 30+20) { value = 4; }
else if (x <= 30+20+10) { value = 5; }
else { value = 6; }We can rewrite that code into a table:
damage_table = [ # array of (damage, probability)
(3, 30),
(4, 20),
(5, 10),
(6, 40),
]; # note: probabilities must add to 100A simple reusable function can look up probabilities from a table (you might turn this into a method on a damage table class):
function lookup_value(table, x) {
cumulative_probability = 0;
for (value, probability in table) {
cumulative_probability += probability;
if (x <= cumulative_probability) {
return value;
}
}
}
damage = lookup_value(damage_table, random(1,100));The data in the table is from the histogram. This means the game designer can pick any shape of the probability distribution function. For quick iteration and balancing, put the table into a data file so that it can be edited and reloaded without recompiling the code.
The code to generate numbers from the table is simple. For small tables, iterating linearly in O(N) time is ok because N is small. Note that the order of the table doesn’t matter so you can make the linear scan slightly faster on average by sorting so the most probable outcomes come first.
If your probabilities are evenly distributed, you can invert the table into a lookup table. In the above example you can build a 10-element array [3, 3, 3, 4, 4, 5, 6, 6, 6, 6] and choose the value randomly from there. If your tables are larger, and not guaranteed to be evenly distributed, you can use more sophisticated techniques, from binary search taking O(log N) time, to interpolation search taking O(log log N) time, to this brilliant technique taking O(1) time.
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.
- Use the number of rolls to control the variance. A low number of rolls corresponds to a high variance, and vice versa.
- Use the offset and die size to control the scale. If you want the random numbers to range from X to Y, then each of the N rolls should produce a random number from 0 to (Y-X)/N, and add X to the sum. Positive offsets can be used for bonus damage or bonus attributes; negative offsets can be used for blocking damage.
- Use asymmetry to make higher-than-average or lower-than-average value occur more often. Attribute rolls often make higher than average values more common, using max, best of three, or rerolling the lowest. Damage rolls often make lower than average values more common, using min, or critical bonuses. Random encounter difficulty also often makes lower than average values more common.
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.
Unlike paper & dice games, you aren’t limited to a distribution based on the sums of random numbers. You can easily draw your own arbitrary distributions and write straightforward code to pick random numbers from them. The main downside of this approach is that it’s harder to use simple parameters to vary the distribution.
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.