Twilight 2000 Dice

Posted: 2022-11-12
Word Count: 969
Tags: dice lua-code rpg

Last year Free League Publishing released Twilight 2000. They’re the makers of Coriolis, Vaesen, Mutant Year Zero, and other Year Zero Engine games, about which I’ve probably written a little too much.

T2K is based on a 1984 post-WWIII survival RPG from GDW1, with roughly the same setting but different mechanics superficially similar to their YZE games:

As the die mechanics differ significantly from the usual YZE pool of d6s, I inevitably wrote a Lua program to understand the probabilities.

The probabilities for a single die are pretty straightforward:

die 1 2
d6 16.67%
d8 37.50%
d10 40.00% 10.00%
d12 33.33% 25.00%

I was slightly surprised that a single success is less common on a d12 than on a d10, but I guess I shouldn’t have been.

Slightly more surprising was the probabilities for rolling two dice. Below are all the unique combinations of dice.2

dice 1+ 2+ 3+ 4
d6 d6 30.56% 2.78%
d6 d8 47.92% 6.25%
d6 d10 58.33% 16.67% 1.67%
d6 d12 65.28% 30.56% 4.17%
d8 d8 60.94% 14.06%
d8 d10 68.75% 25.00% 3.75%
d8 d12 73.96% 37.50% 9.38%
d10 d10 75.00% 35.00% 9.00% 1.00%
d10 d12 79.17% 45.83% 15.83% 2.50%
d12 d12 82.64% 54.86% 22.92% 6.25%

Surprisingly, rolling two d8s has a lower probability of any successes than rolling a d6 and a d12. Likewise rolling 2 successes is less probable and rolling 3 successes is literally impossible. We see a similar effect in the 2+ column, where two d10s are less likely to roll 2+ successes than a d8 and a d12.

So I guess the lesson is that you’re better off increasing all your Skills by two grades than trying to improve base Attributes, or something. Which almost makes sense, but the quirks of game mechanics intruding into the setting’s “natural laws” always irks me somehow. Yes, in a game we have to represent complex phenomena with numbers and simple rules. But it always seemed like the bad kind of fourth wall breaking when characters in an RPG discard their mere +1 sword for a +2 sword, or talk about how many HP and MP they have left, or (more to the point) improve their characters solely to min-max their dice rolls.

Program

Note that I’m using a “new” (to me) feature of Lua 5.4, relased in … 2020. <const> marks a local variable as a constant3; attempting to assign a new value afterwards causes a compiler4 error. It’s probably not necessary in such a short script, but it’s better to get in the habit of using it.

#!/usr/bin/env lua-5.4

local DICE_SIDES <const> = { 6, 8, 10, 12 }

local NDICE <const> = #DICE_SIDES

--[[
Chance of exactly `nsuccess` successes on a `nsides`-sided die,
where the number of successes depends on the value on the die:
 * (1  .. 5)  => 0 successes
 * (6  .. 9)  => 1 success
 * (10 .. 12) => 2 successes
]]
local function dprob_exact(nsides, nsuccess)
    if nsides < 6 then
        if nsuccess == 0 then
            return 1
        else
            return 0
        end
    end
    if nsuccess == 0 then
        return 5 / nsides
    elseif nsuccess == 1 then
        if nsides > 9 then
            return 4 / nsides
        end
        return (nsides - 5) / nsides
    elseif nsuccess == 2 and nsides > 9 then
        return (nsides - 9) / nsides
    end
    return 0
end

-- Maximum successes on an `nsides`-sided die
local function max_success(nsides)
    if nsides >= 10 then
        return 2
    elseif nsides >= 6 then
        return 1
    else
        return 0
    end
end

-- Chance of at least `nsuccess` successes on two `ns1`- and `ns2`-sided dice
local function success_atleast(ns1, ns2, nsuccess)
    if nsuccess < 0 or nsuccess > 4 then
        return 0
    elseif nsuccess == 0 then
        return 1
    end
    local result = 0
    for i = nsuccess, (max_success(ns1) + max_success(ns2)) do
        for j = 0, i do
            result = result + dprob_exact(ns1, i - j) * dprob_exact(ns2, j)
        end
    end
    return result
end

local function fmtpct(p)
    if p == 0 then
        return "      "
    end
    return string.format("%5.2f%%", p * 100)
end

-- Print success probabilities for each die

print "die | 1      | 2"
print "----|-------:|-------:|"

for i = 1, NDICE do
    local d = DICE_SIDES[i]
    print(string.format("d%-2d | %s | %s |", 
              d, 
              fmtpct(dprob_exact(d, 1)), 
              fmtpct(dprob_exact(d, 2)) 
          ))
end

-- Print success probabilities for unique pairs of two dice

print ""
print "dice    | 1+     | 2+     | 3+     | 4"
print "--------|-------:|-------:|-------:|-------:|"

for i = 1, NDICE do
    for j = i, NDICE do
        local low = DICE_SIDES[i]
        local high = DICE_SIDES[j]
        print(string.format("d%-2d d%-2d | %s | %s | %s | %s |", 
                  low, 
                  high,
                  fmtpct(success_atleast(high, low, 1)), 
                  fmtpct(success_atleast(high, low, 2)), 
                  fmtpct(success_atleast(high, low, 3)), 
                  fmtpct(success_atleast(high, low, 4)) 
              ))
    end
end

  1. A now (mostly?) defunct company that once published RPGs and board games, notably Traveller. Wikipedia has more. ↩︎

  2. I.e. rolling a d8 and a d6, for example, has the same probabilities no matter which is the Attribute and which the Skill. ↩︎

  3. Probably for optimization purposes. Lua was intended as a script engine embedded in larger programs, and some 5.4 changes seem aimed at more efficient use of resources, e.g. garbage collection optimizations, finalizing external resources promptly (e.g. the <close> attribute), and moving string to number conversions out of the core interpreter. ↩︎

  4. Lua scripts, like most modern scripting languages, are compiled into an internal bytecode before executing. There’s even a standalone program luac that creates binary “chunks” from script text. ↩︎