D100 Opposed Rolls

Posted: 2023-06-28
Word Count: 1939
Tags: brp d100 call-of-cthulhu lua-code mythras openquest rpg

Table of Contents

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

Basic Roleplaying

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.5% 40.6% 32.6% 24.7% 18.7% 12.8% 8.8% 4.9% 3.0% 1.1% 0.0%
10 60.3% 50.5% 41.4% 32.5% 25.4% 18.5% 13.5% 8.5% 5.5% 2.6% 0.5%
20 68.2% 59.5% 50.5% 40.6% 32.6% 24.8% 18.7% 12.9% 8.8% 5.0% 2.1%
30 76.0% 68.3% 60.2% 50.5% 41.3% 32.5% 25.4% 18.5% 13.4% 8.5% 4.7%
40 81.9% 75.2% 68.2% 59.6% 50.5% 40.8% 32.6% 24.9% 18.7% 13.0% 8.2%
50 87.7% 82.1% 75.9% 68.3% 60.1% 50.5% 41.2% 32.5% 25.2% 18.5% 12.8%
60 91.5% 87.0% 81.9% 75.3% 68.2% 59.7% 50.5% 40.8% 32.6% 25.0% 18.3%
70 95.3% 91.8% 87.6% 82.1% 75.8% 68.3% 60.0% 50.5% 41.1% 32.5% 24.9%
80 97.2% 94.8% 91.5% 87.1% 81.9% 75.4% 68.2% 59.8% 50.5% 40.9% 32.4%
90 99.0% 97.6% 95.2% 91.8% 87.5% 82.1% 75.7% 68.3% 59.9% 50.5% 40.9%
100 100.0% 99.6% 98.1% 95.7% 92.2% 87.8% 82.3% 75.8% 68.4% 60.0% 50.5%

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.)

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.0% 4.8% 4.8% 4.8% 4.8% 4.8% 4.8% 4.8% 4.8% 4.8% 0.9%
10 95.2% 50.0% 12.9% 12.3% 11.7% 11.0% 10.4% 9.7% 9.1% 8.5% 4.5%
20 95.2% 87.1% 50.0% 19.7% 18.4% 17.0% 15.7% 14.4% 13.0% 11.7% 7.5%
30 95.2% 87.7% 80.3% 50.0% 25.1% 23.1% 21.0% 19.0% 17.0% 15.0% 10.5%
40 95.2% 88.3% 81.6% 74.9% 50.0% 29.1% 26.4% 23.6% 20.9% 18.2% 13.5%
50 95.2% 89.0% 83.0% 77.0% 70.9% 50.0% 31.7% 28.3% 24.9% 21.5% 16.5%
60 95.2% 89.6% 84.3% 79.0% 73.6% 68.3% 50.0% 32.9% 28.8% 24.8% 19.6%
70 95.2% 90.3% 85.6% 81.0% 76.3% 71.7% 67.1% 50.0% 32.8% 28.0% 22.6%
80 95.2% 90.9% 87.0% 83.0% 79.1% 75.1% 71.2% 67.2% 50.0% 31.3% 25.6%
90 95.2% 91.5% 88.3% 85.0% 81.8% 78.5% 75.2% 72.0% 68.7% 50.0% 28.6%
100 99.1% 95.5% 92.5% 89.5% 86.5% 83.5% 80.4% 77.4% 74.4% 71.4% 50.0%

Mythras

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 6.8% 6.5% 5.9% 5.4% 4.9% 4.4% 3.9% 3.4% 2.9% 2.4% 2.1%
10 11.7% 11.3% 10.3% 9.3% 8.3% 7.4% 6.4% 5.5% 4.5% 3.6% 3.1%
20 21.5% 21.1% 19.7% 17.7% 15.7% 13.7% 11.7% 9.8% 7.8% 5.9% 4.8%
30 31.4% 30.9% 29.4% 27.0% 24.0% 21.0% 18.0% 15.1% 12.1% 9.2% 7.6%
40 41.2% 40.6% 39.2% 36.8% 33.4% 29.4% 25.4% 21.4% 17.4% 13.5% 11.3%
50 51.0% 50.4% 49.0% 46.6% 43.1% 38.7% 33.7% 28.7% 23.7% 18.8% 16.1%
60 60.8% 60.2% 58.8% 56.4% 52.9% 48.5% 43.1% 37.1% 31.1% 25.1% 21.8%
70 70.6% 69.9% 68.5% 66.1% 62.7% 58.3% 52.8% 46.4% 39.4% 32.4% 28.6%
80 80.4% 79.6% 78.2% 75.9% 72.5% 68.0% 62.6% 56.2% 48.8% 40.8% 36.4%
90 90.2% 89.3% 88.0% 85.6% 82.2% 77.8% 72.4% 66.0% 58.6% 50.1% 45.2%
100 95.1% 94.2% 92.9% 90.6% 87.2% 82.9% 77.6% 71.2% 63.8% 55.5% 50.4%

