Appearance
8 Ball

Break the rack: reds and colours pay, the ? ball opens the bonus.
- Designer
- player1
- RTP
- 97.00%
- State
- prototype
Opens 97 Plus in demo mode: virtual credits, no sign-up.
How it plays
Details to come.
Original Design document
Created 7 September 2026 · Last modified 17 September 2026
Game design may differ from the content below.

Runtime-loaded plugin game (sibling repo, plugins/8ball symlink). Portrait mock-up: 8baller.png (commit it beside this file).
First game to use a simple physics simulation. Built in stages, factored for re-use.
Table (from the mock-up)
- Square green table, four black corner pockets. A ball is sunk when its centre overlaps a pocket circle.
- A gold ring around the rack. The cue ball rides the lower (yellow) semicircle; the cue stick sits outside the ring, always pointing inward at the rack centre. Start positions are discrete steps along that semicircle (10° apart, 0–180°).
- Rack of 10 balls, inverted triangle, apex nearest the cue ball: six reds, 5 (yellow), 3 (purple), 2 (blue), black ? in the middle.
- Left of the table: the reds-sunk ladder (six slots, rungs at 2x / 3x / 5x / 10x). Above the table: the three coloured balls + 1000.00 bonus panel.
- Standard kit chrome: play (stake), ½, 2×, autoplay, settings.
Rules
- Maths rules (set 2026-09-08, live in the engine; bonus rack re-set 2026-09-11, reds ladder 2026-09-12):
- Reds: 1 → 0.2x, 2 → 0.5x, 3 → 1x, 4 → 2x, 5 or more → 3x the bet. (History: 3/4/5+ → 1/2/3x until 2026-09-11; 2/3/4+ → 1/2/3x for a day. The paying single red would lift the natural hit frequency to ~54%, so the MAIN tilt now also pins the hit rate at 35% of main rounds —
LIBRARY_TARGETS.hitRate, a third tilt feature.) - Coloured balls pay their displayed number × bet (2x, 3x, 5x), added to the reds pay.
- Black ? ball triggers the bonus (Stage 4); pays nothing itself.
- A scratch (cue ball sunk) nulls every win in the round — the bonus trigger included (set 2026-09-09).
- RTP by total-round band (2026-09-12 library): 0× 63% of rounds (hit 37%); RTP shares 0–1× 4.5%, 1–2× 0.3%, 2–5× 25%, 5–10× 45%, 10–25× 7%, ≥25× 15%. (2026-09-11 library, 2-reds rung: hit 27%; 1–2× 6.5%, 2–5× 31%, 5–10× 38%, 10–25× 6%, ≥25× 15%. Original: 0.6 / 33 / 54 / 9 / 0% — ≥25 unreachable at 10× main + 15× bonus.)
- Reds: 1 → 0.2x, 2 → 0.5x, 3 → 1x, 4 → 2x, 5 or more → 3x the bet. (History: 3/4/5+ → 1/2/3x until 2026-09-11; 2/3/4+ → 1/2/3x for a day. The paying single red would lift the natural hit frequency to ~54%, so the MAIN tilt now also pins the hit rate at 35% of main rounds —
- Rack (changed 2026-09-08): rows from the top — red, 5, red, red / red, ?, 3 / 2, red / red. Colours sit inside the rack, not on its corners.
- Superseded from the mock-up reading: the 2x/3x/5x/10x reds ladder and the 1000.00 all-colours bonus.
- Round is fast — a normal play round, tuned by trial and error.
Physics (client-side, reusable)
- Lives entirely in the client. Basic reflection only: moving circles vs moving circles, moving circles vs straight fixed boundaries. Friction so balls stop; hard time cap so a round always ends.
- Fixed timestep, independent of the 30 FPS render, so it can run headless for the sims.
- The server's only contribution is the angle-jitter value (±2° off the aim line). Jitter + start position fully determine the outcome — no other randomness. Since Stage 3 the server picks the jitter from the library for the client's start position, so it also knows the outcome before the client animates it.
- The start position is chosen by bet timing: the next start position in the path of the moving stick when the bet lands.
Stage 1 — Basic setup
Status: built 2026-09-07 in ../project97_8ball (symlinked at plugins/8ball). Physics lives in src/shared/physics/Physics2D.ts, the headless shot in src/shared/EightBallShot.ts (tuning constants there — speed 3000, friction 150, cushions 0.9, balls 0.98 give ~5–6 s rounds); the engine's simulate runs real shots. Settlement is client-reported (mock-only until Stage 3).
- IDLE: the stick swings back and forth along the 180° arc, always pointing at the rack centre.
- On bet: stop the stick at the next position, animate the pull-back, then smack the cue ball into the rack. Balls bounce off each other and the green sides; sunk balls disappear.
- Maths was 1x per ball sunk until 2026-09-08, when the rules above went live in the engine. Don't worry about 97% RTP yet.
Stage 2 — Simulations
Status: built 2026-09-07. DEV menu: Sim Physics (190 shots → JSON) runs generateDataset (src/shared/EightBallDataset.ts, 19 positions × 10 evenly spaced jitters), logs the summary and downloads 8ball-dataset-10x.json; Play Physics loops random rows (no bet) until pressed again, flagging any replay that disagrees with its row. Pays are derived from sunk slots by src/shared/EightBallPays.ts (the same function the engine settles with). Dataset lives at data/8ball-dataset-10x.json.
- Sim Physics (DEV): iterate start positions (every 10°); at each, run the round 10 times varying the jitter within ±2° of the aim line (high resolution). Output per run: start position, jitter, which rack slots sank (and whether the cue ball scratched) — pay is derived afterwards from the slot→colour mapping, so one dataset serves both the main round and the bonus. Also report total pay (mock-up pays) and whether the black ball sank for convenience. Write the set to a
*.json. - Play Physics (DEV): plays randomly picked sims one after another until clicked off.
Stage 3 — Maths mapping
Status: built 2026-09-09. Targets (src/shared/libraryTargets.ts): base 70% + bonus 27% = 97%, per start position, so no position is worth timing for.
npm run build:library(scripts/buildLibrary.ts) runs 1000 shots per start position (evenly spaced jitters across ±2°, ~18 s) and writesdata/8ball-library.json: per position an outcome mask per shot (sunk slots + scratch) and two integer weight tables summing to 1,000,000 — MAIN (E[pay] = 0.70, P(trigger) = 0.27 / 6 = 4.5% — the natural trigger rate, P(pay > 0) = 0.35) and BONUS (E[pay] = 6× — the bonus mean; was 4× / 6.75% until 2026-09-11, raised so the 25 ball's rows keep real weight). Weights are the max-entropy exponential tilt of the uniform jitter draw (EightBallLibrary.tiltWeights, Newton), so every row stays in play; no row carries more than ~2.5%. Re-run after any physics / rack / pay / target change — the library test fails until you do (it checks the physics header, the targets at every position, and replays rows through the live sim).- The round now settles atomically in
requestPlay: the client sends{ startAngleDeg }, the engine draws a MAIN row for that position, pays what it sank, and returns the jitter + outcome + payout asroundData; the client fires the same deterministic shot and reveals the payout once the balls rest (logs a warning if its own physics disagrees with the row). Nothing the client reports is trusted;processInputis unused; there is no recovery state (an unwatched shot was still paid). - The bonus draw exists server-side already (
bonusShot, BONUS table,EightBallPays.BONUS_RACK= five 2s, three 3s, a 5, a 10 on the yellow 5's slot and a 25 on the centre (black ball's) slot — since 2026-09-11; two 5s before);simulateplays it on every trigger, so the maths sim reports the whole game's 97%. The client bonus round is Stage 4. - Natural (untilted) figures for the record: main 0.38–0.98 by position, trigger 1.5–9.3%, bonus mean 1.5–4.1.
Original plan: generate a much larger set — 1000 games per start position — and settle from the library: given the client's start position, pick a jitter whose recorded pay hits the target RTP.
A/B maths balancing
Status: built 2026-09-12 — first game on the host's A/B maths system (.claude/system_mds/ABmaths.md). LIBRARY_MODELS in src/shared/libraryTargets.ts names two maths models, same pays and 97%, different shape: model 1 (re-set 2026-09-12 pm) base 75% + bonus 22%, hit 50%, bonus mean 4× (trigger 5.5%), with the band levers — optional targets mainRtpUnder3 = 0.30 and mainRtp5to10 = 0.20 (main-round pay from wins under 3× / from 5–10×, as a fraction of stake; extra tilt features, EightBallLibrary.mainTilt) — added because hit rate and the base/bonus split alone leave the 5–10× band at 42–44% whatever you do; model 2 base 70% + bonus 27%, hit 25%, bonus mean 9× (trigger 3%), with the bonus band levers bonusPay10to25 = 0.5 and bonusPayOver25 = 8.0 (of the 9× mean; EightBallLibrary.bonusTilt) so its 10–25× band folds into ≥25×. The library carries both models' MAIN + BONUS tables per position (models[]); every round's first draw (8ball.model) picks the model by the host's MathsBalance share, and both of the round's shots come from that model's tables (EightBallMathsState.model). DEV menu Math Balance (beside Maths Sim): a persisted 0–100% slider for model 1 (live in mock only) over each model's two pies; Maths Sim reports the blend. Force Bonus now feeds two cheat floats (model, then main). Candidates are compared with scripts/modelCandidates.ts (300k blended rounds each). Morning set, hit rate + bonus mean only (E = hit 20% / 12× is infeasible — some positions can't reach a 12× bonus mean):
| candidate | hit | trigger | 0–1× | 2–5× | 5–10× | 10–25× | ≥25× |
|---|---|---|---|---|---|---|---|
| A 35% / 6× (model 1 until 2026-09-12 pm) | 36.8% | 4.5% | 4.5 | 25.3 | 44.3 | 7.0 | 15.2 |
| B 25% / 9× (model 2) | 26.5% | 3.0% | 2.4 | 18.8 | 49.7 | 6.1 | 18.3 |
| C 25% / 6× | 27.0% | 4.4% | 2.3 | 19.9 | 51.1 | 7.7 | 15.5 |
| D 45% / 4× | 47.0% | 6.7% | 6.7 | 30.3 | 40.4 | 7.5 | 10.6 |
| F 30% / 9× | 31.5% | 3.0% | 3.4 | 21.9 | 46.3 | 5.9 | 18.9 |
| G 45% / 6× | 46.5% | 4.4% | 6.8 | 28.7 | 39.1 | 6.5 | 15.4 |
Afternoon set for the "shrink 25–50× and 5–10×, grow under 3×" brief — the 5–10× band is 41.5 of the 44.3 points main-shot pay (5-ball rows), so only a lever on it moves it (base+bonus / bonus mean / hit / <3 / 5–10 targets; band columns = RTP share %):
| candidate | hit | 0–1× | 2–3× | 3–5× | 5–10× | 10–25× | 25–50× |
|---|---|---|---|---|---|---|---|
| A (former model 1) | 36.8% | 4.5 | 9.6 | 15.7 | 44.3 | 7.0 | 15.2 |
| N 70+27 / 6× / 45% / .20 / .25 | 46.4% | 6.4 | 13.4 | 25.3 | 28.6 | 6.9 | 15.7 |
| O 70+27 / 6× / 50% / .25 / .20 | 51.4% | 7.5 | 17.2 | 25.4 | 24.1 | 6.8 | 15.6 |
| P 75+22 / 4× / 50% / .25 / .25 | 51.4% | 7.1 | 17.6 | 26.1 | 29.7 | 6.8 | 8.7 |
| Q 75+22 / 4× / 50% / .30 / .20 — model 1 | 51.5% | 6.9 | 22.6 | 26.0 | 25.0 | 6.8 | 9.0 |
| R 75+22 / 4× / 55% / .35 / .15 | 56.4% | 7.9 | 26.4 | 26.2 | 20.1 | 6.7 | 8.6 |
(1–2× is ~0.5% everywhere; ≥50× is unreachable.)
Model 2 set for the "shrink 10–25×, expand ≥25×" brief — both bands are bonus pay, so the levers are on the bonus table (bonus mean / bonus pay from 10–25× shots / from ≥25× shots):
| candidate | trigger | 5–10× | 10–25× | 25–50× |
|---|---|---|---|---|
| B (model 2 before) 9× | 3.0% | 49.7 | 6.1 | 18.3 |
| T 9× / 1.0 / 7.0 | 3.0% | 49.1 | 4.8 | 20.6 |
| U 9× / 0.5 / 7.5 | 3.0% | 48.9 | 3.4 | 22.7 |
| V 9× / 0.5 / 8.0 — model 2 | 3.0% | 48.1 | 3.1 | 24.4 |
| W 10× / 1.0 / 8.5 | 2.7% | 48.4 | 4.4 | 23.8 |
| X 10× / 0.5 / 9.0 | 2.7% | 48.4 | 3.0 | 24.9 |
(the bands below 5× are unchanged at 2.4 / 0.2 / 6.5 / 12; hit 26%.) To swap a model: edit its line in LIBRARY_MODELS, npm run build:library.
Stage 4 — Bonus
Status: built 2026-09-10. A black-ball main shot leaves the round active (EightBallMathsState.main.bonus); the client re-racks in bonus colours (EightBallTable.setRack('bonus') — every slot numbered per BONUS_RACK, coloured like the main rack's ball of that number), the cue sweeps again, and play (autoplay: a 0.9 s timer) parks it and sends { bonusStartAngleDeg } to processInput, which draws the position's BONUS row, pays every sunk ball its number, and closes the round (end.payout = main + bonus). The kit's recovery popup resumes a round found with its bonus unplayed. DEV menu Force Bonus rigs every main shot through the host's per-game RNG cheat channel — a one-shot float per round (forcedBonusFloat) that lands the cue's start position on its first black-ball row, since the row a float reaches differs by position (the generic Force Win is excluded for that reason). History replay has two sub-steps when a bonus was played; the Replay option re-runs both shots. Turbo option (2026-09-13, above Replay in Options): live shots and the round-over pause run turboSpeed× (2.5) faster, on top of autoplay's speed; the slow-motion Replay is unaffected. Persisted per game (turbo) like Replay.
- Triggered when the black ball sinks. Plays after all balls have stopped.
- Intro (built 2026-09-11): once the main shot has settled, the table frosts behind a Big-Win-style backdrop with BONUS in large white type for 1 s and a bell ring (
bell, a host sound inpublic/sounds/, app-scoped so any game's bonus can ring it; a touch anywhere skips the card; ÷ autoplay speed when autoplaying), then the bonus rack is up and the cue sweeps.EightBallBonusIntro(aConfigScene, so the blur is the host's own); stateBONUS_INTROsits between SETTLING and BONUS_IDLE, and recovery enters through it too. - Bonus rack: the same 10-ball triangle with a prize 1–100 on every ball (no reds, no black) — changed 2026-09-16; was a fixed rack of five 2s, three 3s, a 5, a 10 and a 25. While the cue sweeps (BONUS_INTRO + BONUS_IDLE) all ten prizes re-draw every second, for show only (client
Math.random, same weights). On play the server'sprocessInputdraws the real ten (8ball.prizes, onegetRandomValues), then the BONUS row; the reply'sprizesgo up on the balls before the strike, and each sunk ball pays its prize. Ball colour by prize band (EightBallTable.PRIZE_COLOURS: <3 blue, <5 purple, <10 yellow, <25 orange, <50 teal, else magenta). - Prize weights (
EightBallPays.BONUS_PRIZE_WEIGHTS): round(10⁶ / v^1.5) — P(1) 41%, P(≥10) 19%, P(≥25) 8.5%, P(≥50) 3.5%, P(100) 0.04%; mean 7.70. Chosen over v^1.3 (mean 11.3) and v^1.7 (mean 5.3, which piled ~10% of model 2's bonus weight on single rows). Prizes are drawn independently of the row, so the BONUS tables are tilted on balls sunk × mean prize = bonusMean (model 1 4×, model 2 9× — unchanged, so trigger rates and the base/bonus split are too). Model 2's bonus band levers (bonusPay10to25/bonusPayOver25) were dropped: a row no longer fixes its pay. - RTP share by total-round band after the change (
scripts/modelCandidates.ts, 300k rounds): model 1 — 0–1× 6.9, 1–2× 1.3, 2–3× 22.1, 3–5× 25.2, 5–10× 22.2, 10–25× 5.7, 25–50× 5.0, 50–100× 8.0, ≥100× 1.0; model 2 — 2.3 / 0.6 / 6.4 / 12.4 / 50.3 / 7.3 / 6.5 / 8.9 / 1.5 (its ≥25× share falls from 24.4 to ~17). - The stick cycles as in IDLE; the player picks the start position (same bet-timing rule). Every ball sunk pays its number × stake.
- Settled the same way as the main round: the server holds a weighted table of pay outcomes per start position for the bonus, built from the same sim dataset as the main game — same rack geometry, so the physics outcomes are identical; only the colour-to-slot mapping (and therefore the pay) differs. (Built in Stage 3: the BONUS table +
bonusShot; the client round remains.) - The bonus carries 27% of the game's RTP (base 70%; set 2026-09-09, superseding the 10–20% first thought). The main-round tables are weighted for the remainder.