Under Construction
Skip to content

Crash ​

Crash on a phone

A rising multiplier; bank before it busts.

Designer
player1
RTP
97.00%
State
Maths Complete
Play Crash →

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" in assets/tiles.json), flat across every cash-out target (§2).
  • Colours: accent ALIZARIN, CRASH_TILE_BG, CRASH_SCENE_BG, alt CRASH_ALT. Big win at bigWinMultiplier: "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 becomes CrashGame.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 carrying crashPoint or maxCrashPoint outright ('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 an active record (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 how processInput and history replay tell a shared wager from a solo one.
  • processInput
    • { type: 'cashout', multiplier } — rejects a non-finite/negative claim. Settles at min(claim, reached), where reached is the server's own curve: wall clock since startedAtMs (× flightSpeed), or lastMultiplier for 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 reveals crashPoint (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.
  • clientView strips crashPoint, flightSpeed, startedAtMs.
  • reconstructMathsState rebuilds from the start event, dropping startedAtMs (a recovered cashout caps at lastMultiplier).
  • scheduleResolve (mock flow) — bust at crashFlightTimeMs(crashPoint) / flightSpeed. Server-side only; the delay never goes to the client.
  • tick every tickIntervalMs = 250 — the multiplier clamped below the point; persisted as lastMultiplier.
  • simulate — each round draws its own target uniformly from [targetMin, targetMax] (mirrors autoplay) and wins iff crashPoint ≥ target. Uses the same resolveCrashPoint (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, loop

crashed 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, live multiplier, 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 by changeBet, the connection's verified uid) — the bet is never sent. autoCashOut must 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 with mergeCrashFeed), crash {crashPoint, feed} (the authoritative ranked feed). The feed is cash-outs only, highest multiplier first, capped at CRASH_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, no requestPlay, no client bust, no cosmetic spectator curve, no recovery branch.
  • Play in the window: changeBet, then placeBet(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-side crashPointFromH(Math.random()) point — no server round, no stake, silent. Bet/Options/Auto stay live so the player can arm the next round.
  • Wager — changeBet then requestPlay({ autoCashOut }) (only the autoplay target; never point or cap). The bet is deducted server-side; the client mirrors session.balance.
  • Timed bust — IGameEngine.scheduleResolve → GameServiceCore.autoResolve fires the bust at the point's flight time and pushes it through IGameService.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's cashedAt. 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 flightTick when 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 active crash 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_OVER holds roundOverDelayMs (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/) ​

FileResponsibility
index.tsCrashGame — both flows behind this.shared, state machine, autoplay, recovery, history replay.
CrashSharedRound.tsReal mode only: room subscription, phase tracking, client-clock curve anchor, placeBet/cashOut.
CrashCurve.tsAuto-scaling curve, head marker, live readout, countdown bar, cashed/crash treatments, fading cash-out markers.
MultiplierClock.tselapsedMs ↔ multiplier (shared growth rate).
CrashBetConfig.tsOptions: Bet, Bet Change, Game Colour, Alt Colour, Cash-out Markers toggle, Sound.
CrashAutoplayConfig.tsAutoplay: 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 in defaultEngines.ts and project97_server's ServerGameService.
  • NPCs join the room via project97_npcs' CrashRoomWatcher.
  • Tile entry in tiles.json; case 'crash': in GameTile.buildIcon; info page public/assets/info/crash.html.

8. Known gaps ​

  • If the server restarts mid-flight, that round's active wager records are orphaned.
  • No reduced-motion (static result) mode.