The storyteller never holds the dice
Created by Short Hill Labs, LLC · Developer of Tabled · 30 October 2026
Short Hill Labs, LLC wrote these notes with AI help, per the team. Tabled is a platform Arcanum has reviewed. Contributing to the library does not affect its review or score.
An AI game master controls both the story and the dice, so it can bend either to fit the other, and a rule written in its prompt does not reliably stop it. This is how Tabled takes the numbers away from the model: dice sealed before the model runs, outcomes computed by our own code, and a step-by-step way to move any rule that matters out of the prompt and into the server.
The problem
Ask a language model to "roll a d20" and you get a number it chose. Even when it is trying to be fair, it can write the fiction to fit a number it wants, or a number to fit the fiction it wants. You cannot catch that by reading the prose.
The usual answer is a strong rule in the system prompt. It helps, and then a long campaign fills the context with story and the rule loses. In our game the model used the wrong ability modifier on about one check in seven, with the rule written plainly in its instructions.
Part 1. Seal the dice before the model runs
This is commit and reveal, an old idea from cryptography, applied to a narrator. Nothing in it is new, and that is the point.
- Seed. When a turn resolves, derive the seed from things fixed before anyone could game them: the turn's id (created when the turn opened), the table, the turn number, a public randomness beacon (we use drand), and a secret you committed (by its hash) when the turn opened. The beacon takes away the house's freedom over the dice. The secret takes away the players': every other input is public early, so without it a player who knows your derivation could compute the dice before declaring, and change what they declare to move them.
- Roll everything. Draw every die the turn could need, in a published order. That means each hero's d20s and damage dice, each spell's dice, and each enemy's attacks.
- Seal. Write the dice into a JSON manifest and save its SHA-256 on the turn, before the model is called. A die that should stay face-down rides as a salted hash, so it is bound. To keep it hidden after the turn lands as well, draw it from its own stream keyed by a hash of the secret and the hero, and publish that key only when the die turns face-up.
- Narrate. The model gets the dice and writes the scene. It cannot roll, reroll or choose.
- Open. When the turn lands, publish the manifest and the salts, and the secret once nothing is face-down. Anyone can re-hash it and get the fingerprint that existed before the outcome was known. Publish every draw in order too, so a checker can replay each die from the seed: that is what lets anyone see the beacon actually bound the dice. In our app a player taps "Check the seal" and the hashes re-run on their own phone.
The whole format, the exact derivation with test vectors, and a reference verifier you can run on a real turn are in the dice specification.
Part 2. Your code decides the outcome, not the model
Sealed dice stop the model from choosing the roll. They do not stop it from misreading the roll. So the rules engine recomputes every result from the sealed die, takes the modifier from the character sheet, and caps damage at what was actually rolled. The model's version is overwritten, quietly. A turn never fails because the model fumbled a number. It gets corrected and the story goes on.
Our public counters as of October 30, 2026, across every table: 3,429 checks rolled from sealed dice, every one recomputed by the server, 464 modifiers set back to what the sheet supports, 16 advantages the story forgot granted anyway, and 9 enemy wounds capped to the real dice. The live numbers are on how the dice stay fair.
Part 3. The ladder, for every other rule
Dice are the easy case. Most rules are fuzzier: "this enemy attacks twice", "a fallen hero who is stabilised does not die", "a refused quest stays refused". We learned to move each one up a ladder, one rung at a time.
- A rule in the system prompt. Cheap, and cached. It works until the context is long.
- A binding note in that turn's own input. Not "enemies attack per their stat block" in the rulebook, but "the wolf strikes twice this turn, at Mara and at Tam" in the turn itself. This lands far more often, because it is specific and it sits right next to the task.
- The server computes the number and overwrites. The model only narrates a fact it was given.
Two habits make the ladder work.
- Count every rung. Each rule gets a counter for how often the model got it wrong and how often the server had to step in. Read the counters after real play, and climb a rung only when the numbers say the current one is failing. Without counters you are guessing, and your test sessions will mostly look fine.
- Settle what you can on the server. If a result travels through the model's output on its way to your database, the model can lose it. We had a hero saved by a won Medicine check, narrated beautifully, and then killed by the death rules anyway, because the model wrote the healing onto the wrong character. Now the server reads the check and sets the hero stable itself.
What failed first
- Trusting a strong prompt. Every rule we care about started there, and most of them had to leave.
- Asking the model to report its own dice. It reported them honestly most of the time, which is exactly the problem. "Most of the time" is not something you can publish.
- Rejecting bad turns. Our first instinct was to refuse a turn with a wrong number. Players saw errors and waited. Correcting and carrying on is better for everyone.
- Green tests. Our scripted tests passed while real tables drifted. The live counters, not the tests, showed us the gaps.
What it does not prove
We run the server, so in principle we could run different code than we publish. Checking the published files on the player's own device limits that, and we publish the spread of check margins (a storyteller quietly rigging near misses would show a spike near zero), but the scheme cannot prove the house honest on its own. The specification says so too.
A worked example: a refused quest
Part 3 is general, so here is one rule that climbed the whole ladder. Players who turned down a quest kept getting it back, with news of it every turn and its symbols in every new storyline. A prompt rule did not hold. Neither did a server guess at whether a line was a refusal. What worked was a mix of rungs. The model gives the verdict in a shape the server can read, the server holds it from then on, and counters measure whether it comes back. That one has its own note: keeping a refused quest refused.
Source of the note above (original, may be newer): tabled.gg/notes/dice
The specification
Tabled · specification · manifest v8, October 2026.
This page specifies exactly how Tabled's dice are rolled, sealed, anchored, and verified: precisely enough that you can re-derive every die yourself, or build the same machinery. Nothing here is novel cryptography, on purpose: it is commit–reveal (Blum, 1981) plus a public randomness beacon (drand), assembled for a threat model that didn't exist until AI narrators did: the storyteller controls both the story and the dice.
1. The problem
A generative narrator asked to "roll dice" can retroactively write fiction to fit any numbers it likes, or write numbers to fit the fiction it wants. Auditing prose can't catch this. The fix is structural: the narrator must never hold the dice at all.
2. The scheme
- Open. When a round opens, the database mints a random 64-hex-character
round secret and stamps
veil_commit = sha256(secret)on the round row, which every member of the table can read from that moment. The round's id (a UUID) is fixed at the same instant. Both exist before the beacon the round will use. - Seed. When the round resolves, the server reads the first
drand round published after the round's secret was
committed (default chain, 30-second cadence, two mirrors), waiting for it if a round
resolves that fast and retrying both mirrors for about half a minute if they do not
answer, and builds the seed text
seed_id + ":" + party + ":" + round + ":" + beacon.randomness + ":" + mainwithmain = sha256(secret + ":main"), then the seedfnv1a32(seed text). Every input was fixed before the beacon existed, so the house holds no dice freedom; and becausemainstays private until the round lands, no player can compute a die while they can still change what they declare. - Roll. Every die the round could need (each hero's damage pool, a held
working's own dice, a declared spell's own dice, every standing foe's blows, a debut
reserve) is drawn from
mulberry32(seed), the main stream, and every draw is recorded on the manifest's draw tape. Each hero's three d20s come from their own keyed stream,mulberry32(fnv1a32(seed text + ":d20:" + hero id + ":" + key))withkey = sha256(secret + ":" + hero id), so no one can compute them before they open, and the house could not choose them either. - Seal. The dice are serialized into a JSON manifest. Each hero's d20s
ride as a salted commitment
sha256("d1|d2|d3|salt"). The manifest's own SHA-256 is stamped onto the round before the language model runs. - Interpret. The model writes the scene around dice it was handed. It cannot roll, reroll, or choose dice. A deterministic rules engine recomputes every outcome from the sealed die, sets modifiers from the character sheet, and clamps damage to what was actually rolled.
- Open. When the round lands, the manifest publishes, and so does every hero's opening (their d20s, salt and key). A veiled roll (a check whose result would spoil the story) keeps its hero's opening back until the veil turns. The round secret publishes once no veil of the round is left face-down.
3. The derivation, exactly
Tag fnv1a-mulberry32-v1. Every hash input is ASCII, so code units and bytes
agree.
- FNV-1a, 32-bit. Offset basis
2166136261, prime16777619; for each UTF-16 code unit: xor, then multiply mod 232. Result as an unsigned 32-bit integer. - Seed text.
seed_id:party:round, plus:randomnesswhen the manifest has abeacon, plus:mainwhen it hasveil.main(manifest v8).roundis a decimal integer, UUIDs are lowercase with dashes, the randomness is 64 lowercase hex. No beacon field means both mirrors failed through every retry, and the text simply stops after the round number (then:main). The round still resolves, so a table never stalls on a beacon outage, but the reference verifier flags it and /fair counts every such round. - mulberry32. As in the verifier below: 32-bit state, each call returns an unsigned 32-bit value divided by 232.
- A die. One call per die:
floor(u × sides) + 1. No rejection sampling; the bias this leaves on a d20 is under one part in 227. An expression like2d6+3is two such dice in order, plus 3. - Draw order. It depends on the table (who is a halfling, which weapon,
which foes stand), so rather than leave it to be inferred, the manifest lists every
main-stream die in order on its tape, lane by lane: per hero in action order (the order
the actions were submitted),
hero:<id>:step(three d20 positions the main stream steps past;hero:<id>:d20on a round with no secret, whose faces ARE that hero's d20s), then:damage(four weapon rolls, five under Savage Attacker, then six d8, a fighter's last a d10), then:workingand:spellwhen present; then per foefoe:<ref>:strikes(for each blow: a d20, its spare d20, a d8, the blow's damage dice, any rider's dice) and:saveswhen foes roll saves; thendebut(four d20). - A hero's d20s. Three dice from their keyed stream (or their
d20lane). Where the opening lists an index underluck, that die was a halfling's natural 1, and the next face from the same stream replaces it. - Side streams. Dice a round decides it needs only after the seal (a
blessing's d4, an initiative roll, a working the storyteller names) come from
fnv1a32(seed text + ":" + stream name), the name salted with the ids it concerns. The manifest names three (zones,working,initiative); the others areaim,heroaim,bless,concentration,rider,gift,stone,breath,stable,traitor,ensnare,ensnare-tick,ensnare-escape,falselifeandhealupcast. Their faces ride the roll cards; the reference verifier does not replay them. The death save is drawn next from the main stream, after the tape ends. - Width. Every stream starts from a 32-bit seed. That is narrow for a secret, but nothing here is secret by its width: the inputs carry a 256-bit round secret and a public beacon, and no one can test guesses against a sealed round.
4. The manifest format
{
"v": 8,
"zones": { "drv": "fnv1a-mulberry32-v1", "stream": "zones" }, …
"d20": { "drv": "fnv1a-mulberry32-v1", "stream": "d20:<character id>:<veil key>" },
"veil": { "commit": "<sha256 of the round secret>", "at": "<when the round opened>",
"main": "<sha256 of secret + ":main">" },
"party": "<party uuid>",
"round": 522,
"beacon": { "chain": "drand-default", "round": 4482091,
"randomness": "<64 hex chars>" },
"drv": "fnv1a-mulberry32-v1",
"seed_id": "<round uuid>",
"salt": "<manifest salt>",
"actions": [ { "id": "<hero uuid>",
"d20c": "sha256("d1|d2|d3|salt")",
"damage": [9,7,8,6,3,5,1,8,2,4],
"spell": { "name": "Fireball", "rolls": [4,6,2,5,1,3,6,2] } } ],
"strikes": [ { "name": "the Rime-Crowned Wyrm", "strike": "<the sealed blow, as the storyteller read it>" } ],
"debut": [14,3,19,8],
"tape": [ { "lane": "hero:<id>:step", "dice": [[20,7],[20,15],[20,2]] },
{ "lane": "hero:<id>:damage", "dice": [[8,6],[8,4],[8,5],[8,3],[8,3],…] },
{ "lane": "foe:<ref>:strikes", "dice": [[20,11],[20,4],[8,2],[6,5],[6,1]] },
{ "lane": "debut", "dice": [[20,14],[20,3],[20,19],[20,8]] } ]
}
A damage pool entry is a sum: the weapon's dice from its lane plus the
weapon's modifier from the character sheet (each die floored at 3 under Great Weapon
Fighting). veil.at is an ISO 8601 timestamp with microseconds and an offset
(2026-10-30T22:41:07.123456+00:00); trim it to milliseconds before parsing.
veil and d20 are absent on a round opened before October 30,
2026, which draws its d20s from the main stream; veil.main is absent before
manifest v8. Openings land beside the
manifest as { "v": 2, "opened": [ { "id", "d20": […], "salt", "key", "luck"? } ],
"secret"? } (the openings carry their own format number).
5. Verify one round
From the app: tap under any round → Check the seal. To re-derive every die
yourself, take the round's manifest, openings and seal (a table member can read them from
the round's record: dice_manifest, dice_openings,
dice_hash), and run the reference verifier below in Node 20+, Deno, or a
browser console:
const r = await verifyRound(manifestText, openingsText, sealHex, openCommit);
console.log(r.lines.join("\n")); // every check, ok / FAIL / wait / warn
openCommit is the round's veil_commit as you recorded it when
the round opened; leave it out and the commitment is checked only against itself. The
app records it for you: each device keeps the first commitment it sees for an open round,
with the time, and Check the seal compares the published manifest against that
record.
It checks, in order: the manifest re-hashes to the seal; the commitment matches the one
you recorded; every hero has their d20 and damage lanes; every die on the tape is the
main stream's own face at that position; the manifest body's working, spell and debut dice
match the tape (a damage pool is a sum and a foe strike is prose, so those are read
against their lanes by eye); each
opened hero's dice hash to their commitment and re-derive from the seed and their key; each
key, and the main key, derives from the round secret, the secret hashes to the commitment,
that commitment was made before the beacon round was published (drand time is
1595431050 + (round − 1) × 30), and the beacon is the first one after it.
Last, fetch the beacon and compare:
curl -s https://api.drand.sh/public/<beacon.round>
That trusts the relay. To check the beacon itself, verify its BLS signature against the default chain's public key with any drand client library, which does exactly that for a round number; a round the relays had forged would fail it.
// Tabled dice verifier: reference implementation of fnv1a-mulberry32-v1
// for manifests v7 and v8. Public domain; no dependencies.
// Usage: const r = await verifyRound(manifestText, openingsText, sealHex, openCommit);
// (openCommit: the round's veil_commit as you recorded it at round
// open; optional, but without it the commitment is the house's word)
// console.log(r.lines.join("\n"), r.ok); // ok / FAIL / wait / warn
// FNV-1a, 32-bit: offset basis 2166136261, prime 16777619, over the
// string's UTF-16 code units (every seed input is ASCII, so: its bytes).
function fnv1a32(s) {
let h = 2166136261;
for (let i = 0; i < s.length; i++) {
h ^= s.charCodeAt(i);
h = Math.imul(h, 16777619);
}
return h >>> 0;
}
// mulberry32: 32-bit state, each call a float in [0, 1).
function mulberry32(seed) {
let a = seed >>> 0;
return () => {
a |= 0;
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// One die: one call, floor(u * sides) + 1. No rejection sampling.
const rollDie = (rng, sides) => Math.floor(rng() * sides) + 1;
async function sha256hex(s) {
const b = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(s));
return [...new Uint8Array(b)].map((x) => x.toString(16).padStart(2, "0")).join("");
}
// The round's seed text: seed_id, party and round joined by ":", then
// ":" + the beacon's randomness when the manifest carries a beacon, then
// ":" + the main key when it carries one (v8, a keyed round).
function seedText(m) {
return m.seed_id + ":" + m.party + ":" + m.round +
(m.beacon ? ":" + m.beacon.randomness : "") +
(m.veil && m.veil.main ? ":" + m.veil.main : "");
}
const DRAND_GENESIS = 1595431050; // drand default chain, 30 s period
const same = (a, b) => JSON.stringify(a ?? []) === JSON.stringify(b ?? []);
async function verifyRound(manifestText, openingsText, sealHex, openCommit) {
const lines = [];
let ok = true;
const pass = (s) => lines.push("ok " + s);
const fail = (s) => { ok = false; lines.push("FAIL " + s); };
const wait = (s) => lines.push("wait " + s);
try {
await checkRound(manifestText, openingsText, sealHex, openCommit, lines, pass, fail, wait);
} catch (e) {
fail("the manifest or openings could not be read: " + (e && e.message ? e.message : e));
}
return { ok, lines };
}
async function checkRound(manifestText, openingsText, sealHex, openCommit, lines, pass, fail, wait) {
// 1. The seal: the manifest, byte for byte, hashes to the stamp.
if (sealHex) {
(await sha256hex(manifestText)) === sealHex.toLowerCase()
? pass("manifest re-hashes to the seal")
: fail("manifest does not re-hash to the seal");
} else {
wait("no seal given: the manifest was not checked against the round's stamp");
}
const m = JSON.parse(manifestText);
if (!(m.v >= 7) || !Array.isArray(m.tape)) {
fail("manifest v" + m.v + " has no draw tape: only the hashes can be checked");
return;
}
const text = seedText(m);
const seed = fnv1a32(text);
if (m.v >= 8 && !m.veil) fail("v8 manifest without a veil key: every v8 round is keyed");
if (!m.veil && openCommit) fail("the round was committed at open, but its manifest has no veil key");
if (!m.veil) {
lines.push("warn no round secret: a player who knew the derivation could have " +
"computed these dice before declaring (rounds opened before October 30, 2026)");
}
if (m.v >= 8 && m.veil && !m.veil.main) fail("v8 keyed round without a main key");
if (m.veil && openCommit) {
String(openCommit).toLowerCase() === m.veil.commit
? pass("commitment matches the one recorded at round open")
: fail("commitment differs from the one recorded at round open");
} else if (m.veil) {
wait("commitment not compared with one recorded at round open (pass it as openCommit)");
}
// Every hero has their lanes: the d20 positions and the damage pool.
for (const a of m.actions ?? []) {
const d20s = m.tape.find((l) => l.lane === "hero:" + a.id + (m.veil ? ":step" : ":d20"));
const dmg = m.tape.find((l) => l.lane === "hero:" + a.id + ":damage");
if (!d20s || d20s.dice.length < 3 || !d20s.dice.every((d) => d[0] === 20)) {
fail(a.id.slice(0, 8) + " has no d20 lane of three d20s on the tape");
}
if (!dmg || dmg.dice.length === 0) fail(a.id.slice(0, 8) + " has no damage lane on the tape");
}
if (!m.tape.every((l) => Array.isArray(l.dice) &&
l.dice.every((d) => Number.isInteger(d[0]) && d[0] >= 2 && d[0] <= 100 &&
Number.isInteger(d[1]) && d[1] >= 1 && d[1] <= d[0]))) {
fail("the tape holds a die that is not a real die (sides 2 to 100, face 1 to sides)");
}
// 2. The tape: every die the main stream drew, in order, is the
// stream's own face at that position.
const rng = mulberry32(seed);
let n = 0, bad = 0;
for (const lane of m.tape) {
for (const [sides, face] of lane.dice) {
n++;
if (rollDie(rng, sides) !== face) bad++;
}
}
bad === 0
? pass("all " + n + " main-stream dice replay from seed " + seed)
: fail(bad + " of " + n + " main-stream dice do not replay from seed " + seed);
// 3. The raw lanes the manifest body repeats agree with the tape.
const lane = (name) => (m.tape.find((l) => l.lane === name)?.dice ?? []).map((d) => d[1]);
for (const a of m.actions ?? []) {
if (a.working) {
same(a.working.rolls, lane("hero:" + a.id + ":working"))
? pass(a.id.slice(0, 8) + " working dice match the tape")
: fail(a.id.slice(0, 8) + " working dice differ from the tape");
}
if (a.spell) {
same(a.spell.rolls, lane("hero:" + a.id + ":spell"))
? pass(a.id.slice(0, 8) + " spell dice match the tape")
: fail(a.id.slice(0, 8) + " spell dice differ from the tape");
}
}
if (m.debut) {
same(m.debut, lane("debut")) ? pass("debut dice match the tape") : fail("debut dice differ from the tape");
}
// 4. Each opened hero: the dice hash to their commitment, and the dice
// re-derive. Keyed round: from the hero's own stream. Unkeyed: from
// their d20 lane on the tape. "luck" names pool indices where a
// halfling's natural 1 drew a reroll (the next face replaces it).
const o = openingsText ? JSON.parse(openingsText) : { opened: [] };
const opened = new Map((o.opened ?? []).map((x) => [x.id, x]));
for (const a of m.actions ?? []) {
const id = a.id.slice(0, 8);
const op = opened.get(a.id);
if (!op) { wait(id + " still face-down (a veiled roll): it opens when the veil turns"); continue; }
(await sha256hex(op.d20.join("|") + "|" + op.salt)) === a.d20c
? pass(id + " opened dice hash to their commitment")
: fail(id + " opened dice do not hash to their commitment");
let next;
const bound = !m.veil || !!o.secret;
if (m.veil) {
if (!op.key) { fail(id + " keyed round, but the opening has no key"); continue; }
const own = mulberry32(fnv1a32(text + ":d20:" + a.id + ":" + op.key));
next = () => rollDie(own, 20);
} else {
const faces = lane("hero:" + a.id + ":d20");
next = () => faces.shift();
}
const luck = new Set(op.luck ?? []);
const pool = [];
for (let i = 0; i < op.d20.length; i++) {
let f = next();
if (luck.has(i)) f = f === 1 ? next() : NaN;
pool.push(f);
}
if (!same(pool, op.d20)) {
fail(id + " d20s do not re-derive: got " + pool.join(",") + ", opened " + op.d20.join(","));
} else if (bound) {
pass(id + " d20s re-derive from the seed" + (m.veil ? " and their key" : ""));
} else {
wait(id + " d20s re-derive from their key, but the key is bound to the commitment " +
"only once the round secret publishes");
}
if (m.veil && o.secret) {
(await sha256hex(o.secret + ":" + a.id)) === op.key
? pass(id + " key derives from the round secret")
: fail(id + " key does not derive from the round secret");
}
}
// 5. The secret: committed when the round opened, before the beacon.
if (m.veil) {
if (o.secret) {
(await sha256hex(o.secret)) === m.veil.commit
? pass("round secret hashes to the commitment made at round open")
: fail("round secret does not hash to its commitment");
if (m.veil.main) {
(await sha256hex(o.secret + ":main")) === m.veil.main
? pass("main key derives from the round secret")
: fail("main key does not derive from the round secret");
}
} else {
wait("round secret publishes when the round's last veil turns");
}
if (m.beacon) {
const beaconAt = DRAND_GENESIS + (m.beacon.round - 1) * 30;
const committedAt = Date.parse(String(m.veil.at).replace(/(\.\d{3})\d+/, "$1")) / 1000;
const first = Math.floor((committedAt - DRAND_GENESIS) / 30) + 2;
if (!(committedAt < beaconAt)) {
fail("secret was committed after the beacon it is mixed with");
} else if (m.beacon.round !== first) {
fail("beacon " + m.beacon.round + " is not the first after the commitment (" + first + ")");
} else {
pass("secret committed " + Math.round(beaconAt - committedAt) +
" s before the beacon existed, and the beacon is the first after it");
}
}
}
// 6. The beacon. This compares against a relay; to check the beacon
// itself, verify its BLS signature with a drand client (see the spec).
if (m.beacon) {
wait("beacon: compare randomness with https://api.drand.sh/public/" + m.beacon.round +
" (or check its signature with a drand client)");
} else {
lines.push("warn no beacon: no drand mirror answered, so this " +
"round's seed is its identity" + (m.veil ? " and veil keys" : "") +
" only. The dice still replay and match the seal, but no public randomness " +
"bound them. Rounds like this are counted on tabled.gg/fair.");
}
}
6. Test vectors
Inputs and the outputs any correct implementation must produce.
u is each raw mulberry32 output times 232.
fnv1a32("") = 2166136261
fnv1a32("a") = 3826002220
seed text: 11111111-1111-4111-8111-111111111111:22222222-2222-4222-8222-222222222222:7
fnv1a32 = 1383934312
u = 2329461047, 2899305605, 2139454796, 3515870596
8 d20 = 11, 14, 10, 17, 1, 8, 16, 11
sides 20,20,20,8,6,10,4,12 = 11, 14, 10, 7, 1, 4, 4, 7
seed text: 11111111-1111-4111-8111-111111111111:22222222-2222-4222-8222-222222222222:7:a7f2b1c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f
fnv1a32 = 2469237860
u = 758864207, 4070785630, 1854790590, 3274136652
8 d20 = 4, 19, 9, 16, 17, 2, 2, 12
sides 20,20,20,8,6,10,4,12 = 4, 19, 9, 7, 6, 1, 1, 7
round secret: 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
sha256(secret) = a8ae6e6ee929abea3afcfc5258c8ccd6f85273e0d4626d26c7279f3250f77c8e
main = sha256(secret + ":main")
= 8dd8582bd2be96118e1923a8ef9c6dcf15c0630ad5e7648bca3481c186963a62
keyed seed text (the second stream above + ":" + main)
fnv1a32 = 2133104797
4 d20 = 15, 18, 8, 7
key = sha256(secret + ":33333333-3333-4333-8333-333333333333")
= a3b0a448bf1a65df5d77488cb302917c6cbb6594e4e970501d1330e038fa8f46
hero stream text (keyed seed text + ":d20:" + hero + ":" + key)
fnv1a32 = 3902671097
3 d20 = 3, 12, 15
7. What this proves, and what it doesn't
| Claim | Status |
|---|---|
| No die was altered after outcomes were known | Proved by commitment: re-hashing the opened manifest must reproduce the fingerprint stamped on the round before the model ran. The timing is witnessed only by someone who read the stamp then; the replay below binds the dice either way. |
| The dice came from the seed, not from the house | Proved by replay: every die on the tape, and every opened d20, re-derives from the seed text with the code above. |
| The dice weren't chosen to fit a desired story | Bound by the beacon: the seed depends on public randomness outside our control, and the round id and secret were fixed at round open, before that beacon existed. Choosing dice would mean shopping across 30-second beacon windows, and a keyed round has no window to shop: its beacon is the first one after its commitment, so a resolution that fails and re-runs re-seals the same dice. One exception: a beacon reachable on one attempt and not on another yields two sets of dice, which is why every beaconless round is counted on /fair. The commitment itself is only as good as its witness: every player's app records it when the round opens, before the beacon if they are looking, and the seal check compares against that record. |
| No player could foresee the dice while declaring | The seed mixes in a key derived from the round secret, which no player holds until the round lands, so knowing every other input in advance is not enough to compute a die, and changing a declaration to move the lanes is a blind guess. |
| Every round's dice used the beacon | Not guaranteed, and counted. If neither drand mirror answers through
about half a minute of retries, the round resolves without one rather than stall
the table. Such a round's dice are bound only by inputs the house fixed itself, so
it carries no guarantee against the house. Its manifest has no beacon,
the reference verifier flags it, and /fair publishes how many
rounds have gone that way. |
| A veiled roll stays hidden until it turns | Its d20s come from a stream keyed by a secret no player holds until the veil turns, so knowing the derivation and every published input is not enough to compute it early. |
| The tape's shape is the round's true shape | Checkable by the table, not by the tape alone. The tape says which dice were drawn; a player who knows their own table (who is a halfling, which weapons, which foes) can confirm each lane is the shape section 3 gives. |
| The narration matches the dice | Enforced by the rules engine (outcomes recomputed, damage clamped) and published as live counters on /fair. |
| The house is honest about all of the above | Not provable by this scheme alone. We run the server, so we could serve different code than specified; the published artifacts are what pin us, and distribution-level mischief would surface in the public margin histogram rather than any single round. |
8. Changelog
- v1: commitment (July 2026): manifest + SHA-256 stamp before the model runs.
- v2. Per-hero commitments (Aug 2026): salted d20 commitments, so veiled rolls stay bound; manifests always publish at commit.
- v2 + beacon (Aug 24, 2026): seeds anchored to drand; derivation tag and seed identity published in the manifest.
- v7: the draw tape and veil keys (Oct 30, 2026): every draw published in order, the exact derivation, a reference verifier and test vectors; heroes' d20s keyed by a secret committed at round open, with the beacon pinned to the first one after that commitment.
- v8: the main key (Oct 30, 2026): the main stream and every side
stream mix in
sha256(secret + ":main"), published with the manifest.
Source (original, may be newer): tabled.gg/fair/spec
By Short Hill Labs, LLC, makers of Tabled. Published under CC BY 4.0: share and adapt it freely, crediting "Short Hill Labs, LLC". The other entry: Keeping a refused quest refused. Tabled is made by Short Hill Labs LLC, a Virginia limited liability company. Game rules derive from the System Reference Documents 5.1 and 5.2.1, licensed under CC-BY-4.0; Tabled is unofficial and not affiliated with or endorsed by Wizards of the Coast. Arcanum has not tested or audited these claims. Being in the library does not affect any review or score. Licence information on this page is a reference, not legal advice.