DocsCore concepts

Crash Round Lifecycle

How the multiplayer Crash engine and Galabet Flight run a round in time, from chain creation and the salt to bets, cash-outs and the reveal, with the current settlement and recovery boundaries.

The Crash page explains how a crash point is calculated and checked. This page is about time: when the chain is made, when the salt becomes known, when a bet is accepted, what a cash-out reads, and at which moment the game hash goes public.

Two implementations exist in this project and they behave differently. The multiplayer engine lives in apps/api/src/crash/. It needs Redis, Postgres, a worker process and an EVM RPC endpoint, and it is not deployed anywhere. Nothing on galabets.org runs it. Galabet Flight is the single-player variant served by the Astro site at /api/flight, and it is what the Crash game page plays. The second half of this page covers Flight, and the last section records the resolved findings and remaining operational constraints.

Chain and Salt

A chain is created when none exists or when the current one has played all its games. createCrashChain(10000) makes it, and in the same function the engine asks its RPC endpoint for the latest block number and adds 20. That future block number is stored with the chain as saltRef, beside the terminating hash and the length, in a Postgres row that the GET /api/crash/chain/:id endpoint serves. So the announcement and the terminating hash are written together, before the block exists.

The Postgres row is written first. Then, in one Redis transaction, the secret goes under crash:secret:<id> and the expanded chain, all 10,001 hashes from expandCrashChain, is cached as a Redis list so that fetching a round's hash is one LINDEX. If the Redis step fails, its keys and the row are removed again, so a half-created chain can't stall the engine.

Both keys are worth every result still to come, which is why they stay out of Postgres and out of every response. Once the last game of a chain has been revealed, the secret is itself public, being that game's hash, and both keys are deleted.

Before a round uses its hash, the engine checks it: sha256(hash) must equal the hash revealed by the previous round, or the terminating hash for game 1. A mismatch stops the engine with paused and does not retry, because it means the cached chain is not the one that was announced.

No round can start until the announced block is mined. The loop asks for the block's hash, and while there isn't one it broadcasts a paused event naming the block it's waiting for and tries again 5 seconds later. Once the hash arrives it's written to the chain row as saltValue and never looked up again. One salt serves the whole chain.

One Round

Phases of a round, as crash:state holds them
waiting  ── 6 s ──▶  running  ── ln(result) ÷ 0.06 s ──▶  crashed  ── 2 s ──▶  next round
bets accepted         cash-outs accepted                    hash, salt and settlements broadcast

The engine runs in the worker process only. It keeps the live state in a Redis hash, crash:state, and publishes events on a Redis channel. Every HTTP worker runs a WebSocket gateway at /ws/crash that relays those events to browsers. The socket is read only: a client that sends anything is closed with code 1003, and a new connection is handed the most recent event so it doesn't open on a blank screen.

MomentState writtenEvent broadcast
Round announcedphase: waiting, chain id, index, multiplier: 1, startsAtwaiting with startsAt, 6,000 ms ahead
Before waitingThe round hash and endpoint are prepared in private Redis state with the original deadlines. No public history row is insertedNone
Flight beginsphase: running, multiplier: 1None
Every tick, 100 ms plus two Redis callsmultipliertick with multiplier and elapsed milliseconds
Crashphase: crashed, multiplier set to the result. Bets settledcrash with index, gameHash, result, salt and aggregate settlement counts

The engine knows the crash point for the whole of the flight. That is inherent in the design, and it's the reason the hash chain exists: the point was fixed before the first game of the chain, so knowing it early gives the engine nothing to change. The prepared hash and result stay private until the round closes. History receives completed rounds; the gateway emits only public fields.

The Curve

The displayed multiplier is floor(e^(0.06 × seconds) × 100) / 100, capped at the crash point. The engine turns the crash point into a duration by inverting that, ln(result) ÷ 0.06 seconds, and ticks until the duration has passed. Flight uses the same formula with 0.16.

