Appearance
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.jsonThe 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 --diceYou 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:
| check | what it establishes | how |
|---|---|---|
| commitments | the server's revealed seed hashes to the commitment published in the header before play. Under v1 it also checks both players' revealed secrets | SHA-256("gammonchain-commit-v1:" ‖ secret) compared with header.commitments |
| dice | every roll equals the HMAC derivation, and under v2 every revealed player link chains back to that player's published tip | each event is replayed through Match.replay(), which re-derives the dice and re-hashes every link against the previous one |
| legality | every play is legal under the open rules engine | the same replay: each play must be one of legalPlays() for that position and roll |
| result | the final score follows from the events | the score after replay compared with result.score |
| hashchain | nothing was edited, inserted, removed or reordered | buildChain(header, events) recomputed and the last hash compared with chain.final |
| signatures | every signature the record contains is valid | each { 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 |
| participation | each named player actually signed something: the record was not fabricated by the server alone | at least one valid signature from p0 and one from p1 |
| server-countersign | the server signed the final hash | a 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
participationcheck 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.