Dice Pools Explored

Posted: 2022-09-20
Word Count: 965
Tags: dice lua-code rpg third-system writing-rpgs

OK, I thought I was done with this, but I’m not.

Difficulty

The system as written gives the GM two ways to alter probabilities:

  1. Bonuses and Penalties alter the size of a Dice Pool.

  2. Target Numbers alter the probability of a Marginal Success on a Dice Pool of a given size.

This may prove confusing, so I’d suggest this:

  1. Add or remove dice from the Dice Pool based on external factors like equipment used (or not used), lighting, range to a target (if in ranged combat), etc.

  2. Alter the Target Number based on the intrinsic difficulty of the task, i.e. how unusual the task is and/or what happens if it fails. When in doubt, use Normal (5).

As always, the GM’s ruling is final.

Marginal Successes

I added “Marginal Successes” – a 4 or 5 counted as barely a success – partly as a replacement for Pushing a Roll in YZE, and partly in imitation of rolls in Blades in the Dark.

If we eliminate the category and treat all Successes equally, all dice rolls would be Pass/Fail. Each combat round would deal damage either to the Heroes or their adversaries; NPCs would take more damage than the old system, and need more HP.

Moreover, the chances of a Critical Success – more than one die meets or exceeds the Target Number – varies with the Target Number. In combat Heros would do damage more often if the Target Number is 5 or 4, which might mean adjusting the opponents’ HP to compensate.

Using the program below, I’ve calculated how the target number affects the number of Successes.

Dice 4 5 6 4x2 5x2 6x2 4x3 5x3 6x3
0d 25.00% 11.11% 2.78%
1d 50.00% 33.33% 16.67%
2d 75.00% 55.56% 30.56% 25.00% 11.11% 2.78%
3d 87.50% 70.37% 42.13% 50.00% 25.93% 7.41% 12.50% 3.70% 0.46%
4d 93.75% 80.25% 51.77% 68.75% 40.74% 13.19% 31.25% 11.11% 1.62%
5d 96.88% 86.83% 59.81% 81.25% 53.91% 19.62% 50.00% 20.99% 3.55%
6d 98.44% 91.22% 66.51% 89.06% 64.88% 26.32% 65.62% 31.96% 6.23%
7d 99.22% 94.15% 72.09% 93.75% 73.66% 33.02% 77.34% 42.94% 9.58%
8d 99.61% 96.10% 76.74% 96.48% 80.49% 39.53% 85.55% 53.18% 13.48%
9d 99.80% 97.40% 80.62% 98.05% 85.69% 45.73% 91.02% 62.28% 17.83%
10d 99.90% 98.27% 83.85% 98.93% 89.60% 51.55% 94.53% 70.09% 22.48%
11d 99.95% 98.84% 86.54% 99.41% 92.49% 56.93% 96.73% 76.59% 27.32%
12d 99.98% 99.23% 88.78% 99.68% 94.60% 61.87% 98.07% 81.89% 32.26%

If we eliminate Marginal Successes I’d also want to eliminate variable Target Numbers for simplicity. If we fix the Target Number at 5, we get the following probabilities, extended to 6 successes:

Dice 1+ 2+ 3+ 4+ 5+ 6+
0d 11.11%
1d 33.33%
2d 55.56% 11.11%
3d 70.37% 25.93% 3.70%
4d 80.25% 40.74% 11.11% 1.23%
5d 86.83% 53.91% 20.99% 4.53% 0.41%
6d 91.22% 64.88% 31.96% 10.01% 1.78% 0.14%
7d 94.15% 73.66% 42.94% 17.33% 4.53% 0.69%
8d 96.10% 80.49% 53.18% 25.86% 8.79% 1.97%
9d 97.40% 85.69% 62.28% 34.97% 14.48% 4.24%
10d 98.27% 89.60% 70.09% 44.07% 21.31% 7.66%

Also, I’d rule that combat proceeds in two phases, the Heroes turn and the Hordes’ turn, and that Hordes always do Damage as long as they still have HP. (Rivals and Heroes would still roll simultaneously, and only do damage if they roll well.) This modification would effectively make combat closely resemble the Arkham Horror board game.

Program

This program generated the tables above and in the previous article.

#!/usr/bin/env lua

local NSIDES = 6

-- Chance of rolling `t` or better on one die 
local function success_1(t)
    if t <= 1 then
        return 1.0
    elseif t > NSIDES then
        return 0.0
    else
        return (NSIDES + 1 - t) / NSIDES
    end
end

-- Special rule for dice pool <= 0:
-- roll 2 dice, get `t` or higher on BOTH

local function success_0(t)
   local p = success_1(t)
   return p * p
end

-- Classic choose function

local function choose(n, k)
    if n < 1 or k < 0 or k > n then
        return 0
    end

    local result = 1
    for i = 1, k do
        result = result * (n + 1 - i) / i
    end
    return result
end

-- Classic binomial function

local function binomial(n, k, p)
    return choose(n, k) * (p)^k * (1-p)^(n-k)
end

-- chance of rolling at least `k` successes on `n` dice
-- with probability `p`
local function success_at_least(n, k, p)
    if k <= 0 then
        return 1.0
    end

    if k == 1 then
        return 1 - (1 - p)^n
    end

    local result = 0
    for i = k, n do
        result = result + binomial(n, i, p)
    end
    return result
end

-- Chances of rolling `t` or better at least `k` times on `n` 6-sided dice.
local function success(n, k, t)
    if n < 1 then
        if k > 1 then
           return 0.0
        end
        return success_0(t)
    else
        return success_at_least(n, k, success_1(t))
    end
end

local function format_col(v)
    local k,t = table.unpack(v) 
    if k == 1 then
        return string.format(" %-7d ", t)
    else
        return string.format(" %dx%-5d ", t, k)
    end
end

local function format_percent(p)
    if p == 0 then
        return string.rep(" ", 9)
    end
    return string.format(" %6.2f%% ", p * 100)
end

local function print_header(cols)
    local rowbuf

    rowbuf = { "Dice " }

    for i,v in ipairs(cols) do
        table.insert(rowbuf, format_col(v))
    end

    table.insert(rowbuf, "")
    print(table.concat(rowbuf, '|'))

    rowbuf = { ":---:" }
    for d = 1, #cols do
        table.insert(rowbuf, "--------:")
    end

    table.insert(rowbuf, "")
    print(table.concat(rowbuf, '|'))
end

local function print_row(n, cols)
    local rowbuf = { string.format(" %2dd ", n) }

    for i,v in ipairs(cols) do
        local k,t = table.unpack(v) 
        table.insert(rowbuf, format_percent(success(n,k,t)))
    end

    table.insert(rowbuf, "")
    print(table.concat(rowbuf, '|'))
end

local function print_table(maxdice, maxsuccess)
    local md, ms = (maxdice or 10), (maxsuccess or 3)

    local cols = {}

    for i = 1, ms do
        for j = 4,6 do
            table.insert(cols, {i,j})
        end
    end

    print_header(cols)

    for n = 0, md do
       print_row(n, cols)
    end
end

print_table(12)