curve.mjs
const seconds = (result, growth) => (Math.log(result) / growth).toFixed(2).padStart(6);

console.log('result   engine 0.06   Flight 0.16');
for (const result of [1, 1.01, 1.5, 2, 10, 100]) {
  console.log(String(result).padEnd(8), seconds(result, 0.06), 's    ', seconds(result, 0.16), 's');
}
Output
result   engine 0.06   Flight 0.16
1          0.00 s       0.00 s
1.01       0.17 s       0.06 s
1.5        6.76 s       2.53 s
2         11.55 s       4.33 s
10        38.38 s      14.39 s
100       76.75 s      28.78 s

A result of 1.00 has a duration of zero. The tick loop never runs, no tick event is sent, and the round goes from running to crashed with nobody able to cash out. That is correct behaviour: an instant loss, where the house edge shows most plainly.

The loop checks the authoritative Redis clock before publishing a tick. Once the endpoint time has arrived, it leaves the running loop without broadcasting an endpoint tick and proceeds to settlement and reveal.

Bets

POST /api/crash/bet accepts a whole-number stake from 1 to 100,000 and an optional automatic target from 1.01 to 10,000. A Redis Lua operation checks both phase and deadline, then writes the debit and per-session bet together. The round has an index of participating sessions, while each actual bet is a separate private value. One session can place only one bet in a round.

The hash is known privately before betting. Eligibility depends on Redis time being before startsAt, not on whether the worker has published its next phase yet. A delayed worker cannot keep betting open past that deadline.

Manual Cash-Out

POST /api/crash/cashout uses Redis time to calculate the multiplier, checks that the round is still running and before its endpoint, then atomically stores the credit and completed-bet marker. An automatic target already reached caps the manual multiplier.

The last animated tick is not the acceptance price. HTTP workers use the same Redis clock and atomic operation. A cash-out after the deadline is refused even if the worker has not refreshed its phase field.

Auto Cash-Out

Payouts are worked out in whole hundredths: floor(stake × round(multiplier × 100) / 100), so a stake of 100 at 2.01× returns 201 and not the 200 that floating-point multiplication gives. Automatic cash-outs are completed during settlement after the crash. The target must be strictly below the endpoint; equality loses. A player reads their own bet through /api/crash/me. Public events carry aggregate counts and payout totals, never session credentials or private bet lists.

The Reveal

The crash event is the first broadcast that contains gameHash. With it and the hash from the previous round, anyone can run verifyCrashLink, and with the salt, crashResult. The engine sleeps 2 seconds and starts again. GET /api/crash/history serves the crash_games rows, newest first, up to 200 of them.

If the loop throws, it broadcasts paused, waits five seconds and retries the private pending round. Stored deadlines and completed-bet markers survive worker restarts while Redis state survives. Settlement retries cannot credit the same bet twice; repeated history writes do not duplicate the completed round. Redis loss or rollback still requires controlled recovery.

Void Rounds

A round can only crash on players who could see it. The round clock records when the engine last looked at it. At the deadline, the round crashes normally only if the engine was watching: its last look was the running phase no more than a second before the endpoint. Anything else, a round still waiting when the worker died, a flight it stopped ticking during a stall, or a clock left by an older worker, is voided.

A void refunds every unsettled stake once, using the same completed-bet marker as settlement, so a retry refunds nothing more. Bets already paid keep their payout. That includes an unsettled automatic target the displayed multiplier had already passed: it gets its stake back, not its target. The engine then reveals the game hash as usual and broadcasts a void event with public totals. The hash and its result are stored, the next game still links to it, and GET /api/crash/history marks the row voided: true. Unsettled stakes survive an outage for seven days at most.

Galabet Flight

Flight uses the same endpoint calculation with one player per round, without the multiplayer chain or socket. Production /api/flight persists the session, balance ledger, completed records and retry responses in Postgres. Multiple site processes share row locks. Only an unconfigured development server uses a disposable memory map. See Flight Endpoint.

