D100 Opposed Rolls

Posted: 2023-06-28
Last Modified: 2024-03-24
Word Count: 2521
Tags: brp d100 call-of-cthulhu lua-code mythras openquest rpg

Table of Contents

2024-03-24: Fix bug in code and tables, explain all die rolling algorithms in plain English.

After the terrible code I wrote in a previous post I wanted to see if a) if I could still write less ugly code and b) what the opposed roll “resistance table”1 would look like for other d100 systems.

I therefore set out to calculate opposed rolls for the four major extant d100 systems that I know about: Basic Roleplaying (Chaosium), Call of Cthulhu 7th ed (Chaosium), Mythras (Design Mechanism), and OpenQuest (D101 Games).2

Opposed Roll Tables

Nearly all percentile dice systems define a character’s skill level as a percentile score. The player succeeds if the roll is at or below this score, and fails if the roll is above this score. That said, there are numerous ways to define levels of success (or failure). There are also numerous ways to define what happens in an opposed test, when two parties (usually a player and GM) roll dice against their respective skill levels to see who wins.

We will consider four systems, and how they defined opposed tests. I will also present the chances of winning an opposed contest based on the skill levels of the two parties.

Basic Roleplaying

The 2008 “Big Gold Book”, on which the 2023-2024 rules are based, defines two levels of success beyond the simple pass/fail:

In addition to regular Failure, it also defines a Fumble: the character not merely failed but messed up bad. In BRP, if the die rolls is close to (1)00 – 1/20th of the failure chance close, to be exact, the result is a Fumble.

In an opposed test, both parties roll dice and see who gained the greater level of success: Critical beats all, Special beats Regular, Success beats Failure, Failure beats Fumble. If both parties have the same level of success, the one with the higher die roll wins.

The 2008 version apparently used a comparison of skill not die rolls. Apparently the recent (2023?) version used a comparison of die rolls. At least the diagonal is 50.5% now.

Pl. 0 10 20 30 40 50 60 70 80 90 100
0 50.50 41.50 33.55 25.62 19.66 13.74 9.78 5.87 3.91 2.01 1.90
10 59.40 50.50 41.48 32.60 25.64 18.85 13.97 9.22 6.34 3.60 2.70
20 67.25 59.40 50.50 40.68 32.66 24.93 19.00 13.36 9.52 5.97 4.28
30 75.07 68.15 60.19 50.50 41.32 32.54 25.45 18.76 13.76 9.16 6.43
40 80.93 74.98 68.08 59.55 50.50 40.80 32.66 25.05 19.00 13.48 9.78
50 86.74 81.64 75.68 68.20 60.07 50.50 41.20 32.54 25.33 18.76 14.01
60 90.60 86.42 81.48 75.16 68.08 59.67 50.50 40.92 32.66 25.17 19.45
70 94.40 91.06 86.99 81.72 75.56 68.20 59.95 50.50 41.08 32.54 25.76
80 96.26 93.84 90.70 86.59 81.48 75.28 68.08 59.79 50.50 41.04 33.29
90 98.05 96.47 94.12 91.06 86.87 81.72 75.44 68.20 59.83 50.50 41.68
100 98.12 97.32 95.74 93.70 90.48 86.37 81.06 74.87 67.47 59.20 50.50

Call of Cthulhu 7

Among many controversial3 changes, 7th Edition of Call of Cthulhu changed character attributes from the usual 1-20 (well, 3-18) range to percentiles. (It’s just multiplying by 5, guys.) This also made the resistance table1 obsolete, replacing it with opposed rolls. (Which gets tricky when one is holding a door against a huge monster, but they had a solution for that.)

Another controversial change was this versions introduction of multiple levels of success:

Add to that the usual Failure (more than your skill level) and Fumble (at 96 or above).

The winner of an opposed skill test therefore has a higher success level than their opponent. If the two contestants’ success levels are the same, the one with the higher skill wins. If their skills are tied, the rules suggest the two combatants roll again.

When I first wrote a Ruby program to generate this table, I was surprised at how quickly and non-linearly percentages dropped off from the equal skill diagonal. It still looks weird to me, especially the lower skill levels.

