Appearance
Dice fairness
Nobody should be able to predict the next roll or steer it. That includes the server. This page describes the protocol new matches use, hmac-sha256/hashchain-reveal-v2, exactly as src/core/fairness.js, src/core/match.js and src/server/actor.js implement it. It also covers the older protocol, so you can see what v2 fixed, and it states plainly what v2 does not defend against.
The problem with the first design
The first protocol (hmac-sha256/commit-reveal-v1) was a three-party commit-reveal. The server and both players each chose a 32-byte secret and published a hash of it before the first roll. Every roll was then
HMAC-SHA256(serverSeed, "gammonchain-dice-v1|matchId|gameNo|rollIndex|n0|n1")and all three secrets were revealed at the end.
Nobody could bias a roll. But the server held its own seed the whole time. Once it learned the players' nonces, it could compute every remaining roll of the match in advance. It could not change a die, but it knew the future. The code comments point out that this is enough to abort a match selectively or to feed a bot. v2 removes that residual.
Records made under v1 still verify. The verifier reads the header's dice field and replays whichever protocol the match was played under. A record without the field is treated as v1.
The v2 protocol: reverse hash chains
Ingredients
Each player's reveal chain. Before a match, the browser chooses a random 32-byte secret s and builds a reverse hash chain of length 4000 (buildRevealChain):
v[4000] = s
v[k-1] = H(v[k]) where H(x) = SHA-256("gammonchain-chain-v1:" ‖ x)Only the tip v[0] is published. Links are revealed in order v[1], v[2], ..., and each new link is accepted only if hashing it gives the previous one. Because SHA-256 cannot be run backwards, knowing v[k] tells nobody anything about v[k+1]. Because the chain was fixed before play, a player cannot choose a convenient link either. According to the code, 4000 links is enough for the longest 21-point match with room to spare, since a full 21-pointer is well under 1500 rolls per player.
The server's seed. The server chooses a random 32-byte seed and publishes a commitment to it:
commit = SHA-256("gammonchain-commit-v1:" ‖ seed)(All hashes here are over the hex string of the value, with the domain prefix in front.)
Turning an HMAC into dice. diceFrom(seed, base) computes HMAC-SHA256(seed, base ‖ "|" ‖ ctr) for ctr = 0, 1, ... and walks the output bytes. Bytes of 252 or above are thrown away, because 252 is the largest multiple of 6 below 256 and keeping the rest would skew the mapping. Each kept byte becomes (byte mod 6) + 1. The first two kept bytes are the two dice.
The sequence
Step by step:
Commit. Both tips and the seed commitment go into the match header. Both clients sign
h0, the hash of that header, before revealing any link. That order stops the server from choosing its seed with knowledge of anything a player will contribute. Each client also checks that the header carries its own tip.Per game. At the start of every game, both players reveal their next link. Those two links,
g₀andg₁, seed that game's opening roll:HMAC-SHA256(seed, "gammonchain-dice-v2|matchId|gameNo|rollIndex|g₀|g₁|open")If the two dice are equal, the derivation moves to the next
rollIndexuntil they differ. So even the opening roll is unknown to the server until both players have revealed. This costs one round trip per game, never per roll.Per roll. The player about to roll sends their next link
xwith the roll request:HMAC-SHA256(seed, "gammonchain-dice-v2|matchId|gameNo|rollIndex|g₀|g₁|player|x")The server first checks that
H(x)equals that player's previous link, back to the tip in the header. There is no extra round trip: the link rides along on the roll request the player was sending anyway.Reveal. When the match ends, the server reveals
seed. Every player link is already in the signed event log (a.xonnewgameandrollevents), so anyone can re-derive every die.
The server checks every link twice: offerEntropy() rejects a bad link before it reaches the state machine, and Match._consumeLink() checks again. test/run.js measures that changing only the seed, or only the player's link, changes the roll in more than 90% of trials, so each input really drives the result. It also shows that the verifier rejects a substituted reveal.
A chain is used for only one match
Once one link of a chain has been revealed, reusing that chain would let an opponent predict your contribution to later rolls. The client records that a chain has been used (gammonchain.chain.used in localStorage) and builds a fresh chain before entering any match, including after a closed browser, an aborted match or a server restart. On a reload mid-match, the server sends the last link it accepted (lastLink), and the client resumes from that point in the same chain.
Who can predict or steer a roll
| who | can they steer a roll? | can they predict the next roll? |
|---|---|---|
| server | no: it committed to its seed in a header both players signed before revealing anything | no: it does not have the roller's next link until the roller asks to roll |
| rolling player | no: their link is pinned by the chain tip they published | no: the server's seed stays secret until the match ends |
| opponent | no: they contribute only the per-game link g, fixed by their tip | no: they have neither the seed nor the roller's next link |
| anyone with past links | no: every link hashes back to a published tip | no: a revealed link says nothing about the next one |
What this does not defend against
The code and README state this plainly, and so does this page.
The residual: a server that leaks its seed
A server that deliberately leaks its seed to one player hands that player their own future rolls. The player already holds their whole chain, so seed plus own chain gives their own upcoming dice.
Even then, that player:
- still cannot see the opponent's rolls, because those need the opponent's unrevealed links;
- still cannot change any die, because every link is pinned by a published tip and the verifier re-derives every roll.
But knowing your own next roll while choosing your current play is a real advantage. No commit-reveal scheme with a server-held seed removes it. Removing it would need a seed that never exists in one place: threshold or VDF constructions, or a seed derived from the two players only. The design accepts this trade on purpose. It keeps a single round trip and no extra dependencies, and it confines the residual to a server that is actively cheating with a named accomplice. The signed record does not hide who that accomplice played.
Two consequences follow directly from the code:
- Bot matches. A bot's reveal chain is built and held by the server (
MatchActor.start()), so in a bot match the server holds the seed and one player's chain by construction. The protocol cannot prove that the bot did not know its own next roll. It still proves that your rolls were not predictable to anyone and that no die was altered. Bot matches are never rated. - Aborted matches. The server can stop a live match (on restart, a handshake timeout, or 30 minutes of inactivity), and an aborted match produces no record. The dice protocol cannot make a server finish a match it chooses to drop.
The dice in aggregate
GET /api/fairness returns a running histogram of every die the server has rolled, with a chi-square goodness-of-fit statistic (5 degrees of freedom, 11.07 critical value at 95%). The uniform field stays null until at least 600 dice have been counted. As the response says, the histogram is a sanity check, not the proof. The proof is that anyone can re-derive each roll from a downloaded record. See Verification.