DocsGames
Crash
The multiplayer profile. A hash chain fixed in advance, a salt nobody controlled, and a crash point anyone can recalculate from the two.
Crash doesn't use client seeds or nonces. A round has one result shared by everybody in it, so no single player's input can be part of the calculation, and the scheme is built differently from the single-player games.
Hash Chain
The server picks a secret and hashes it. Then hashes the hash. Then again, thousands of times. It publishes only the final hash, and plays the games in reverse.
secret ──sha256──▶ h1 ──sha256──▶ h2 ── … ──▶ hN (published before game 1)
game 1 uses h(N−1) game 2 uses h(N−2) … the last game uses the secret
Since SHA-256 can't be run backwards, seeing game 1's hash tells you nothing about game 2's. But once game 2's hash is revealed you can hash it yourself and check that you get game 1's. Every round is pinned to the one before it, and the first is pinned to the hash that was public before anything was played.
The repository ships a 12-game chain whose secret is public, so that this can be shown with fixed numbers.
import { readFile } from 'node:fs/promises';
import { verifyCrashLink } from '@galabet/fair';
const chain = JSON.parse(await readFile('vectors/gfs-1.0-crash.json', 'utf8'));
const games = chain.games.filter((g) => g.salt === 'galabet' && g.houseEdge === 0.01);
console.log('published first:', chain.terminatingHash.slice(0, 16) + '…');
for (const game of games.slice(0, 4)) {
const linked = await verifyCrashLink(game.gameHash, game.previousHash);
console.log(`game ${game.index} ${game.gameHash.slice(0, 16)}… links back: ${linked}`);
}
published first: bd84ed4ffd79306f…
game 1 44d03a8debca6410… links back: true
game 2 57be8e79a549bfac… links back: true
game 3 8a75bb831ce6b3db… links back: true
game 4 6aa6822ccb21bd0a… links back: true
Salt
A chain alone isn't enough. A server could generate a million chains, look at the results each would give, and publish the one that pays worst. The salt closes that door: a value mixed into every result, which must not have existed when the chain was published.
Galabet's multiplayer engine announces a future block number on a public blockchain when it creates a chain and uses that block's hash once it's mined. A drand round would do as well. What matters is the order of events. Chain first, salt after, and both on record.
Crash Point
digest = HMAC-SHA256(key = gameHash, message = salt)
h = the first 52 bits of the digest, as an integer
result = floor(100 × 2⁵² / (h + 1) × (1 − houseEdge)) / 100, and never below 1.00
import { crashResult } from '@galabet/fair';
const gameHash = '44d03a8debca6410dd70c52a3be7cf3a5b306d9bfd2573c939a33ed98d16c6ab';
console.log(await crashResult(gameHash, 'galabet', 0.01));
console.log(await crashResult(gameHash, 'galabet', 0));
console.log(await crashResult(gameHash, 'a different salt', 0.01));
1.13
1.14
1.23
As with every mapper here, the key is the hex string as text.
This formula has a history. The first version in this project applied the edge in cents and then multiplied by 100 a second time, so every crash point was a hundred times too high, and the unit test passed because it had been written with the same mistake in it. Printing a histogram of results is what exposed it. If you port this, don't stop at "my three test cases pass".
import { readFile } from 'node:fs/promises';
const chain = JSON.parse(await readFile('vectors/gfs-1.0-crash.json', 'utf8'));
const results = chain.games.filter((g) => g.salt === 'galabet' && g.houseEdge === 0.01).map((g) => g.result);
console.log(results.join(' '));
1.13 7.32 2.37 1.01 2.76 1 1.16 10.98 1.61 2.18 7.33 1.23
Twelve games is too few to judge a distribution by. It's enough to see the shape: mostly low, now and then a long one. About one round in fifty is an instant 1.00, the same floor that shows up in Limbo. The edge itself is the (1 − houseEdge) factor; the floor is where it shows most plainly.
Creating a Chain
import { createCrashChain, crashGameHash, expandCrashChain } from '@galabet/fair';
const chain = await createCrashChain(1000);
const all = await expandCrashChain(chain);
console.log(Object.keys(chain));
console.log(all.length, all[0] === chain.terminatingHash, all[1000] === chain.secret);
console.log((await crashGameHash(chain, 1)) === all[1]);
[ 'secret', 'terminatingHash', 'length' ]
1001 true true
true
expandCrashChain gives you index 0 as the published hash and index k as game k's hash. crashGameHash finds a single game by hashing forward from the secret every time, which for game 1 of a long chain is the whole chain's worth of work. Expand once and cache it. The secret is the last game's hash, so whoever holds it holds every result: keep it out of your database's ordinary tables and out of your logs.
Limits of Verification
A recalculated crash point doesn't tell you when you cashed out. The chain fixes where the round ended. It has no idea whether your click arrived at 2.38× or 2.41×, or arrived at all. That's between you, the server's clock and its logs. Galabet Flight records the accepted cash-out in its receipt, and the verifier deliberately reports that part as a supplied claim, not a checked fact.
inspectRecord checks a Crash record's result and one link, to the previousHash you give it. It doesn't walk the chain back to the published hash. For that, loop verifyCrashLink yourself.
Errors
| Thrown | When |
|---|---|
game hash must be 64 lowercase hex chars | crashResult |
house edge must be in [0, 1) | crashResult |
chain length must be 1 to 10,000,000 | createCrashChain |
game index out of range | crashGameHash, k below 1 or past the end |
