Under Construction
Skip to content

Plinko ​

Plinko on a phone

Drop a ball through the pegs into a payout slot.

Designer
player1
RTP
97.50%
State
Maths Complete
Play Plinko →

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, alt PLINKO_ALT (ball colour). Big win at the default bigWinMultiplier (3×; no tiles.json override).
  • Config: volatility only — 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 − 1 deflections, rows buckets, all reachable. All deflections come from one RNG call (plinko.pegDeflection, rows − 1 values in [0, 2)).
  • resolveDrop is the single implementation of the drop maths; startRound and simulate both call it (IRng draws vs mulberry32).
  • Outcome: win if payout > bet, cashout (push) if payout = bet, else loss.
  • Events: start, bet balance, input {drop: true}, maths (the full maths state), payout balance, end.
  • clientView returns 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).
  • reconstructMathsState reads 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.

VolatilityRowsMultipliers (bucket 0 → rows−1)
super-low62.6, 1.6, 0.5, 0.5, 1.6, 2.6
very-low87.8, 1.7, 1.2, 0.5, 0.5, 1.2, 1.7, 7.8
low1015.6, 3.8, 1.7, 0.9, 0.5, 0.5, 0.9, 1.7, 3.8, 15.6
mid1240.3, 12, 3.2, 1.7, 0.7, 0.3, 0.3, 0.7, 1.7, 3.2, 12, 40.3
high1460, 18, 7.1, 2.8, 1.3, 0.7, 0.3, 0.3, 0.7, 1.3, 2.8, 7.1, 18, 60
very-high16388, 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-high182778, 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/) ​

FileResponsibility
index.tsPlinkoGame — drops, per-ball lifecycle, balance mirror, volatility, autoplay, recovery, history replay.
PlinkoFactory.tsBuilds 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.tsProcedural peg; bucket with its label (≥100 → integer, else 1 dp) and landing flash.
Ball.tsPlays the path as scripted hops (160 ms each, small upward kick), ticking a peg sound per bounce.
PlinkoBetConfig.tsOptions: Bet, Volatility (shown as "N rows"), Bet Change, Game Colour, Alt Colour, Sound.
PlinkoAutoplayConfig.tsAutoplay: 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.