Each round gets its own game hash: 32 random bytes, made before the player has chosen a stake. The server computes SHA-256(gameHash) as the round's commitment, sets the salt to galabet-flight: followed by the round's id, and works out the result with crashResult at a 1% edge. The ready view already carries the id, the commitment and the salt. The game hash stays out of every response until the round has a record.

The phases are ready, waiting, running, and then cashed or crashed. start validates a whole stake from 1 to 1,000 and an automatic cash-out from 1.01 to 100 with at most two decimals. The auto target is required here, not optional; a prepared round carries 2 until the player sends their own. The stake is debited and startsAt is set 1,800 ms ahead. A repeated start cannot debit twice; changed stake or target values are refused. Production mutations also require a session-scoped idempotency key.

Flight does not need a tick worker to accept actions. Each transaction reads the database clock after acquiring its session lock. Requests and the periodic recovery sweep call the same settlement function, in this order:

  1. If the auto target is strictly below the result and its moment has passed, the round cashed out at the target, at that moment. It doesn't matter how late the request that discovers this arrives.
  2. Otherwise, if the crash moment has passed, the round crashed.
  3. Otherwise, if this request is a manual cash-out, it pays the curve's value at the server's clock or the auto target, whichever is lower.

An auto target equal to the result loses. The source comment reads "Equal to the endpoint is too late". The client's clock is never consulted; each view includes serverNow so the page can line its animation up with the server's.

Concurrent requests for one Flight session serialize on its Postgres row; the shared limit is ten per second. A completed record and balance movement commit with the action and its retry response. The view contains the last ten records, with durable receipts retained until cleanup. next requires a completed round. Inspecting untrusted records explains why the verifier still cannot authenticate cash-out timing from a pasted record alone.

Settlement Rules Side by Side

Multiplayer engineFlight
Growth constant0.060.16
Wait before the flight6,000 ms1,800 ms
Manual cash-out paysThe curve at Redis timeThe curve at Postgres time
Auto target equal to the resultLosesLoses
Manual cash-out after the auto target has passedPays the auto targetPays the auto target
Auto cash-out settledIn one pass after the crashBy a request or recovery sweep
Concurrent actions on one sessionAtomic Redis bet/payment operationsPostgres row lock and transaction
Payout arithmeticInteger hundredthsWhole credits, rounded down
Worker gone at the deadlineRound voided, stakes refundedSettled from server time on the next request
equality.mjs
const engineAuto = (auto, result) => (auto && auto < result ? auto : null);
const flightAuto = (auto, result) => (auto < result ? auto : null);

for (const [auto, result] of [[2, 2.37], [2, 2], [2, 1.99]]) {
  console.log(`auto ${auto.toFixed(2)}  result ${result.toFixed(2)}  engine pays at ${engineAuto(auto, result)}  Flight pays at ${flightAuto(auto, result)}`);
}
Output
auto 2.00  result 2.37  engine pays at 2  Flight pays at 2
auto 2.00  result 2.00  engine pays at null  Flight pays at null
auto 2.00  result 1.99  engine pays at null  Flight pays at null

Crash points and targets use hundredths, so equality is an ordinary boundary. Both applications require an automatic target strictly below the endpoint. GFS defines the endpoint calculation; application settlement rules remain separate.

Known Faults in the Multiplayer Engine

The earlier review found premature history publication, session credentials in socket payloads, concurrent debit/credit races, a final endpoint tick, late betting, inconsistent target equality, lost stakes when the engine died mid-round, chain secrets kept after use and payouts one chip short. These findings have regression tests against the current code and are fixed as described above. They are not claims about a deployed incident.

The remaining constraints are operational: run one active Crash worker, use persistent standalone Redis with noeviction, keep private chain material out of public responses, and reconcile state after Redis rollback or loss. The 24-hour API idempotency cache blocks uncertain outcomes for investigation; it is not a permanent financial ledger. Do not mix worker versions or erase a pending marker merely to force a wager retry.