Pl. 0 10 20 30 40 50 60 70 80 90 100
0 50.01 5.69 5.69 5.69 5.69 1.93 1.93 1.93 1.93 1.93 1.93
10 94.31 50.03 12.90 12.23 11.56 7.49 6.82 6.15 5.48 4.81 4.19
20 94.31 87.10 50.02 19.66 18.30 13.94 12.58 11.22 9.86 8.50 7.24
30 94.31 87.77 80.34 50.03 25.04 20.39 18.34 16.29 14.24 12.19 10.29
40 94.31 88.44 81.70 74.96 50.02 26.84 24.10 21.36 18.62 15.88 13.34
50 98.07 92.51 86.06 79.61 73.16 50.03 29.90 26.47 23.04 19.61 16.43
60 98.07 93.18 87.42 81.66 75.90 70.10 50.02 31.54 27.42 23.30 19.48
70 98.07 93.85 88.78 83.71 78.64 73.53 68.46 50.03 31.80 26.99 22.53
80 98.07 94.52 90.14 85.76 81.38 76.96 72.58 68.20 50.02 30.68 25.58
90 98.07 95.19 91.50 87.81 84.12 80.39 76.70 73.01 69.32 50.03 28.63
100 98.07 95.81 92.76 89.71 86.66 83.57 80.52 77.47 74.42 71.37 50.02

Mythras

Mythras defines two levels of success: Critical (1/10 of skill) and Normal (at or under skill). Unlike the others, a roll of 5 or less always succeeds, and a roll of 96 or more always fails. It also has Fumbles, defined as 99 or 00 unless skill is high enough. Again, the one with the higher level of success wins, and two characters who succeed compare rolls to see which is higher.

Unlike the others, Mythras insists that if both parties have an ordinary failure, neither party wins. Thus the diagonal is nowhere near 50% until higher skill levels (80% or better).

Pl. 0 10 20 30 40 50 60 70 80 90 100
0 4.90 4.70 4.25 3.81 3.38 2.96 2.55 2.14 1.73 1.32 1.11
10 9.85 9.55 8.55 7.56 6.58 5.61 4.65 3.70 2.76 1.83 1.36
20 19.79 19.54 18.10 16.10 14.11 12.13 10.16 8.20 6.25 4.31 3.28
30 29.72 29.52 28.09 25.65 22.65 19.66 16.68 13.71 10.75 7.80 6.21
40 39.64 39.49 38.07 35.64 32.20 28.20 24.21 20.23 16.26 12.30 10.15
50 49.55 49.45 48.04 45.62 42.19 37.75 32.75 27.76 22.78 17.81 15.10
60 59.46 59.40 58.00 55.59 52.17 47.74 42.30 36.30 30.31 24.33 21.06
70 69.37 69.34 67.95 65.55 62.14 57.72 52.29 45.85 38.85 31.86 28.03
80 79.28 79.27 77.89 75.50 72.10 67.69 62.27 55.84 48.40 40.40 36.01
90 89.19 89.19 87.82 85.44 82.05 77.65 72.24 65.82 58.39 49.95 45.00
100 94.15 94.15 92.84 90.52 87.19 82.85 77.50 71.14 63.77 55.39 50.35

OpenQuest 3

In OpenQuest, a Critical is defined as a success where the dice read double digits (11, 22, 33, etc.), and a Fumble is defines as a failure that reads double digits (00, 99, 88, etc.). If two characters have the same level of success or an ordinary failure, they see who came closest to their skill level from either direction. If both fumble, nobody wins.

We saw this one before, and after seeing the ones above I’m surprised how smooth the progression from the 50% diagonal is. Or that we have a 50% diagonal at all.

Pl. 0 10 20 30 40 50 60 70 80 90 100
0 49.95 49.40 47.15 44.09 40.22 35.54 30.05 23.75 16.64 8.72 0.00
10 50.40 49.95 47.70 44.64 40.77 36.09 30.60 24.30 17.19 9.27 0.55
20 52.66 52.21 50.05 46.98 43.10 38.41 32.91 26.60 19.48 11.55 2.81
30 55.73 55.28 53.12 50.14 46.25 41.55 36.04 29.72 22.59 14.65 5.89
40 59.61 59.16 57.00 54.02 50.22 45.51 39.99 33.66 26.52 18.57 9.79
50 64.30 63.85 61.69 58.71 54.91 50.29 44.76 38.42 31.27 23.31 14.51
60 69.80 69.35 67.19 64.21 60.41 55.79 50.35 44.00 36.84 28.87 20.05
70 76.11 75.66 73.50 70.52 66.72 62.10 56.66 50.40 43.23 35.25 26.41
80 83.23 82.78 80.62 77.64 73.84 69.22 63.78 57.52 50.44 42.45 33.59
90 91.16 90.71 88.55 85.57 81.77 77.15 71.71 65.45 58.37 50.47 41.59
100 100.00 99.55 97.39 94.41 90.61 85.99 80.55 74.29 67.21 59.31 50.50

