# How a round is decided The specification of the game's random outcome, written for whoever has to check it: a laboratory, an auditor, a mathematician, or a player with a shell. Every statement here is either a line of code in `packages/engine` or a test in `packages/engine/test`, and the file and test are named. Nothing in this document is needed to *play*; it exists so that nobody has to take the table's word for anything. ## 1. The three ingredients **A hash chain.** Before any round is played, a root seed is drawn from the operating system's CSPRNG (`randomBytes(32)`, `fairness.ts: randomSeed`) and hashed forward `length − 1` times: seed[i + 1] = SHA-256( hex text of seed[i] ) fairness.ts: hashSeed, generateChain The last link, `seed[length − 1]`, is the **commitment**, published before the chain enters play. Rounds consume the chain *backwards*: the first round's seed is `seed[length − 2]`, the next round's is `seed[length − 3]`, and so on. Each revealed seed hashes to the seed revealed before it (and to the commitment, for the first), so the sequence cannot be rewritten after players have bet: producing a different seed that hashes to an already-published value is the SHA-256 preimage problem. The production chain is 200,000 links; `length − 1` rounds are playable. **A salt.** The seed is not used raw. It is keyed with a **salt** the operator could not have known when the chain was built: in production, the hash of a Bitcoin block mined *after* the commitment was published (`CRASH_SALT`, with the block height in `CRASH_SALT_BLOCK` so anyone can look it up). The salt closes the one remaining freedom, generating many candidate chains and publishing the most favourable: the outcomes depend on a value that did not exist yet. **The house edge and the cap.** Two numbers in the public configuration: `houseEdge` (3% on the live table) and `maxMultiplier` (25,000x). Both are served by `GET /api/fairness`. ## 2. From seed to crash point digest = HMAC-SHA256( key = salt, message = hex text of seed ) fairness.ts: roundDigest u = integer( first 13 hex characters of digest ) / 2^52 crash.ts: crashPointFromDigest raw = (1 − houseEdge) / (1 − u) crash = 1.00 if raw < 1 = min( floor(raw · 100) / 100, maxMultiplier ) otherwise `u` is uniform on `[0, 1)` with 52 bits of resolution, the width a double represents exactly. For any multiplier `x ≥ 1` on the 0.01 grid, P(crash ≥ x) = P(raw ≥ x) = P(u ≥ 1 − (1 − e)/x) = (1 − e) / x (truncation to the grid cannot move a draw across a grid value, so it does not change this). Draws with `raw < 1.01` fall into the 1.00 bucket and pay nothing: the **instant-bust** rate is `1 − (1 − e)/1.01`, about 3.96% at a 3% edge (`crash.ts: instantBustProbability`). Tests: `crash.test.ts` — "every crash point is a payable two-decimal multiplier within the cap", "the house edge surfaces as instant busts at the predicted rate", "P(crash >= x) tracks (1 − edge) / x", "RTP is 97% at every cash-out target" (300,000 draws), "maxMultiplier caps the tail without touching the body". `fairness.test.ts` — "each seed hashes to the one revealed before it", "the salt changes every outcome in the chain", "verifyRound rejects a seed swapped out of the chain", "verifyRound rejects a misreported crash point". ## 3. The ladder The round does not pay every number on the way up. It pays on a **ladder of rungs** built from the **commitment** (public before betting opens) and nothing else: - A deterministic stream is derived from the commitment text: an FNV-1a hash of the characters seeds a 32-bit linear congruential generator (`ladder.ts: ladderRandom`). It is not a random number generator in the certification sense and does not need to be: it consumes only public data and exists so that every party rebuilds the same ladder. - Rungs ascend from 1.00. The first adds `U(0.01, 0.04)` of its value; every later rung adds `U(0.04, 0.11)`; each addition is rounded to 0.01 and is at least 0.01 (`buildRungs`). - From the sixth rung on (`BONUS_STEP_EARLIEST = 5`), each rung is with probability `1 / bonusStepEvery` a **bonus rung** that triples instead: it adds twice its value (`BONUS_STEP_GROWTH = 2`). The table's density is `bonusStepEvery = 12`; `0` turns bonus rungs off and the branch that would consume a stream value is never reached, so the ordinary rungs are identical either way. - The ladder stops at the table ceiling. **Settlement.** A round pays the highest rung at or below its crash point (`stepAtOrBelow`); a crash point below the first rung above 1.00 pays nothing. A manual cash-out pays the rung the round is resting on at the moment the server receives it, on the server's clock, never a number between rungs (`gameLoop.ts: cashOut`). An automatic cash-out pays its target rung the moment the round reaches it, executed by the server whether or not the player is connected (`flushAutoQueue`). **Targets.** The client's auto cash-out field is a rung picker: it shows the rung of the current round's ladder nearest to what the player typed, re-snapped every round, and that rung is what fires (`nearestStep`). Over the API, a target that is not a rung fires on the first rung at or above it (`stepAtOrAbove`), so a player is never paid less than they asked for. **Timing.** Each rung is due when the smooth curve `1.00 · e^(rate · t)` reaches it (`curve.ts`, `rungSchedule`), except that no rung may take longer than `MAX_RUNG_MS = 2000` ms; the round ends when the rung *after* the one it settles on was due (`elapsedAfterRung`), so the last payable rung has its full turn on screen. Timing decides *when* money moves, never *how much*: settlement reads the draw against the rungs. ### Why the ladder does not move the return For any rung `r` the player can be paid at, they are paid `r` exactly when the crash point is at or above `r`, which by §2 has probability `(1 − e)/r`. The expected return of a stake of 1 targeting `r` is therefore r · (1 − e)/r = 1 − e whatever `r` is, wherever the rungs are, however wide the gap below `r`. A tripling rung is worth `1 − e` like any other; the compensation for its size is that more draws land in the gap under it and settle on the rung below. What changes with the ladder's shape is the *variance* of a round, not its mean. Tests: `ladder.test.ts` — "a ladder is reproducible from the commitment and nothing else", "rungs grow inside the documented band", "the ladder does not move the return to player" (100,000 rounds), "a bonus rung is worth (1 − houseEdge), exactly like every other rung", "bonus rungs do not change how often a round pays nothing", "an auto cash-out is never lifted below the target it was set to", "nearestStep picks the closer rung, the lower one on a tie, and never leaves the ladder", and the timing group ("no rung runs longer than the cap", "the clock does not move the return to player"). ## 4. Gold rounds Every `goldRoundEvery`-th round by count (5 by default; the live value is published by `/api/fairness`), the ladder is built at a denser bonus setting (`goldStepEvery = 4` instead of 12). Which rounds are gold is a count from the first round dealt, not a draw: the chain is consumed backwards, nothing about a round after the next one is knowable, and a schedule nobody could see coming would not be a schedule. The density a round ran on is published with that round and must be used to rebuild its ladder (`gold.ts: bonusStepEveryFor`). By §3 the return on a gold round is the same `1 − e`; the round reaches a given multiplier with the same probability; it gets there in fewer, bigger steps. The player-facing line is exactly that: "a gold round changes the shape of the ladder, not the odds." Tests: `gold.test.ts` — "a gold round returns the same as an ordinary one, and gets there in fewer crates", "reaching a given multiplier is no likelier on a gold round", "the low grind is identical, so gold rounds do not change how often a round pays nothing", "a gold round verifies at its own density and fails at the table's". ## 5. What is revealed, and when | Moment | Public | Withheld | | --- | --- | --- | | Before the chain is played | the commitment, the salt and its block, the edge, the cap, the ladder rule | every seed | | Betting open | the round's commitment (the previous round's seed), hence its ladder | the seed, the crash point | | Round running | the rung the round is on, on the server clock | the seed, the crash point | | Round over | the seed, the raw draw, the crash point | — | The loop's snapshot gates the three withheld fields on the round's phase (`gameLoop.ts: snapshot`); the back office shows a round's crash point and seed only once it is over (`reports.ts`); the public `GET /api/rounds/:id` answers only for rounds that are over. The rule in `AGENTS.md` is that no outbound message carries a crash point or seed before the crash. ## 6. Verifying a round With the seed, the salt, the commitment (the value published before the round), the crash point claimed and the density the round ran on: 1. `SHA-256(hex text of seed)` must equal the commitment. 2. `HMAC-SHA256(salt, seed)` → `u` → `crash` as in §2. 3. Rebuild the ladder from the commitment at the round's density and take the highest rung at or below the crash; it must equal the claimed crash point. `POST /api/verify` does this on the server; `apps/client/src/fairness.ts` does it in the browser with WebCrypto and the same pure derivation; the game's "Provably fair" dialog re-derives recent rounds and any round by number (`/game/?round=N`). From a shell, step 1 is `printf %s | sha256sum` and step 2 is `printf %s | openssl dgst -sha256 -hmac `. ## 7. Operational controls the code cannot enforce - **The commitment must be published before the salt's block is mined.** The code makes the order of events visible; it cannot make the operator keep it. A chain is replaced by a ceremony run from the back office (`GET/POST/PATCH/DELETE /api/admin/chains*`): 1. *Prepare.* The server draws the next chain and stores it with no salt. From that moment its commitment is served by `GET /api/fairness` (`next.commitment`, with `next.publishedAt`) and shown in the lobby; the operator announces it somewhere they cannot later edit. Audit: `chain_prepared`. 2. *Salt.* The operator gives the prepared chain the hash of a Bitcoin block mined after that announcement, and the block's height. The server insists on a 64-hex value that differs from the salt in play and refuses to use an unsalted chain. Audit: `chain_salted`. 3. *Switch.* Asked for (`chain_switch_requested`), the table deals the next round from the new chain under its own salt; not asked for, the switch happens when the chain in play runs out. Either way the first round of the new chain has the new commitment as its previous-seed and the new salt in its history entry, so verification of old rounds uses the old salt and of new rounds the new. Audit: `chain_switched` with `ceremony: true`. A chain that runs out with no salted successor is rotated on the salt it had, which is honest but weaker (the operator saw the salt before the commitment); the audit trail says so (`chain_rotated`, `ceremony: false`) and an alert is raised 15,000 rounds ahead of that point. The first chain's salt comes from `CRASH_SALT`; every later one from the ceremony. - **One process deals.** The table lease (`lease.ts`) keeps a deploy from becoming two loops on one chain; it is an availability control, not part of the fairness argument. - **Money.** Payouts are `floor(stake · multiplier)` in integer minor units (`money.ts`), the per-round exposure cap refuses bets whose worst case would breach it, and the wallet contract (`docs/INTEGRATION.md`) carries an idempotency key on every movement. ## 8. Test vectors `docs/test-vectors.json` freezes every quantity above for the thirty-one rounds of a short chain under the live table's parameters: a fixed root and salt, each round's seed, commitment, digest, `u`, raw and payable crash point, density, ladder with its bonus rungs, the rung it settles on, the payout of a 100.00 stake, and what an auto cash-out target of 2.00 resolves to over the API and in the client's field. `npm run vectors` checks the file against the engine and fails on any difference; `npm run vectors -- --write` regenerates it, which is a change to announce here. `packages/engine/test/vectors.test.ts` re-derives every row from §1–§3 with `node:crypto` and arithmetic alone, so a laboratory has a worked example of exactly what it must implement, and a proof that the engine agrees with it. ## 9. What is not claimed - The ladder stream (§3) is not a cryptographic generator and is not claimed to be; its inputs are public and its only job is agreement between parties. - The RNG proper is the chain (SHA-256 over a CSPRNG root) keyed by a public salt; the mapping from digest to outcome is exact in double precision for the 52 bits it consumes and does not depend on floating-point rounding for its distribution. - Timing (§3) is on the server's clock; a client's clock is used only to draw. A cash-out request is judged by when it arrives, which is the usual and the only honest rule.