Appearance
Plinko

Drop a ball through the pegs into a payout slot.
- Designer
- player1
- RTP
- 97.50%
- 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 16 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. Mines is the reference for the shared machinery (GameKit, GameChrome, bet/autoplay dialogs, history, big-win); Plinko differs in having no in-round decision and several balls in flight at once.
A ball drops through a triangular peg field and lands in one of rows buckets; the bucket's multiplier × bet is the payout. Multipliers are U-shaped: low in the centre (common), high at the edges (rare). A volatility level picks the row count and how extreme the U is.
- RTP: every payout table computes to 97.5% (binomial EV); the tile shows
rtp: "97.0"(assets/tiles.json). - Colours: accent
AMETHYST,PLINKO_TILE_BG,PLINKO_SCENE_BG, altPLINKO_ALT(ball colour). Big win at the defaultbigWinMultiplier(3×; no tiles.json override). - Config:
volatilityonly — the row count is derived from it.
1. Round model — atomic, but acknowledged
The whole drop is resolved in requestPlay → PlinkoEngine.startRound: path, bucket, multiplier, payout, and the closing balance + end events. The client only animates a known result.
The record is nevertheless returned active (it already carries its end event). It flips to complete only when the client calls acknowledgeRound after the ball lands on screen, so a drop the player never saw land is offered for recovery instead of being buried in history.
Because the record already has an end event, GameServiceCore treats it as resolved: a new requestPlay is allowed while earlier balls are still falling, and changeBet isn't blocked by them. That is what makes multi-ball work — N balls = N independent records + N animations. processInput just completes an active record (it keeps quitGame's settle-on-exit working).
2. Engine — PlinkoEngine (src/game-engine/games/plinko/PlinkoEngine.ts)
ts
type PlinkoVolatility = 'super-low' | 'very-low' | 'low' | 'mid' | 'high' | 'very-high' | 'super-high';
interface PlinkoConfig { volatility: PlinkoVolatility }
interface PlinkoMathsState {
volatility; rows;
path: number[]; // rows-1 entries, 0 = left, 1 = right
bucketIndex: number; // sum(path), 0..rows-1
multiplier: string; // e.g. "5.60"
}- Rows by volatility: super-low 6, very-low 8, low 10, mid 12, high 14, very-high 16, super-high 18. An unknown volatility, or a table whose length ≠ rows, throws.
- The ball enters through the top row without deflecting, then deflects once per remaining row:
rows − 1deflections,rowsbuckets, all reachable. All deflections come from one RNG call (plinko.pegDeflection,rows − 1values in [0, 2)). resolveDropis the single implementation of the drop maths;startRoundandsimulateboth call it (IRng draws vsmulberry32).- Outcome:
winif payout > bet,cashout(push) if payout = bet, elseloss. - Events:
start, betbalance,input {drop: true},maths(the full maths state), payoutbalance,end. clientViewreturns the maths state unchanged — the path is needed for the animation, so the outcome is known to the client before the ball lands (accepted; the round is already settled).reconstructMathsStatereads the start event's maths state.
3. Payout tables
Engine DEFAULT_PAYOUT_TABLES, mirrored in assets/plinkoMaths.json (the client passes the asset's tables to the engine and draws bucket labels from them). PlinkoEngine.test.ts pins the two together — they drifted silently once.
| Volatility | Rows | Multipliers (bucket 0 → rows−1) |
|---|---|---|
| super-low | 6 | 2.6, 1.6, 0.5, 0.5, 1.6, 2.6 |
| very-low | 8 | 7.8, 1.7, 1.2, 0.5, 0.5, 1.2, 1.7, 7.8 |
| low | 10 | 15.6, 3.8, 1.7, 0.9, 0.5, 0.5, 0.9, 1.7, 3.8, 15.6 |
| mid | 12 | 40.3, 12, 3.2, 1.7, 0.7, 0.3, 0.3, 0.7, 1.7, 3.2, 12, 40.3 |
| high | 14 | 60, 18, 7.1, 2.8, 1.3, 0.7, 0.3, 0.3, 0.7, 1.3, 2.8, 7.1, 18, 60 |
| very-high | 16 | 388, 113, 22, 6.3, 2.1, 0.9, 0.5, 0.1, 0.1, 0.5, 0.9, 2.1, 6.3, 22, 113, 388 |
| super-high | 18 | 2778, 239, 88, 8.6, 2.8, 1.5, 0.9, 0.5, 0.1, 0.1, 0.5, 0.9, 1.5, 2.8, 8.6, 88, 239, 2778 |
Bucket probability is binomial: P(k) = C(rows−1, k) / 2^(rows−1). Tables are symmetric.
4. Client — PlinkoGame (src/lib_games/plinko/)
| File | Responsibility |
|---|---|
index.ts | PlinkoGame — drops, per-ball lifecycle, balance mirror, volatility, autoplay, recovery, history replay. |
PlinkoFactory.ts | Builds the peg field + bucket row for a row count (peg spacing 32, row height 28). Row r (1..rows) has r+1 pegs; rows buckets sit in the bottom row's gaps. |
Peg.ts / Bucket.ts | Procedural peg; bucket with its label (≥100 → integer, else 1 dp) and landing flash. |
Ball.ts | Plays the path as scripted hops (160 ms each, small upward kick), ticking a peg sound per bounce. |
PlinkoBetConfig.ts | Options: Bet, Volatility (shown as "N rows"), Bet Change, Game Colour, Alt Colour, Sound. |
PlinkoAutoplayConfig.ts | Autoplay: Number of Drops, Turbo, Stop on Win / Loss, On Win / On Loss bet step. |
No round FSM — a ready game with balls in flight
- Drop: Play (or autoplay) →
changeBet→requestPlay({ volatility }). The stake comes off the displayed balance at once so an overlapping drop sees it; each reply resyncs the display to the server's balance, less the payouts of balls still in the air and the stakes of unanswered drops. - Failed drop: asks the server for the balance (
getBalance) and resyncs; refunds locally only if that also fails. Autoplay stops. - Landing: credits the payout on screen, flashes the bucket, plays a tiered landing sound (<1×, ≥1×, ≥2×, ≥5×), releases the close lock, then
acknowledgeRound. The ball is removed 600 ms later. - Manual cooldown:
minDropGapMs(500) between manual drops; Play is disabled meanwhile. - Locks while busy: bet, bet ±, and the volatility arrows work only when idle (no balls, no autoplay, no cooldown, not replaying). A volatility picked in Options while balls fall is deferred until the field is empty. Changing volatility clamps the bet down so the table's top multiplier stays within
maxWinLimit. - Volatility arrows flank the field in landscape; in portrait they sit under Auto / Bet-up. Idle message shows the row count ("12 rows"). The choice persists (AppSettings).
- Big win: a landing ≥
bigWinMultiplier× bet shows the live big win (only one at a time); all balls freeze until it closes. Autoplay auto-dismisses it 1 s after the count-up. - Autoplay: a drop every 350 ms (÷ Turbo speed); stop conditions checked before each drop, bet step applied as each ball lands. Already-falling balls finish.
Recovery
loadGame returns every active drop (Plinko can leave several balls unacknowledged). The client restores the pre-payout balance (the last record's settled balance minus each round's payout), shows one "game in progress" popup, rebuilds the field to their volatility, and re-drops each ball 500 ms apart; each credits its payout and acknowledges on landing as normal. Local active records the server no longer offers are retired (reconcileActiveRounds).
History replay
One record = one drop. The field rebuilds to the record's volatility (restored on exit). STEP releases one hop at a time (reporting left/right); PLAY runs the whole drop. Static big win above the threshold.
Dev menu
Always Left / Always Right (cheat seed; mutually exclusive), Maths Sim (1mil at the current volatility). Generic Force Win is excluded. Auto-playtest adds plinko.volatility (tap an arrow).
5. Config — assets/plinko.json
jsonc
{ "defaultVolatility": "mid", "minDropGapMs": 500, "roundOverDelayMs": 800, "requestTimeoutMs": 5000 }Volatility list and payouts come from plinkoMaths.json via GAME_CONFIG.plinko (GameConfig.ts); bet ladder from the shared betOptions.json. Layout sheet: assets/layouts/plinko.json.
6. Wiring
Lazy loader in builtinGameLoaders.ts; engine in defaultEngines.ts (constructed with the asset's tables) and project97_server; case 'plinko': in GameTile.buildIcon; info page public/assets/info/plinko.html.