Program

#!/usr/bin/env lua

-- Status code for a Fumble, defined variously in various rules.
local STATUS_FUMBLE <const>   = -1
-- Status code for an ordinary Failure, i.e. > skill and not a Fumble.
local STATUS_FAILURE <const>  = 0
-- Status code for an ordinary Success, i.e. <= skill and not a higher level.
local STATUS_SUCCESS <const>  = 1
-- Status code for a CoC "hard" success, i.e. <= skill/2
local STATUS_HARD <const>     = 2
-- Status code for a CoC "extreme" success, i.e. <= skill/5
local STATUS_EXTREME <const>  = 3
-- Status code for a BRP "special" success, i.e. <= skill/5
local STATUS_SPECIAL <const>  = 4
-- Status code for a critical success, defined variously in various rules.
local STATUS_CRITICAL <const> = 5

-- What level of success a roll of `p` indicates for a percentile `pct`
-- in Basic Roleplaying (2023)
local function success_level_brp(p, pct)
    if p == 1 then
        return STATUS_CRITICAL
    elseif p == 100 then
        return STATUS_FUMBLE
    elseif p <= pct then
        -- p is between 2 and 99
        if p <= pct // 20 then
            return STATUS_CRITICAL
        elseif p <= pct // 5 then
            return STATUS_SPECIAL
        else
            return STATUS_SUCCESS
        end
    else
        local fumble = 100 - (100 - pct) // 20
        if p >= fumble then
            return STATUS_FUMBLE
        end
        return STATUS_FAILURE
    end
end

-- Whether player wins on opposed rolls `p` vs `r`
-- with skills `player` vs. `resist`
-- in Basic Roleplaying (2023)
local function is_player_win_brp(p, r, player, resist)
    local pstat, rstat =
        success_level_brp(p, player),
        success_level_brp(r, resist)

    return pstat > rstat or (pstat == rstat and p >= r)
end

-- What level of success a roll of `p` indicates for a percentile `pct`
-- in Call of Cthulhu 7th Edition
local function success_level_coc7(p, pct)
    if p == 1 then
        return STATUS_CRITICAL
    elseif p == 100 then
        return STATUS_FUMBLE
    elseif p <= pct then
        -- p is between 2 and 99
        if p <= pct // 5 then
            return STATUS_EXTREME
        elseif p <= pct // 2 then
            return STATUS_HARD
        else
            return STATUS_SUCCESS
        end
    else
        if pct < 50 and p >= 96 then
            return STATUS_FUMBLE
        else
            return STATUS_FAILURE
        end
    end
end

-- Whether player wins on opposed rolls `p` vs `r`
-- with skills `player` vs. `resist`
-- in Call of Cthulhu 7th Edition
local function is_player_win_coc7(p, r, player, resist)
    local pstat, rstat =
        success_level_coc7(p, player),
        success_level_coc7(r, resist)

    if pstat > rstat then
        return true
    elseif pstat == rstat then
        -- According to the CoC7 Quickstart p. 10,
        -- "In the case of a draw, the side with the higher
        -- skill value wins. If both skills are equal then have
        -- both sides roll 1D100, with the lower result winning."
        if player > resist then
            return true
        elseif player == resist then
            -- Best approximation to random second roll.
            return (p + r) % 2 == 0
        end
    end
end

-- What level of success a roll of `p` indicates for a percentile `pct`
-- in Mythras
local function success_level_mythras(p, pct)
    -- 1 ... 5 is always a success
    -- 96 ... 100 is always a failure
    if p <= 5 or (p < 96 and p <= pct) then
        if p == 1 or p <= pct // 10 then
            return STATUS_CRITICAL
        else
            return STATUS_SUCCESS
        end
    else
        if p == 100 or (pct <= 100 and p == 99) then
            return STATUS_FUMBLE
        else
            return STATUS_FAILURE
        end
    end
end

