Appearance
Crash

A rising multiplier; bank before it busts.
- Designer
- player1
- RTP
- 97.00%
- State
- Maths Complete
Opens 97 Plus in demo mode: virtual credits, no sign-up.
How it plays
Details to come.
Original Design document
Created 15 July 2026 · Last modified 18 September 2026
Game design may differ from the content below.
Status: implemented. This doc describes the game as built (2026-09-14). Read alongside CLAUDE.md and docs/crash-shared-round-plan.md (the shared-room plan, phase log and the crash-point leak checklist).
A round is one rising multiplier curve. It starts at 1.00× and climbs with flight time; at a hidden crash point it dies. A wager staked before launch either cashes out (bet × multiplier-at-cashout) or rides past the crash and loses. A player with no wager just watches, and may stake the next round while watching.
- RTP: 97% (tile
rtp: "97.0"inassets/tiles.json), flat across every cash-out target (§2). - Colours: accent
ALIZARIN,CRASH_TILE_BG,CRASH_SCENE_BG, altCRASH_ALT. Big win atbigWinMultiplier: "2"(tiles.json). - Two round flows, chosen once at init: real = one shared server room for every player and NPC (§3); mock = the original solo per-session flow (§4).
ServiceProvider.crashRoom(null in mock) is the single branch point; in the client it becomesCrashGame.shared.
1. The curve
m(t) = exp(0.22·t) (t in seconds) — CRASH_GROWTH_PER_SEC = 0.22 in CrashEngine.ts, shared by the client's MultiplierClock, crashMultAt(ms) and its inverse crashFlightTimeMs(mult). The curve is cosmetic on the client; the server owns the point.
⚠️ crashFlightTimeMs is invertible: the flight's length is the crash point in other units. Any duration, timer delay or tick count derived from the point leaks it (see §3 Secrecy).
2. Engine — CrashEngine
Stateless; implements IGameEngine.
Crash-point distribution
h = draw / 10_000_000 (uniform integer draw, crash.crashPoint), then
crashPoint = min(maxCrashPoint, max(1.00, floor(97 / (1 − h)) / 100))PAYOUT_CONST = 97 is hardcoded. P(crashPoint ≥ x) = 0.97/x, so cashing out at any target x has EV 0.97 — no good or bad target, only variance. The max(1.00, …) clamp is the ~3% instant bust (a loss; you can't cash at 1.00×). The cap maxCrashPoint is the engine's constructor argument (default 1000), never read from a round's config.
Entry points
startRound— the only entry the wire reaches. Refuses a config carryingcrashPointormaxCrashPointoutright ('Crash: crashPoint and maxCrashPoint are not client options') — a client that could name the point would cash out just under it every round. Samples its own point, returns anactiverecord (start + bet deduction).startSharedRound(header, config, crashPoint, seed)— server-side coordinator only (the room). Uses the room's pre-sampled point verbatim, draws nothing, and writes the point into the start config — that is howprocessInputand history replay tell a shared wager from a solo one.processInput{ type: 'cashout', multiplier }— rejects a non-finite/negative claim. Settles atmin(claim, reached), wherereachedis the server's own curve: wall clock sincestartedAtMs(×flightSpeed), orlastMultiplierfor a recovered round. So an inflated claim just banks the real value and a scripted client can't binary-search the point. If the curve itself has passed the point:'Cashout rejected: round has already crashed'— never naming the point. A solo cashout's result revealscrashPoint(the round is over); a shared one stays silent (the curve is still live for everyone else).{ type: 'bust' }— loss, reveals the point. A bust on an already-settled round (cashed earlier) appends nothing and just reveals the point so the client can finish the curve.
clientViewstripscrashPoint,flightSpeed,startedAtMs.reconstructMathsStaterebuilds from the start event, droppingstartedAtMs(a recovered cashout caps atlastMultiplier).scheduleResolve(mock flow) — bust atcrashFlightTimeMs(crashPoint) / flightSpeed. Server-side only; the delay never goes to the client.tickeverytickIntervalMs = 250— the multiplier clamped below the point; persisted aslastMultiplier.simulate— each round draws its own target uniformly from[targetMin, targetMax](mirrors autoplay) and wins iffcrashPoint ≥ target. Uses the sameresolveCrashPoint(quantised draw) as live rounds.
CrashConfig.flightSpeed is still accepted and honoured, but the client no longer sends it (there is no fast-play option — §5).
3. Real — the shared room
Server: project97_server/src/CrashRoom.ts, owned by ServerGameService. Contract: ICrashRoom.ts — real-only, deliberately not part of IGameService, so the mock needn't fake a room. Client: CrashSharedRound.ts.
Phase loop (server-owned; clients follow)
betting BETTING_MS = 3000 — point already sampled (secret); placeBet accepted
flying (variable) — tick broadcast every 250 ms; cash-outs settle
crashed COOLDOWN_MS = 3000 — reveal the point, settle everyone still in as a loss, loopcrashed doubles as the cooldown (the plan's fourth phase was dropped). One crash point per round, sampled in openBetting from a fresh CSPRNG seed (on a forked RNG stream where the RNG can fork), shared by every player and NPC wager via startSharedRound.
Actions (JSON-RPC crashJoin / crashLeave / crashPlaceBet / crashCashOut)
- join returns a
CrashRoomSnapshot(phase, roundId,remainingMs, livemultiplier, feed). - placeBet(sessionId, autoCashOut?) — betting only; one wager per session (a concurrent second bet is refused while the first is still staking). The stake and identity come from server state (
sessionView: the session's bet set bychangeBet, the connection's verified uid) — the bet is never sent.autoCashOutmust be a finite number ≥ 1.01. Staked under the player's wallet lock. - cashOut(sessionId) — flying only; settles at the room's own
crashMultAt(now − epoch). The client sends no multiplier. A cashout past the point is refused without naming it. - Auto-cashout is server-side: at launch, each wager whose target ≤ the point gets a timer at
crashFlightTimeMs(target)and banks exactly the target. A client-fired target would land a round-trip late. - Refusals that are correct play (bet after the window, cashout after the crash) throw
RoundRejection, counted apart from real faults.
ServerGameService refuses solo requestPlay for crash ('Crash is played in the shared room (crashPlaceBet), not as a solo round'), and its record store hides crash from findActive, so a room wager is never recovered as a solo session (which could cash out at 1× while the room also settles it).
Frames
crashPush— room news, broadcast to every joined socket (CrashRoomEvent):phase {phase, roundId, remainingMs},tick {multiplier},cashout {entry}(the one new entry only — watchers merge it withmergeCrashFeed),crash {crashPoint, feed}(the authoritative ranked feed). The feed is cash-outs only, highest multiplier first, capped atCRASH_FEED_LIMIT = 20.crashSettle— a player's own outcome, sent privately to that session alone (CrashSettlement):staked(bet taken; balance; no record — a live record would carry the point),cashed(cashedAt, payout, balance, record),lost(balance, record). A push, not a return value, because auto-cashout and the crash loss aren't client-initiated. Leaving the room is not a forfeit — a staked wager still settles.
Timings are relative
Durations are remainingMs, never absolute server timestamps (the clocks aren't synced). The flying phase — and the snapshot while flying — always reports remainingMs: 0. The flight's length is crashFlightTimeMs(crashPoint), so a flight countdown is the crash point (crashPoint = crashMultAt(remainingMs); cash out at remainingMs − ε) — this leaked once through setPhase('flying', flightMs) and the mid-flight snapshot. A client reads the flight from the ticks and the crash frame only. When adding any field to a broadcast, snapshot, push or record: if producing it needs the point, it must wait for crash. See "Crash point leak: what to check" in the plan doc.
Secrecy of records
While the round flies, every persisted or pushed copy of a wager's record has the point and the seed stripped from its start event (config + mathsState); crash() re-saves the banked wagers' full records once the point is public, so history replay can read it from the start config. Settlement at the crash is serial per participant (two wagers may share a wallet).
Client (real)
- The curve anchors on the client clock at launch; each tick re-derives the implied anchor and re-anchors only on drift > 150 ms. Joining mid-flight anchors from the snapshot multiplier.
- States map from phases: betting →
IDLE, flying →FLYING, crashed →ROUND_OVER. No local betting timer, norequestPlay, no client bust, no cosmetic spectator curve, no recovery branch. - Play in the window:
changeBet, thenplaceBet(autoplay target or none). Pressed while watching (flying or crash display), or bounced by a closing window:pendingBet— staked as soon as the next window opens. Mid-flight with a stake: cash out. - The balance and personal result arrive only via
crashSettle; the leaderboard slide and the curve's cash-out markers are fed by the room feed.
4. Mock — the solo flow
In-process MockGameService; no room, so each session flies its own curve.
- Betting window — local timer,
bettingWindowMs(5000, crash.json). Play arms the wager; when the window ends it launches (REQUESTING). No arm → a watched round: a cosmetic spectator curve with a client-sidecrashPointFromH(Math.random())point — no server round, no stake, silent. Bet/Options/Auto stay live so the player can arm the next round. - Wager —
changeBetthenrequestPlay({ autoCashOut })(only the autoplay target; never point or cap). The bet is deducted server-side; the client mirrorssession.balance. - Timed bust —
IGameEngine.scheduleResolve→GameServiceCore.autoResolvefires the bust at the point's flight time and pushes it throughIGameService.onServerEvent. The client never busts a wagered round itself. Live ticks (250 ms) re-sync the curve. - Cash out —
processPlayerSelection({ type: 'cashout', multiplier }); the server clamps the claim to its own curve and the client shows the server'scashedAt. A cashout that loses the race to the bust (already ended/already crashed) is expected and settles as the loss. A cashed round keeps flying to its crash so the player sees how far it went. - Auto-cashout (autoplay only) — client-fired in
flightTickwhen the curve crosses the target. - Recent-wins board — polled via the kit leaderboard (50 ms in flight, default otherwise), cleared each launch, and capped to the local curve (
cappedLeaderboard) so it can't show a multiple this rocket hasn't reached. A display trick only; real mode needs none. - Recovery — an
activecrash record on load: popup, then auto-cash-out at the last streamed multiplier (lastMultiplier), which the server clamped below the point. - After the crash,
ROUND_OVERholdsroundOverDelayMs(2500) →IDLE(or the next autoplay round).
GameServiceCore's shared rules apply: requestPlay is refused while a round is unresolved, changeBet is refused mid-round, and settlement throws Insufficient balance rather than letting the wallet go negative.
5. Client — CrashGame (src/lib_games/crash/)
| File | Responsibility |
|---|---|
index.ts | CrashGame — both flows behind this.shared, state machine, autoplay, recovery, history replay. |
CrashSharedRound.ts | Real mode only: room subscription, phase tracking, client-clock curve anchor, placeBet/cashOut. |
CrashCurve.ts | Auto-scaling curve, head marker, live readout, countdown bar, cashed/crash treatments, fading cash-out markers. |
MultiplierClock.ts | elapsedMs ↔ multiplier (shared growth rate). |
CrashBetConfig.ts | Options: Bet, Bet Change, Game Colour, Alt Colour, Cash-out Markers toggle, Sound. |
CrashAutoplayConfig.ts | Autoplay: Number of Rounds, Cash Out Min / Max (from autoCashOutOptions, defaults 1.50× / 3.00×, kept ordered), Stop on Win / Loss, On Win / On Loss bet step. No Turbo — a shared round can't be fast-forwarded. |
States: IDLE | REQUESTING | FLYING | SETTLING | ROUND_OVER | HISTORY_REPLAY (REQUESTING and SETTLING are mock-only). Layout sheet: assets/layouts/crash.json.
- Autoplay — each round draws its target uniformly (2 dp) from the Min/Max range. Mock: rounds chain
ROUND_OVER → REQUESTING. Real: stakes each betting window until done. Started while watching, it begins with the next round; a watched round isn't counted. Manual play has no auto-cashout target. - Sound — rising ascend tone (+ looping tail for long flights) and a crash burst, only when staked.
- History replay — one record = one round. Flies the curve to the crash point (from the maths result; for a shared-round cashout, from the start config), showing the cash-out marker on the way.
- Dev menu — Force High Crash, Force Instant Bust, Maths Sim (1mil, autoplay range). Generic Force Win is excluded.
6. Config — assets/crash.json
jsonc
{
"slotCount": 2, // unused (left from the unshipped multi-panel design)
"maxCrashPoint": 1000, // client sim/spectator cap; the live cap is the engine's own
"autoCashOutOptions": [1.2, 1.5, 2, 3, 5, 10, 25, 50, 100, 200, 500, 800, 888, 1000],
"bettingWindowMs": 5000, // mock only — the room's window is BETTING_MS (3000)
"roundOverDelayMs": 2500, // mock only — the room's is COOLDOWN_MS (3000)
"requestTimeoutMs": 5000
}Bet ladder: shared assets/betOptions.json, filtered below maxWinLimit.
7. Wiring
- Lazy loader in
builtinGameLoaders.ts; engine indefaultEngines.tsand project97_server'sServerGameService. - NPCs join the room via project97_npcs'
CrashRoomWatcher. - Tile entry in tiles.json;
case 'crash':inGameTile.buildIcon; info pagepublic/assets/info/crash.html.
8. Known gaps
- If the server restarts mid-flight, that round's
activewager records are orphaned. - No reduced-motion (static result) mode.