Twilight 2000 Dice

Posted: 2022-11-12
Last Modified: 2024-04-03
Word Count: 1025
Tags: dice lua-code rpg

2024-04-03: Changed the program and output in response to a question on Discord. The probabilities and commentary are unaltered.

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 0 (1-5) 1 (6-9) 2 (10+)
d6 83.33% 16.67%
d8 62.50% 37.50%
d10 50.00% 40.00% 10.00%
d12 41.67% 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

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 nsuccess == 0 then
        -- 1..5
        return 5 / nsides
    elseif nsuccess == 1 then
        if nsides == 6 then
            -- 6
            return 1/6
        elseif nsides == 8 then
            -- 6, 7, 8
            return 3/8
        elseif nsides == 10 or nsides == 12 then
            -- 6, 7, 8, 9
            return 4/nsides
        end
    elseif nsuccess == 2 then
        if nsides == 10 then
            -- 10
            return 1/10
        elseif nsides == 12 then
            -- 10, 11, 12
            return 3/12
        end
    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 | 0 (1-5) | 1 (6-9) | 2 (10+)"
print "----|--------:|--------:|--------:|"

for i = 1, NDICE do
    local d = DICE_SIDES[i]
    print(string.format("d%-2d |  %s |  %s |  %s |", 
              d, 
              fmtpct(dprob_exact(d, 0)), 
              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. ↩︎