-- Whether player wins on opposed rolls `p` vs `r`
-- with skills `player` vs. `resist`
-- in Mythras
local function is_player_win_mythras(p, r, player, resist)
    local pstat, rstat =
        success_level_mythras(p, player),
        success_level_mythras(r, resist)

    return pstat >= STATUS_SUCCESS
        and (pstat > rstat or (pstat == rstat and p >= r))
end

-- What level of success a roll of `p` indicates for a percentile `pct`
-- in OpenQuest 3rd edition.
local function success_level_oq3(p, pct)
    if p % 11 == 0 or p == 100 then
        -- roll is doubles: 11, 22, 33, ... 99, 00
        if p <= pct then
            return STATUS_CRITICAL
        else
            return STATUS_FUMBLE
        end
    else
        if p <= pct then
            return STATUS_SUCCESS
        else
            return STATUS_FAILURE
        end
    end
end

-- Whether player wins on opposed rolls `p` vs `r`
-- with skills `player` vs. `resist`
-- in OpenQuest 3rd edition.
local function is_player_win_oq3(p, r, player, resist)
    local pstat, rstat =
        success_level_oq3(p, player),
        success_level_oq3(r, resist)

    if pstat > rstat then
        return true
    elseif pstat == rstat then
        if pstat >= STATUS_SUCCESS then
            return p >= r
        elseif pstat == STATUS_FAILURE then
            return p <= r
        end
        -- STATUS_FUMBLE is never a player win
    end
end

-- Figure out probabilities of opposed rolls through brute force
local function opposed_roll(player, resist, pfunc)
    local sum = 0
    for p = 1, 100 do
        for r = 1, 100 do
            if pfunc(p, r, player, resist) then
                sum = sum + 1
            end
        end
    end
    return sum/10000
end

local function format_header(min, max, step)
    local buf = { "", " Pl." }
    for i = min, max, step do
        table.insert(buf, string.format(" %5d ", i))
    end
    table.insert(buf, "")
    return table.concat(buf, "|")
end

local function format_bar(min, max, step)
    local buf = { "", ":--:" }
    for i = min, max, step do
        table.insert(buf, "------:")
    end
    table.insert(buf, "")
    return table.concat(buf, "|")
end

local function format_row(percent, results)
    local buf = { "", string.format("%3d ", percent), }
    for n = 1, #results do
        table.insert(buf, string.format("%6.2f ", results[n] * 100))
    end
    table.insert(buf, "")
    return table.concat(buf, "|")
end

local RULES_LIST <const> = {
    {n = "Basic Roleplaying",  f = is_player_win_brp},
    {n = "Call of Cthulhu 7",  f = is_player_win_coc7},
    {n = "Mythras",            f = is_player_win_mythras},
    {n = "OpenQuest 3",        f = is_player_win_oq3},
}

local function print_columns(min, max, step)
    for i, rules in ipairs(RULES_LIST) do
        for p = min, max, step do
            for r = min, max, step do
                print(string.format("%s\t%d\t%d\t%.4f",
                                    rules.n,
                                    p,
                                    r,
                                    opposed_roll(p, r, rules.f)))
            end
        end
    end
end

local function print_table(min, max, step)
    for i, rules in ipairs(RULES_LIST) do
        print("###", rules.n, "###")

        print(format_header(min, max, step))
        print(format_bar(min, max, step))

        for p = min, max, step do
            local results = {}
            for r = min, max, step do
                table.insert(results, opposed_roll(p, r, rules.f))
            end
            print(format_row(p, results))
        end
    end
end

if arg[1] == '-l' then
    print_columns(0, 100, 10)
else
    print_table(0, 100, 10)
end

  1. An old mechanic for an active attribute to resist another attribute, where attributes are usually in the range 1-20. Basically the equation “50% + (Active - Resist) × 5%” as a table. ↩︎ ↩︎

  2. Sorry, Legend (Mongoose), you didn’t make the cut, but I wasn’t sure you were still supported. And sorry, too, French-made RPG that I remember buying but can’t even remember the name of. ↩︎

  3. I’m not saying they should have been controversial. Editions 1-6 had changed very little except to add more “stuff”. The changes augmented mechanics that hadn’t changed since the ’80s. But like all fandoms, the two modes of CoC players are “it’s changed so it sucks” and “it’s the same so it sucks”, so Chaosium was doomed either way. ↩︎