OpenQuest 3

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 50.0% 49.4% 47.1% 44.1% 40.2% 35.5% 30.0% 23.8% 16.6% 8.7% 0.0%
10 50.4% 50.0% 47.7% 44.6% 40.8% 36.1% 30.6% 24.3% 17.2% 9.3% 0.5%
20 52.7% 52.2% 50.0% 47.0% 43.1% 38.4% 32.9% 26.6% 19.5% 11.6% 2.8%
30 55.7% 55.3% 53.1% 50.1% 46.2% 41.5% 36.0% 29.7% 22.6% 14.6% 5.9%
40 59.6% 59.2% 57.0% 54.0% 50.2% 45.5% 40.0% 33.7% 26.5% 18.6% 9.8%
50 64.3% 63.8% 61.7% 58.7% 54.9% 50.3% 44.8% 38.4% 31.3% 23.3% 14.5%
60 69.8% 69.3% 67.2% 64.2% 60.4% 55.8% 50.3% 44.0% 36.8% 28.9% 20.1%
70 76.1% 75.7% 73.5% 70.5% 66.7% 62.1% 56.7% 50.4% 43.2% 35.2% 26.4%
80 83.2% 82.8% 80.6% 77.6% 73.8% 69.2% 63.8% 57.5% 50.4% 42.4% 33.6%
90 91.2% 90.7% 88.5% 85.6% 81.8% 77.1% 71.7% 65.5% 58.4% 50.5% 41.6%
100 100.0% 99.6% 97.4% 94.4% 90.6% 86.0% 80.5% 74.3% 67.2% 59.3% 50.5%

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 <= pct) then
        if p < math.ceil(p / 20) then
            return STATUS_CRITICAL
        elseif p < math.ceil(p / 5) then
            return STATUS_SPECIAL
        else
            return STATUS_SUCCESS
        end
    else
        local fumble = 100 - math.floor((100 - pct) / 20)
        if p == 100 or 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 == 100 then
        return STATUS_FUMBLE
    elseif p <= pct then
        if p == 1 then
            return STATUS_CRITICAL
        elseif p <= math.ceil(pct / 5) then
            return STATUS_HARD
        elseif p <= math.ceil(pct / 2) then
            return STATUS_EXTREME
        else
            return STATUS_SUCCESS
        end
    else
        if 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)
    if p >= 96 then
        -- 96 ... 100 is always a failure
        if p == 100 or (p == 99 and pct <= 100) then
            return STATUS_FUMBLE
        end
        return STATUS_FAILURE
    end

    if p <= pct or p <= 5 then
        -- 1 ... 5 is always a success
        if p <= math.ceil(pct / 10) then
            return STATUS_CRITICAL
        end
        return STATUS_SUCCESS
    else
        return STATUS_FAILURE
    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 > rstat 
        or (pstat == rstat and pstat >= STATUS_SUCCESS and p >= r)
end

-- Whether the roll is a critical success or critical failure (fumble)
-- in OpenQuest 3rd edition.
local function is_oq3_crit(r)
    return r % 11 == 0 or r == 100
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 <= pct then
        if is_oq3_crit(p) then
            return STATUS_CRITICAL
        else
            return STATUS_SUCCESS
        end
    else
        if is_oq3_crit(p) 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 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("%5.1f%% ", results[n] * 100))
    end
    table.insert(buf, "")
    return table.concat(buf, "|")
end

local function print_table(min, max, step)
    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},
    }

    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

print_table(0, 100, 10)

  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. ↩︎