So, I’m trying to figure out how to do a “players roll all dice” version of d20 where the probabilities remain the same for both sides.
Addenda to the d20 SRD suggest that enemies receive an Attack Score equal to 11 + their normal attack bonus and make Defense Checks equal to 1d20 + (AC - 10). However, when I brought this up on a Discord server, one user insisted that I should use (AC - 11), and indeed 11 across the board to convert static DCs to bonuses and vice versa.
That didn’t sound right to me, so I resolved to do my own calculations.
The Logical Approach
For argument’s sake let’s call the attack bonus of an attacker ATK
and
define the AC as 10 + DEF
, where DEF
is the total bonus provided by armor,
Dexterity, magic, etc. So the fundamental equation of d20 games is:
1d20 + ATK >= 10 + DEF
or
1d20 >= 10 + DEF - ATK
If we set DEF == ATK for a moment, canceling them out, we find that the d20 must roll 10 or higher to hit. That’s a 55% chance. The probability of not getting hit is therefore 45%. If you’re rolling high for that on a d20, that’s a … 12 or more?
So does that mean your defense roll is 1d20 + DEF vs. 12 + ATK?
I was expecting a 10 or 11, not a 12.
The Algebraic Approach
Let’s try this again:
- Attacker has a bonus of ATK.
- Defender has an AC of 10 + DEF.
Attacker’s chance of success is:
1d20 + ATK >= 10 + DEF
1d20 >= 10 + DEF - ATK
Defender’s chance of success is therefore:
1d20 < 10 + DEF - ATK
(21 - 1d20) < 10 + DEF - ATK
since (21 - 1d20) produces the same probability distribution as 1d20.
-1d20 < -11 + DEF - ATK
Taking the negative of both sides, we get:
1d20 > 11 + ATK - DEF
Or, since all these quantities are integers:
1d20 >= 12 + ATK - DEF
Huh.
The Numeric Approach
Not believing in the 12, I decided to plug in a few numbers and see what I got. The results are below.
DEF - ATK | ATK DC | hit | miss | DEF DC |
---|---|---|---|---|
+6 | 16 | 25% | 75% | 6 |
+3 | 13 | 40% | 60% | 9 |
0 | 10 | 55% | 45% | 12 |
-3 | 7 | 70% | 30% | 15 |
-6 | 4 | 85% | 15% | 18 |
The Programmatic Approach
Not trusting my own arithmetic, I whipped up a quick Python script and ended up with the following numbers:
DEF - ATK | ATK DC | % hit | % miss | DEF DC |
---|---|---|---|---|
10 | 20 | 5 | 95 | 2 |
9 | 19 | 10 | 90 | 3 |
8 | 18 | 15 | 85 | 4 |
7 | 17 | 20 | 80 | 5 |
6 | 16 | 25 | 75 | 6 |
5 | 15 | 30 | 70 | 7 |
4 | 14 | 35 | 65 | 8 |
3 | 13 | 40 | 60 | 9 |
2 | 12 | 45 | 55 | 10 |
1 | 11 | 50 | 50 | 11 |
0 | 10 | 55 | 45 | 12 |
-1 | 9 | 60 | 40 | 13 |
-2 | 8 | 65 | 35 | 14 |
-3 | 7 | 70 | 30 | 15 |
-4 | 6 | 75 | 25 | 16 |
-5 | 5 | 80 | 20 | 17 |
-6 | 4 | 85 | 15 | 18 |
-7 | 3 | 90 | 10 | 19 |
-8 | 2 | 95 | 5 | 20 |
Note that I’m assuming that a Natural 20 always hits, and a Natural 1 always misses.
Conclusion
So it looks like when I revise The Elf System I’ll have to make sure that all the opponents DCs are based on 12, not 10.
What happened to the elevens? Well, if I’d started with this:
1d20 + ATK >= 11 + DEF
I’d probably have ended up with eleven on the other side:
1d20 + DEF >= 11 + ATK
That’s because a DC of 11 is exactly 50%, which gives the defender a 50% to not get hit. My way, on the other hand, biases rolls toward the attacker by 10%. Bummer for the defender, but it does get combats over with more quickly by having attackers whiff less.
Script
#!/bin/env python3
# /// script
# requires-python = ">=3.13"
# dependencies = [
# "tabulate",
# ]
# ///
from fractions import Fraction
from tabulate import tabulate
FIVE_PERCENT = Fraction(1, 20)
def dc_to_p(dc: int) -> Fraction:
if dc <= 1:
return Fraction(19, 20)
return Fraction((21 - dc), 20) if dc < 20 else FIVE_PERCENT
def p_to_dc(p: Fraction) -> int:
if p >= 1:
return 1
return (21 - round(p * 20)) if p >= FIVE_PERCENT else 20
def main() -> None:
headers: list[str] = ["DEF - ATK", "ATK DC", "% hit", "% miss", "DEF DC"]
table: list[list[int | Fraction | float]] = [
[
diff,
(atkdc := diff + 10),
(atkp := dc_to_p(atkdc))*100,
(defp := 1 - atkp)*100,
p_to_dc(defp),
]
for diff in range(+10, -9, -1)
]
print(tabulate(table, headers=headers, tablefmt="pipe", floatfmt=".0f"))
if __name__ == "__main__":
main()