Skip to content

Verification

Download any finished match and re-check it without trusting the server. The verifier (src/verify/verify.js) imports the same rules engine, dice derivation and hash chain code that the server runs. So "the site says I won" and "the maths says I won" cannot come apart. The browser client runs the same verifier in the page, and you can also run it from the command line.

Running it

Download a record from the site, or from GET /records/<matchId>.json, then:

bash
node src/verify/cli.js m1abc2def.json            # run every check
node src/verify/cli.js m1abc2def.json --dice     # also print every roll: recorded vs re-derived
npm run verify -- m1abc2def.json                 # same thing via package.json

The CLI prints each check with a ✓ or ✗ and a short explanation, then VERIFIED or VERIFICATION FAILED. It exits 0 when the record verifies, 1 when it does not, and 2 when called without a file. You can use it in scripts.

The CLI reads both plain JSON (what GET /records/<id>.json downloads) and the gzipped <matchId>.json.gz files the server writes to RECORDS_DIR by default. Gzip is detected from the file's first bytes, not its name, so you can point it straight at a file on the server:

bash
node src/verify/cli.js /data/records/m1abc2def.json.gz --dice

You need Node with Ed25519 in WebCrypto. The project's Dockerfile and CI use Node 22.

Every check

verifyRecord() returns { ok, checks, replay }. ok is true only when every check passes. The checks, in the order they run:

checkwhat it establisheshow
commitmentsthe server's revealed seed hashes to the commitment published in the header before play. Under v1 it also checks both players' revealed secretsSHA-256("gammonchain-commit-v1:" ‖ secret) compared with header.commitments
diceevery roll equals the HMAC derivation, and under v2 every revealed player link chains back to that player's published tipeach event is replayed through Match.replay(), which re-derives the dice and re-hashes every link against the previous one
legalityevery play is legal under the open rules enginethe same replay: each play must be one of legalPlays() for that position and roll
resultthe final score follows from the eventsthe score after replay compared with result.score
hashchainnothing was edited, inserted, removed or reorderedbuildChain(header, events) recomputed and the last hash compared with chain.final
signaturesevery signature the record contains is valideach { n, by, sig } checked against h(n) with the matching key from the header. Signatures whose signer has no key are counted as unverifiable, not invalid
participationeach named player actually signed something: the record was not fabricated by the server aloneat least one valid signature from p0 and one from p1
server-countersignthe server signed the final hasha server signature exists at the last event index

The replay is strict. Match.replay() recomputes each event from the previous state and the recorded action, then requires the result to match the recorded event byte for byte (after serialising both). A swapped die, an altered hop, an edited cube value or a changed winner all fail here. If the replay throws, the result contains a failed replay check with the reason, and ok is false.

The test suite demonstrates that records are rejected when someone tampers with a move, swaps a die for a better one, forges the server seed, substitutes a dice reveal, deletes an event, or inflates the score.

Why participation is a separate check

signatures verifies the signatures a record contains. It says nothing about the signatures a record leaves out.

Anyone holding a server key can derive dice, generate legal plays, build a correct chain and sign all of it, while naming two players who never touched the match. Such a record would ship no player signatures at all. Before participation existed, that record passed every other check: the signatures check had nothing invalid to find, so it passed vacuously.

participation closes that gap. If a player is named in the header with a public key, at least one event signature must verify against that key, or the record is fatally invalid. That is what makes a downloaded record evidence of a game between two people, not just evidence of a well-formed file.

How much trust this removes

The verifier's own header comment states this precisely:

  • The server cannot alter a finished record. The dice re-derive, the plays re-validate, the chain recomputes, and the players' keys have signed it.
  • A server-signed record is not self-proving. The participation check is what binds a record to its players.
  • For a guest, whose key never leaves their browser, that signature is unforgeable and the record is proof they played.
  • For a registered account, whose key the server stores so it can email a sign-in link, the operator could produce that signature. This is the trade-off described in Accounts & email, and it is why guests still exist.

The verifier also inherits the residual of the dice protocol. It can prove that no die was altered and that no roll was predictable to the server. It cannot prove that a server did not leak its seed to one player.

The dice audit

auditDice(record) (the CLI's --dice flag) re-derives the dice for every open and roll event on its own and lists each roll as recorded next to its re-derived value, with any mismatch marked. It is the same information the dice check relies on, laid out one roll at a time for someone who wants to see it.

Rules engine, fairness protocol, verifier, analysis and worker: MIT. Server and client: AGPL-3.0-or-later.