Critical chance in video games is not calculated how you think. Video games have a sophisticated way of applying probability, and it's called pseudorandom distribution (PRD).
Uniform distribution
You already know what a uniform distribution is. It's your intuitive idea of probability where every outcome is equiprobable. The simple example is a d10; no side has a higher chance to land than any other. This creates a swingy distribution where outcomes could land high or low without skew.
In video games, a uniform distribution can generate Bernoulli (binary) outcomes. For example, let's say {1, 2, 3, 4, 5, 6, 7, 8, 9} are non-crit values and {10} is a crit value. There's an underlying uniform distribution, but our selection process for the outcome is Bernoulli. This is just a fancy way of saying we can have only two types of outcomes. However, players find the outcomes frustratingly unpredictable.
Consider a crit chance of 10%; intuition says you will crit 1-in-10 times. This is wrong. In a uniform distribution, you could miss crit 20 times in a row. The chance of a 20-hit streak without crit is 12.16%, a near 1-in-8 chance. Humans underestimate the chances of streaks, and eventually experience disappointment.
This pain point is solved via PRD, a trick to make probability feel more fair.
Pseudorandom distribution
Say you have a desired crit chance . PRD can help you crit 1-in-10 times more consistently. Instead of having a crit chance on every hit, your initial crit chance is . After each missed crit, you gain a flat constant of ; this stacks until a crit is guaranteed. Once you crit, it resets back to again. PRD serves as a pity mechanic, eventually guaranteeing the desired outcome. You can be unlucky now, but you will never be unlucky forever. Sophisticated, no?
The equation is , capped at the smallest where . is crit chance, is the number of hits since the last crit, and is the additive increment to crit chance per consecutive miss. Note, is also the initial crit chance. On proc, resets to 1.
Now, you might be wondering how we got . Well, we have to solve for backwards.
Solving for the PRD constant
is the PRD constant. There's only one exact for each , but the smaller is, the more rows you'll have to compute. crit requires computing 6,409 rows. crit requires only 4 rows. For now, let's try finding for crit.
Example. Let's say I want to find where .
Recall that .
-
Guess and check .
-
-
Find the hit number where crit is guaranteed ().
-
Find the chance to reach each hit without crit. You can use one of the two following equations; they're equivalent. I use the recursive form.
Product form.
Recursive form.
| hit | calculation | |
|---|---|---|
| 1 | ||
| 2 | ||
| 3 | ||
| 4 | ||
| 5 | ||
| 6 | ||
| 7 | ||
| 8 | ||
| 9 | ||
| 10 |
- Find crit chance for hit .
-
Find average hits per crit.
Calculate the weighted average for every .
- Find the actual crit chance.
Here's the comprehensive table:
| hit () | crit chance | chance to reach | chance crit procs on () | |
|---|---|---|---|---|
Sum of : (average hits per crit) actual crit chance
Well damn, we didn't find for crit. We found for crit.
Did we do something wrong? No, we did everything right; we're just not finished yet. You know whether your guess for is too high or too low by calculating the actual crit rate and comparing it to desired crit rate . Then, we'll set an interval .
- If , then is too high. Set the interval .
- If , then is too low. Set the interval .
Our for crit is too high, but we know that for must be too low, so the answer must exist in .
In the inverse case, if were too low, we know that for would be too high, so the answer would exist in .
Bisection
I'm not going to check every number in this interval; I'm going to bisect it. Bisection is uber simple, just slash the interval of candidate values in half at the midpoint, calculate , compare and values, and keep the half that still contains the answer. Repeat this, and we converge towards the answer.
For each iteration , find the midpoint .
Using , I calculate for again, and we get .
At this point, I continue iterating until we converge to the answer.
This is the bisection recurrence and midpoint equations.
where is the actual crit chance produced by .
Iteration table
| iteration | interval | midpoint | actual crit rate | comparison to |
|---|---|---|---|---|
| high | ||||
| high | ||||
| low | ||||
| high | ||||
| high | ||||
| low | ||||
| high | ||||
| low | ||||
| low | ||||
| high |
By iteration 9, we've converged to the answer:
For any real-world use case, I would consider using a lookup table over calculating the values in runtime. While runtime calculation is already very fast, you can ensure consistency across networks by preventing weird floating point divergences.
If you're serious about precision, bisect 65 times; this will saturate float64. At this precision, precomputing C for every integer in will still be 5 times faster than the visible flash of lightning (lightning flash , precomputation on my machine ).
Curious whether I'm right? Don't be; see it for yourself.
Computing the PRD table for [1, 100]
from math import ceil
from time import perf_counter
def find_prd_constant(desired_crit_chance):
if desired_crit_chance == 1:
return 1.0
low, high = 0.0, 1.0
for _ in range(65):
C = (low + high) / 2
p = actual_crit_chance(C)
if p > desired_crit_chance:
high = C
else:
low = C
return (low + high) / 2
def actual_crit_chance(C):
average_hits = 0.0
reach_chance = 1.0
for n in range(1, ceil(1 / C) + 1):
crit_chance = min(C * n, 1.0)
proc_chance = reach_chance * crit_chance
average_hits += n * proc_chance
reach_chance *= 1 - crit_chance
return 1 / average_hits
results = []
elapsed_ms = 0.0
for percent in range(1, 101):
start = perf_counter()
C = find_prd_constant(percent / 100)
elapsed_ms += (perf_counter() - start) * 1000
results.append((percent, C))
print(f"{'p':>3} {'C':>4}")
for percent, C in results:
print(f"{percent:3}% {C * 100:>12.8f}")
print(f"\nDone in {elapsed_ms:.3f} ms")
PRD table for [1, 100]
Rounded to the nearest 0.001% via banker's rounding.
| desired | constant | desired | constant | desired | constant | desired | constant |
|---|---|---|---|---|---|---|---|
| 1 | 0.016 | 26 | 9.118 | 51 | 31.268 | 76 | 68.421 |
| 2 | 0.062 | 27 | 9.783 | 52 | 32.329 | 77 | 70.130 |
| 3 | 0.139 | 28 | 10.467 | 53 | 33.412 | 78 | 71.795 |
| 4 | 0.245 | 29 | 11.171 | 54 | 34.737 | 79 | 73.418 |
| 5 | 0.380 | 30 | 11.895 | 55 | 36.040 | 80 | 75.000 |
| 6 | 0.544 | 31 | 12.638 | 56 | 37.322 | 81 | 76.543 |
| 7 | 0.736 | 32 | 13.400 | 57 | 38.584 | 82 | 78.049 |
| 8 | 0.955 | 33 | 14.181 | 58 | 39.828 | 83 | 79.518 |
| 9 | 1.202 | 34 | 14.981 | 59 | 41.054 | 84 | 80.952 |
| 10 | 1.475 | 35 | 15.798 | 60 | 42.265 | 85 | 82.353 |
| 11 | 1.774 | 36 | 16.633 | 61 | 43.460 | 86 | 83.721 |
| 12 | 2.098 | 37 | 17.491 | 62 | 44.642 | 87 | 85.057 |
| 13 | 2.448 | 38 | 18.362 | 63 | 45.810 | 88 | 86.364 |
| 14 | 2.823 | 39 | 19.249 | 64 | 46.967 | 89 | 87.640 |
| 15 | 3.222 | 40 | 20.155 | 65 | 48.113 | 90 | 88.889 |
| 16 | 3.645 | 41 | 21.092 | 66 | 49.248 | 91 | 90.110 |
| 17 | 4.092 | 42 | 22.036 | 67 | 50.746 | 92 | 91.304 |
| 18 | 4.562 | 43 | 22.990 | 68 | 52.941 | 93 | 92.473 |
| 19 | 5.055 | 44 | 23.954 | 69 | 55.072 | 94 | 93.617 |
| 20 | 5.570 | 45 | 24.931 | 70 | 57.143 | 95 | 94.737 |
| 21 | 6.108 | 46 | 25.987 | 71 | 59.155 | 96 | 95.833 |
| 22 | 6.668 | 47 | 27.045 | 72 | 61.111 | 97 | 96.907 |
| 23 | 7.249 | 48 | 28.101 | 73 | 63.014 | 98 | 97.959 |
| 24 | 7.851 | 49 | 29.155 | 74 | 64.865 | 99 | 98.990 |
| 25 | 8.474 | 50 | 30.210 | 75 | 66.667 | 100 | 100.000 |
Addenda
- The average lightning flash lasts 301 ms according to NOAA's Geostationary Lightning Mapper (GLM).
- One popular video game known to use PRD for crit chance is Dota 2; this is where I first learned it. I spent ~1,600 hrs playing Dota 2 when I was in high school.