← back to Model Arena
auto-save: 2026-07-25T09:05:11 (2 files) — staging-90210/90210-blackjack.html staging-90210/90210-roulette.html
52073ae1cba2bbce249b3e68e72d84463858b742 · 2026-07-25 09:05:17 -0700 · Steve Abrams
Files touched
M staging-90210/90210-blackjack.htmlA staging-90210/90210-roulette.html
Diff
commit 52073ae1cba2bbce249b3e68e72d84463858b742
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Sat Jul 25 09:05:17 2026 -0700
auto-save: 2026-07-25T09:05:11 (2 files) — staging-90210/90210-blackjack.html staging-90210/90210-roulette.html
---
staging-90210/90210-blackjack.html | 1 -
staging-90210/90210-roulette.html | 1159 ++++++++++++++++++++++++++++++++++++
2 files changed, 1159 insertions(+), 1 deletion(-)
diff --git a/staging-90210/90210-blackjack.html b/staging-90210/90210-blackjack.html
index d3d1db8..3d98737 100644
--- a/staging-90210/90210-blackjack.html
+++ b/staging-90210/90210-blackjack.html
@@ -1277,7 +1277,6 @@ function nextHand() {
}
function resetBankroll() {
- if (confirm) { /* no native confirm — just reset silently after a hand */ }
bankroll = 1000;
bestWin = 0;
streak = 0;
diff --git a/staging-90210/90210-roulette.html b/staging-90210/90210-roulette.html
new file mode 100644
index 0000000..af61350
--- /dev/null
+++ b/staging-90210/90210-roulette.html
@@ -0,0 +1,1159 @@
+<!DOCTYPE html>
+<!--
+ 90210 ROULETTE — PLAN
+ =====================
+ American-style roulette (0, 00, 1–36) rendered on a <canvas> element.
+
+ WHEEL:
+ - 38 pockets (0, 00, 1–36) in authentic American layout order.
+ - Drawn with requestAnimationFrame: animated spin, decelerating ball
+ physics with bounce/scatter effect, ball settles in pocket.
+ - Wheel spins one direction; ball counter-rotates then slows + spirals in.
+ - Pockets colored: 0/00 = green, alternating red/black per standard layout.
+
+ BETS:
+ - Inside bets: Straight Up (single number), clicked on the number grid.
+ - Outside bets: Red/Black, Odd/Even, 1–18/19–36, Dozens (1st/2nd/3rd 12),
+ Columns (1/2/3).
+ - Multiple bets per spin, shown as stacked chip icons on the grid.
+ - Chip values: $5, $10, $25, $50, $100.
+ - Payouts: Straight Up 35:1, Outside 1:1, Dozens/Columns 2:1.
+
+ STATE MACHINE: betting → spinning → result → betting
+ - SPIN button launches the wheel animation (~4.5s).
+ - After the ball settles, winning pockets are highlighted and
+ winning bets are paid out. Losing bets disappear.
+ - In-page result banner (no alert).
+
+ PERSISTENCE: bankroll + biggest single win in localStorage.
+ SOUND: Web Audio — ball tick on pocket edge, wheel whirr (oscillator),
+ win chime, lose low note, chip click.
+
+ UI: Gold-on-marble. Same serif/sans system stack as Blackjack sibling.
+ Canvas wheel centered above the number layout grid.
+ Fully touch-compatible — tap to bet, SPIN button.
+-->
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
+<title>90210 Roulette — Beverly Hills Casino</title>
+<style>
+:root {
+ --gold: #c9a961;
+ --gold-light: #e8d49a;
+ --gold-dark: #9a7a3a;
+ --champagne: #f0e6c8;
+ --marble: #f7f3ed;
+ --felt: #1c3d2a;
+ --felt-dark: #122a1c;
+ --ink: #1a1209;
+ --cream: #faf6ef;
+ --red-pocket: #b52a2a;
+ --serif: "Cormorant Garamond","Garamond","Times New Roman",Georgia,serif;
+ --sans: "Gill Sans","Optima","Trebuchet MS",system-ui,sans-serif;
+}
+*, *::before, *::after { box-sizing:border-box; margin:0; padding:0; }
+html, body { height:100%; overflow-x:hidden; }
+
+body {
+ background:#0e1a14;
+ font-family:var(--sans);
+ color:var(--cream);
+ min-height:100%;
+ display:flex;
+ flex-direction:column;
+ align-items:center;
+ user-select:none;
+ -webkit-tap-highlight-color:transparent;
+}
+
+/* ── HEADER ── */
+header {
+ width:100%; text-align:center;
+ padding:16px 20px 10px;
+ border-bottom:1px solid rgba(201,169,97,0.2);
+ background:linear-gradient(180deg,#0a110d,#0e1a14);
+ position:relative;
+}
+.wordmark {
+ font-family:var(--serif);
+ font-size:clamp(20px,4vw,32px);
+ letter-spacing:0.25em;
+ color:var(--gold);
+ text-transform:uppercase;
+ font-weight:400;
+}
+.wordmark span { color:var(--champagne); }
+.tagline {
+ font-family:var(--serif);
+ font-size:clamp(9px,1.5vw,12px);
+ letter-spacing:0.4em;
+ color:rgba(201,169,97,0.5);
+ text-transform:uppercase;
+ margin-top:2px;
+}
+#muteBtn {
+ position:absolute; right:14px; top:50%; transform:translateY(-50%);
+ background:rgba(201,169,97,0.1); border:1px solid rgba(201,169,97,0.3);
+ color:var(--gold); border-radius:50%;
+ width:34px; height:34px; font-size:15px; cursor:pointer;
+ display:flex; align-items:center; justify-content:center;
+ transition:background 0.2s;
+}
+#muteBtn:hover { background:rgba(201,169,97,0.2); }
+
+/* ── BANKROLL BAR ── */
+.bankroll-bar {
+ display:flex; align-items:center; justify-content:center;
+ gap:28px; padding:8px 20px;
+ font-size:clamp(10px,1.8vw,12px); letter-spacing:0.12em;
+ color:rgba(240,230,200,0.6);
+ border-bottom:1px solid rgba(201,169,97,0.08);
+}
+.bankroll-bar .val {
+ font-family:var(--serif);
+ font-size:clamp(14px,2.5vw,19px);
+ color:var(--gold); letter-spacing:0.05em;
+}
+
+/* ── MAIN LAYOUT ── */
+.main-wrap {
+ width:100%; max-width:960px;
+ display:flex; flex-direction:column;
+ align-items:center; gap:0; padding:12px 12px 24px;
+}
+
+/* ── WHEEL CANVAS ── */
+#wheelCanvas {
+ width:clamp(220px,50vw,300px);
+ height:clamp(220px,50vw,300px);
+ border-radius:50%;
+ box-shadow: 0 0 0 4px var(--gold-dark),
+ 0 0 0 8px #0a1a10,
+ 0 0 0 10px rgba(201,169,97,0.3),
+ 0 20px 60px rgba(0,0,0,0.7);
+ display:block; margin:0 auto 10px;
+ cursor:default;
+}
+
+/* ── RESULT BANNER ── */
+#resultBanner {
+ background:rgba(10,18,13,0.95);
+ border:1.5px solid var(--gold);
+ border-radius:8px;
+ padding:8px 20px;
+ font-family:var(--serif);
+ text-align:center;
+ margin:4px 0 8px;
+ min-width:220px;
+ transition:opacity 0.4s;
+ opacity:0;
+ pointer-events:none;
+}
+#resultBanner.show { opacity:1; }
+.rb-number {
+ font-size:clamp(26px,5vw,40px);
+ letter-spacing:0.05em;
+ line-height:1;
+}
+.rb-number.green { color:#3db87a; }
+.rb-number.red { color:#e05050; }
+.rb-number.black { color:var(--champagne); }
+.rb-label {
+ font-size:clamp(10px,1.8vw,13px);
+ letter-spacing:0.3em;
+ color:rgba(240,230,200,0.6);
+ margin-top:2px;
+}
+.rb-payout {
+ font-size:clamp(14px,2.5vw,20px);
+ margin-top:4px;
+ letter-spacing:0.05em;
+}
+.rb-payout.win { color:#80d890; }
+.rb-payout.lose { color:#e07070; }
+.rb-payout.push { color:var(--champagne); }
+
+/* ── STATUS ── */
+#statusMsg {
+ font-family:var(--serif);
+ font-size:clamp(12px,2vw,15px);
+ letter-spacing:0.15em;
+ color:rgba(240,230,200,0.65);
+ text-align:center; min-height:20px;
+ margin-bottom:6px;
+}
+
+/* ── CHIP SELECTOR ── */
+.chip-selector {
+ display:flex; gap:7px; flex-wrap:wrap;
+ justify-content:center; margin-bottom:10px;
+}
+.chip-sel {
+ width:clamp(40px,7vw,50px); height:clamp(40px,7vw,50px);
+ border-radius:50%;
+ border:2.5px dashed rgba(255,255,255,0.3);
+ display:flex; align-items:center; justify-content:center;
+ font-family:var(--serif); font-size:clamp(10px,1.8vw,13px); font-weight:600;
+ cursor:pointer;
+ transition:transform 0.1s, box-shadow 0.15s, border-color 0.15s;
+ box-shadow:0 2px 6px rgba(0,0,0,0.4);
+}
+.chip-sel:hover { transform:scale(1.1); }
+.chip-sel:active { transform:scale(0.95); }
+.chip-sel.active { border-color:white; box-shadow:0 0 12px rgba(255,255,255,0.5); transform:scale(1.08); }
+.chip-sel.c5 { background:radial-gradient(circle at 35% 35%,#e05050,#8b1a1a); color:#fde8e8; }
+.chip-sel.c10 { background:radial-gradient(circle at 35% 35%,#5080e0,#1a3a8b); color:#e8eefe; }
+.chip-sel.c25 { background:radial-gradient(circle at 35% 35%,#50b050,#1a5a1a); color:#e8fde8; }
+.chip-sel.c50 { background:radial-gradient(circle at 35% 35%,#c9a961,#7a5a1a); color:#fdf0d0; }
+.chip-sel.c100 { background:radial-gradient(circle at 35% 35%,#2a2a2a,#111); color:var(--gold); border-color:rgba(201,169,97,0.5); }
+
+/* ── BETTING TABLE ── */
+.table-outer {
+ background:radial-gradient(ellipse 90% 80% at 50% 50%, #26563a 0%, #1c3d2a 55%, #122a1c 100%);
+ border-radius:10px;
+ border:2.5px solid var(--gold-dark);
+ box-shadow:0 0 0 5px #0a1a10, 0 0 0 7px var(--gold-dark),
+ inset 0 2px 8px rgba(0,0,0,0.4),
+ 0 12px 40px rgba(0,0,0,0.6);
+ padding:10px;
+ width:100%;
+ position:relative;
+}
+.table-outer::before {
+ content:''; position:absolute; inset:6px;
+ border:1px solid rgba(201,169,97,0.12);
+ border-radius:6px; pointer-events:none;
+}
+
+/* ── NUMBER GRID ── */
+.number-grid {
+ display:grid;
+ grid-template-columns: 36px repeat(12,1fr);
+ gap:2px;
+ width:100%;
+ margin-bottom:6px;
+}
+
+.cell {
+ aspect-ratio:0.7;
+ border-radius:3px;
+ display:flex; align-items:center; justify-content:center;
+ font-family:var(--serif); font-size:clamp(9px,1.6vw,13px);
+ cursor:pointer;
+ position:relative;
+ transition:opacity 0.1s, transform 0.1s;
+ border:1px solid transparent;
+ min-height:28px;
+}
+.cell:hover { opacity:0.85; transform:scale(1.05); border-color:rgba(255,255,255,0.4); z-index:2; }
+.cell:active { transform:scale(0.95); }
+.cell.num-red { background:var(--red-pocket); color:#fff; }
+.cell.num-black { background:#1a1a1a; color:#fff; }
+.cell.num-green { background:#1a6b32; color:#fff; font-size:clamp(7px,1.3vw,10px); }
+.cell.zero-col { grid-column:1; grid-row:1/4; border-radius:4px 4px 4px 4px;
+ display:flex; flex-direction:column; gap:4px;
+ background:transparent; cursor:default; border:none; }
+.cell.zero-col:hover { transform:none; opacity:1; }
+.zero-single {
+ flex:1; border-radius:3px;
+ display:flex; align-items:center; justify-content:center;
+ font-family:var(--serif); font-size:clamp(8px,1.4vw,11px);
+ cursor:pointer; transition:opacity 0.1s, transform 0.1s;
+ border:1px solid transparent;
+}
+.zero-single:hover { opacity:0.85; transform:scale(1.05); border-color:rgba(255,255,255,0.4); }
+.zero-single:active { transform:scale(0.95); }
+.zero-single.num-green { background:#1a6b32; color:#fff; }
+
+/* winning highlight */
+.cell.winner, .zero-single.winner {
+ outline:2.5px solid var(--gold-light);
+ box-shadow:0 0 12px rgba(201,169,97,0.8);
+ z-index:5;
+ animation:winPulse 0.6s ease 3;
+}
+@keyframes winPulse {
+ 0%,100% { box-shadow:0 0 12px rgba(201,169,97,0.8); }
+ 50% { box-shadow:0 0 24px rgba(201,169,97,1); }
+}
+
+/* ── CHIP INDICATOR ON CELL ── */
+.chip-dot {
+ position:absolute; bottom:2px; right:2px;
+ background:var(--gold); color:var(--ink);
+ border-radius:50%; width:14px; height:14px;
+ display:flex; align-items:center; justify-content:center;
+ font-size:7px; font-weight:bold;
+ box-shadow:0 1px 4px rgba(0,0,0,0.5);
+ pointer-events:none;
+}
+
+/* ── OUTSIDE BETS ── */
+.outside-grid {
+ display:grid;
+ grid-template-columns:36px repeat(6,1fr);
+ gap:2px; width:100%; margin-bottom:4px;
+}
+.outside-grid .spacer { grid-column:1; }
+
+.out-cell {
+ border-radius:3px;
+ padding:5px 4px;
+ display:flex; align-items:center; justify-content:center;
+ font-family:var(--serif); font-size:clamp(8px,1.5vw,12px);
+ letter-spacing:0.05em;
+ cursor:pointer; border:1px solid rgba(201,169,97,0.25);
+ color:var(--champagne);
+ background:rgba(0,0,0,0.3);
+ transition:background 0.15s, transform 0.1s, border-color 0.15s;
+ text-align:center; line-height:1.2;
+ position:relative; min-height:32px;
+}
+.out-cell:hover { background:rgba(201,169,97,0.12); border-color:rgba(201,169,97,0.5); }
+.out-cell:active { transform:scale(0.97); }
+.out-cell.red-bg { background:rgba(140,30,30,0.5); }
+.out-cell.blk-bg { background:rgba(20,20,20,0.7); }
+.out-cell.winner { border-color:var(--gold-light); box-shadow:0 0 10px rgba(201,169,97,0.7); animation:winPulse 0.6s ease 3; }
+
+.dozen-grid {
+ display:grid;
+ grid-template-columns:36px repeat(3,1fr);
+ gap:2px; width:100%; margin-bottom:2px;
+}
+.dozen-grid .spacer { grid-column:1; }
+
+.col-grid {
+ display:grid;
+ grid-template-columns:36px repeat(3,1fr);
+ gap:2px; width:100%;
+}
+.col-grid .spacer { grid-column:1; }
+
+/* ── BOTTOM CONTROLS ── */
+.controls-row {
+ display:flex; gap:10px; justify-content:center;
+ flex-wrap:wrap; margin-top:12px;
+}
+.btn {
+ font-family:var(--serif);
+ font-size:clamp(12px,2vw,15px);
+ letter-spacing:0.18em; text-transform:uppercase;
+ padding:clamp(9px,2vw,12px) clamp(20px,4vw,36px);
+ border-radius:4px; border:none; cursor:pointer;
+ transition:transform 0.1s, box-shadow 0.15s, opacity 0.2s;
+}
+.btn:active { transform:scale(0.96); }
+.btn:disabled { opacity:0.35; cursor:not-allowed; transform:none !important; }
+.btn-spin {
+ background:linear-gradient(135deg,var(--gold-light),var(--gold),var(--gold-dark));
+ color:var(--ink);
+ box-shadow:0 3px 14px rgba(201,169,97,0.4);
+ min-width:120px;
+}
+.btn-spin:hover:not(:disabled) { box-shadow:0 5px 20px rgba(201,169,97,0.6); }
+.btn-clear {
+ background:rgba(201,169,97,0.1);
+ color:var(--gold); border:1px solid rgba(201,169,97,0.35);
+}
+.btn-clear:hover:not(:disabled) { background:rgba(201,169,97,0.18); }
+.btn-reset {
+ background:rgba(160,50,50,0.12);
+ color:#e08888; border:1px solid rgba(180,60,60,0.3);
+}
+.btn-reset:hover:not(:disabled) { background:rgba(160,50,50,0.22); }
+
+/* ── FOOTER ── */
+footer {
+ width:100%; text-align:center;
+ padding:10px 20px; font-size:10px;
+ letter-spacing:0.25em;
+ color:rgba(201,169,97,0.22); text-transform:uppercase;
+ border-top:1px solid rgba(201,169,97,0.07);
+}
+</style>
+</head>
+<body>
+
+<header>
+ <div class="wordmark">90210 <span>Roulette</span></div>
+ <div class="tagline">Beverly Hills Casino Royale</div>
+ <button id="muteBtn" aria-label="Toggle sound" title="Toggle sound">🔊</button>
+</header>
+
+<div class="bankroll-bar">
+ <div>
+ <div style="font-size:0.85em;letter-spacing:0.12em;color:rgba(240,230,200,0.45);margin-bottom:2px">BANKROLL</div>
+ <div class="val" id="bankrollDisplay">$1,000</div>
+ </div>
+ <div>
+ <div style="font-size:0.85em;letter-spacing:0.12em;color:rgba(240,230,200,0.45);margin-bottom:2px">TOTAL BET</div>
+ <div class="val" id="totalBetDisplay">—</div>
+ </div>
+ <div>
+ <div style="font-size:0.85em;letter-spacing:0.12em;color:rgba(240,230,200,0.45);margin-bottom:2px">BEST WIN</div>
+ <div class="val" id="bestWinDisplay">$0</div>
+ </div>
+</div>
+
+<div class="main-wrap">
+
+ <!-- WHEEL -->
+ <canvas id="wheelCanvas" width="400" height="400" aria-label="Roulette wheel"></canvas>
+
+ <!-- RESULT BANNER -->
+ <div id="resultBanner">
+ <div class="rb-number" id="rbNumber">0</div>
+ <div class="rb-label" id="rbLabel">GREEN</div>
+ <div class="rb-payout" id="rbPayout">+$0</div>
+ </div>
+
+ <div id="statusMsg">Select a chip, then click to place bets</div>
+
+ <!-- CHIP SELECTOR -->
+ <div class="chip-selector" id="chipSelector">
+ <div class="chip-sel c5" data-val="5" role="button" tabindex="0" aria-label="$5 chip">$5</div>
+ <div class="chip-sel c10" data-val="10" role="button" tabindex="0" aria-label="$10 chip">$10</div>
+ <div class="chip-sel c25" data-val="25" role="button" tabindex="0" aria-label="$25 chip">$25</div>
+ <div class="chip-sel c50" data-val="50" role="button" tabindex="0" aria-label="$50 chip">$50</div>
+ <div class="chip-sel c100" data-val="100" role="button" tabindex="0" aria-label="$100 chip">$100</div>
+ </div>
+
+ <!-- BETTING TABLE -->
+ <div class="table-outer">
+
+ <!-- Zero column + number grid (3 rows of 12) -->
+ <div class="number-grid" id="numberGrid">
+ <!-- zero column injected by JS -->
+ </div>
+
+ <!-- Dozens -->
+ <div class="dozen-grid" id="dozenGrid">
+ <div class="spacer"></div>
+ <div class="out-cell" data-bet="dozen1" aria-label="1st 12">1st 12</div>
+ <div class="out-cell" data-bet="dozen2" aria-label="2nd 12">2nd 12</div>
+ <div class="out-cell" data-bet="dozen3" aria-label="3rd 12">3rd 12</div>
+ </div>
+
+ <!-- Columns -->
+ <div class="col-grid" id="colGrid">
+ <div class="spacer"></div>
+ <div class="out-cell" data-bet="col1" aria-label="Column 1 (3,6,9…)">Col 1</div>
+ <div class="out-cell" data-bet="col2" aria-label="Column 2 (2,5,8…)">Col 2</div>
+ <div class="out-cell" data-bet="col3" aria-label="Column 3 (1,4,7…)">Col 3</div>
+ </div>
+
+ <!-- Outside even-money bets -->
+ <div class="outside-grid" id="outsideGrid" style="margin-top:4px">
+ <div class="spacer"></div>
+ <div class="out-cell" data-bet="low" aria-label="1 to 18">1–18</div>
+ <div class="out-cell" data-bet="even" aria-label="Even">Even</div>
+ <div class="out-cell red-bg" data-bet="red" aria-label="Red">Red</div>
+ <div class="out-cell blk-bg" data-bet="black" aria-label="Black">Black</div>
+ <div class="out-cell" data-bet="odd" aria-label="Odd">Odd</div>
+ <div class="out-cell" data-bet="high" aria-label="19 to 36">19–36</div>
+ </div>
+
+ </div>
+
+ <!-- CONTROLS -->
+ <div class="controls-row">
+ <button class="btn btn-spin" id="spinBtn" disabled>Spin</button>
+ <button class="btn btn-clear" id="clearBtn">Clear Bets</button>
+ <button class="btn btn-reset" id="resetBtn">↺ Reset</button>
+ </div>
+
+</div>
+
+<footer>90210 Roulette · American Wheel (0, 00) · Straight Up 35:1 · Beverly Hills Casino</footer>
+
+<script>
+// ════════════════════════════════════════════════════
+// CONSTANTS
+// ════════════════════════════════════════════════════
+
+// American roulette wheel order (clockwise from top, pocket index 0 = 0)
+const WHEEL_ORDER = [
+ 0, 28, 9, 26, 30, 11, 7, 20, 32, 17, 5, 22, 34, 15, 3, 24, 36, 13, 1,
+ '00', 27, 10, 25, 29, 12, 8, 19, 31, 18, 6, 21, 33, 16, 4, 23, 35, 14, 2
+];
+const NUM_POCKETS = WHEEL_ORDER.length; // 38
+
+// Standard American roulette red numbers
+const REDS = new Set([1,3,5,7,9,12,14,16,18,19,21,23,25,27,30,32,34,36]);
+
+function pocketColor(n) {
+ if (n === 0 || n === '00') return 'green';
+ return REDS.has(n) ? 'red' : 'black';
+}
+
+// Grid layout: numbers in the betting grid go top-to-bottom per column
+// Row 1: 3,6,9,12,15,18,21,24,27,30,33,36
+// Row 2: 2,5,8,11,14,17,20,23,26,29,32,35
+// Row 3: 1,4,7,10,13,16,19,22,25,28,31,34
+const GRID_ROWS = [
+ [3,6,9,12,15,18,21,24,27,30,33,36],
+ [2,5,8,11,14,17,20,23,26,29,32,35],
+ [1,4,7,10,13,16,19,22,25,28,31,34]
+];
+
+// Payout multipliers (return factor, not including original bet)
+const PAYOUT = {
+ straight: 35,
+ dozen: 2,
+ col: 2,
+ outside: 1
+};
+
+// ════════════════════════════════════════════════════
+// AUDIO ENGINE
+// ════════════════════════════════════════════════════
+const AudioCtx = window.AudioContext || window.webkitAudioContext;
+let actx = null;
+let muted = false;
+
+function getCtx() {
+ if (!actx) actx = new AudioCtx();
+ if (actx.state === 'suspended') actx.resume();
+ return actx;
+}
+
+function playTone({ freq=440, type='sine', vol=0.2, dur=0.15, attack=0.01 }={}) {
+ if (muted) return;
+ try {
+ const c = getCtx();
+ const osc = c.createOscillator();
+ const g = c.createGain();
+ osc.connect(g); g.connect(c.destination);
+ osc.type = type; osc.frequency.value = freq;
+ g.gain.setValueAtTime(0, c.currentTime);
+ g.gain.linearRampToValueAtTime(vol, c.currentTime + attack);
+ g.gain.exponentialRampToValueAtTime(0.001, c.currentTime + dur);
+ osc.start(); osc.stop(c.currentTime + dur + 0.02);
+ } catch(e){}
+}
+
+let wheelWhirr = null;
+function startWhirr() {
+ if (muted || !AudioCtx) return;
+ try {
+ const c = getCtx();
+ wheelWhirr = c.createOscillator();
+ const g = c.createGain();
+ wheelWhirr.connect(g); g.connect(c.destination);
+ wheelWhirr.type = 'sawtooth';
+ wheelWhirr.frequency.setValueAtTime(80, c.currentTime);
+ wheelWhirr.frequency.linearRampToValueAtTime(40, c.currentTime + 4);
+ g.gain.setValueAtTime(0, c.currentTime);
+ g.gain.linearRampToValueAtTime(0.06, c.currentTime + 0.3);
+ g.gain.linearRampToValueAtTime(0.03, c.currentTime + 3.5);
+ g.gain.linearRampToValueAtTime(0, c.currentTime + 4.5);
+ wheelWhirr.start();
+ wheelWhirr.stop(c.currentTime + 5);
+ } catch(e){}
+}
+
+const sounds = {
+ chip() { playTone({ freq:1600, type:'triangle', vol:0.08, dur:0.07, attack:0.005 }); },
+ tick() { playTone({ freq:1200, type:'square', vol:0.05, dur:0.04, attack:0.002 }); },
+ settle() {
+ playTone({ freq:400, type:'sine', vol:0.12, dur:0.25 });
+ setTimeout(()=>playTone({ freq:500, type:'sine', vol:0.08, dur:0.2 }), 80);
+ },
+ win() {
+ [0,100,220,380].forEach((ms,i)=>{
+ setTimeout(()=>playTone({ freq:[523,659,784,1047][i], type:'triangle', vol:0.18-i*0.02, dur:0.28 }), ms);
+ });
+ },
+ bigWin() {
+ [0,80,180,300,450].forEach((ms,i)=>{
+ setTimeout(()=>playTone({ freq:[523,659,784,1047,1319][i], type:'sine', vol:0.2-i*0.02, dur:0.35 }), ms);
+ });
+ },
+ lose() {
+ playTone({ freq:200, type:'sawtooth', vol:0.1, dur:0.4 });
+ setTimeout(()=>playTone({ freq:160, type:'sawtooth', vol:0.07, dur:0.35 }), 200);
+ },
+ click() { playTone({ freq:700, type:'sine', vol:0.05, dur:0.05 }); }
+};
+
+// ════════════════════════════════════════════════════
+// STATE
+// ════════════════════════════════════════════════════
+let bankroll = 1000;
+let bestWin = 0;
+let selectedChip = 25;
+let bets = {}; // key → amount (key = 'n_7', 'red', 'dozen1', 'col2', etc.)
+let phase = 'betting'; // 'betting' | 'spinning' | 'result'
+let lastResult = null;
+
+function saveState() {
+ localStorage.setItem('rl90210_bankroll', bankroll);
+ localStorage.setItem('rl90210_bestWin', bestWin);
+}
+function loadState() {
+ bankroll = parseInt(localStorage.getItem('rl90210_bankroll') || '1000');
+ bestWin = parseInt(localStorage.getItem('rl90210_bestWin') || '0');
+ if (isNaN(bankroll) || bankroll < 1) bankroll = 1000;
+ if (isNaN(bestWin)) bestWin = 0;
+}
+
+function totalBet() {
+ return Object.values(bets).reduce((s, v) => s + v, 0);
+}
+
+// ════════════════════════════════════════════════════
+// WHEEL CANVAS RENDERER
+// ════════════════════════════════════════════════════
+const canvas = document.getElementById('wheelCanvas');
+const ctx2 = canvas.getContext('2d');
+const CX = canvas.width / 2;
+const CY = canvas.height / 2;
+const R_OUTER = CX - 6;
+const R_INNER = CX * 0.68;
+const R_BALL = CX * 0.56;
+const R_NUM = CX * 0.82;
+const R_HUB = CX * 0.18;
+
+let wheelAngle = 0; // radians, current rotation of wheel
+let ballAngle = 0; // radians, current position of ball on track
+let ballR = 0; // current radius of ball (spirals inward)
+let spinning = false;
+let wheelSpeed = 0; // rad/frame wheel
+let ballSpeed = 0; // rad/frame ball (counter-rotating)
+let spinTarget = 0; // total angle wheel will travel
+let spinElapsed = 0;
+let spinDuration = 0;
+let resultPocketIdx = -1; // which pocket ball lands in
+let rafId = null;
+let ballSettled = false;
+let settleTimer = 0;
+let tickTimer = 0;
+
+// Pocket angle: pocket i is at angle (i / NUM_POCKETS) * 2π from top of wheel
+// We rotate the wheel, so the pocket at physical position = wheelAngle + i*(2π/38)
+function pocketAngle(i) {
+ return (i / NUM_POCKETS) * Math.PI * 2;
+}
+
+function drawWheel(winPocket = -1) {
+ ctx2.clearRect(0, 0, canvas.width, canvas.height);
+
+ // Outer rim
+ ctx2.save();
+ ctx2.beginPath();
+ ctx2.arc(CX, CY, R_OUTER, 0, Math.PI * 2);
+ const rimGrad = ctx2.createRadialGradient(CX-20, CY-20, R_INNER, CX, CY, R_OUTER);
+ rimGrad.addColorStop(0, '#d4a44c');
+ rimGrad.addColorStop(0.5, '#c9a961');
+ rimGrad.addColorStop(1, '#7a5a1a');
+ ctx2.fillStyle = rimGrad;
+ ctx2.fill();
+ ctx2.restore();
+
+ // Pockets
+ ctx2.save();
+ ctx2.translate(CX, CY);
+ ctx2.rotate(wheelAngle - Math.PI / 2); // start from top
+ const sliceAngle = (Math.PI * 2) / NUM_POCKETS;
+
+ for (let i = 0; i < NUM_POCKETS; i++) {
+ const start = i * sliceAngle - sliceAngle / 2;
+ const end = start + sliceAngle;
+ const n = WHEEL_ORDER[i];
+ const col = pocketColor(n);
+
+ // Pocket background
+ ctx2.beginPath();
+ ctx2.moveTo(0, 0);
+ ctx2.arc(0, 0, R_INNER, start, end);
+ ctx2.closePath();
+
+ if (winPocket === i) {
+ ctx2.fillStyle = '#e8d49a';
+ } else if (col === 'green') {
+ ctx2.fillStyle = '#1a6b32';
+ } else if (col === 'red') {
+ ctx2.fillStyle = '#9e2222';
+ } else {
+ ctx2.fillStyle = '#111';
+ }
+ ctx2.fill();
+ ctx2.strokeStyle = 'rgba(201,169,97,0.25)';
+ ctx2.lineWidth = 0.5;
+ ctx2.stroke();
+
+ // Number label
+ const midAngle = i * sliceAngle;
+ const tx = Math.cos(midAngle) * R_NUM;
+ const ty = Math.sin(midAngle) * R_NUM;
+ ctx2.save();
+ ctx2.translate(tx, ty);
+ ctx2.rotate(midAngle + Math.PI / 2);
+ ctx2.fillStyle = (winPocket === i) ? '#1a1209' : '#fff';
+ ctx2.font = `bold ${Math.floor(CX * 0.065)}px serif`;
+ ctx2.textAlign = 'center';
+ ctx2.textBaseline = 'middle';
+ ctx2.fillText(String(n), 0, 0);
+ ctx2.restore();
+ }
+ ctx2.restore();
+
+ // Center hub
+ ctx2.save();
+ ctx2.beginPath();
+ ctx2.arc(CX, CY, R_HUB, 0, Math.PI * 2);
+ const hubGrad = ctx2.createRadialGradient(CX - R_HUB*0.3, CY - R_HUB*0.3, 2, CX, CY, R_HUB);
+ hubGrad.addColorStop(0, '#e8d49a');
+ hubGrad.addColorStop(0.5, '#c9a961');
+ hubGrad.addColorStop(1, '#7a5a1a');
+ ctx2.fillStyle = hubGrad;
+ ctx2.fill();
+ ctx2.strokeStyle = '#5a3a0a';
+ ctx2.lineWidth = 1.5;
+ ctx2.stroke();
+ ctx2.restore();
+
+ // Ball
+ if (spinning || ballSettled) {
+ const bx = CX + Math.cos(ballAngle) * ballR;
+ const by = CY + Math.sin(ballAngle) * ballR;
+
+ // Ball shadow
+ ctx2.save();
+ ctx2.beginPath();
+ ctx2.arc(bx + 2, by + 2, CX * 0.038, 0, Math.PI * 2);
+ ctx2.fillStyle = 'rgba(0,0,0,0.35)';
+ ctx2.fill();
+ ctx2.restore();
+
+ // Ball
+ ctx2.save();
+ ctx2.beginPath();
+ ctx2.arc(bx, by, CX * 0.038, 0, Math.PI * 2);
+ const ballGrad = ctx2.createRadialGradient(bx - CX*0.015, by - CX*0.015, 1, bx, by, CX*0.038);
+ ballGrad.addColorStop(0, '#fff');
+ ballGrad.addColorStop(0.4, '#e8e8e8');
+ ballGrad.addColorStop(1, '#bbb');
+ ctx2.fillStyle = ballGrad;
+ ctx2.fill();
+ ctx2.restore();
+ }
+
+ // Pointer/marker at top
+ ctx2.save();
+ ctx2.beginPath();
+ ctx2.moveTo(CX, CY - R_OUTER + 2);
+ ctx2.lineTo(CX - 6, CY - R_INNER - 8);
+ ctx2.lineTo(CX + 6, CY - R_INNER - 8);
+ ctx2.closePath();
+ ctx2.fillStyle = var90210Gold;
+ ctx2.fill();
+ ctx2.restore();
+}
+
+const var90210Gold = '#c9a961';
+
+// ════════════════════════════════════════════════════
+// SPIN ANIMATION
+// ════════════════════════════════════════════════════
+function startSpin(pocketIdx) {
+ resultPocketIdx = pocketIdx;
+ spinning = true;
+ ballSettled = false;
+ settleTimer = 0;
+ tickTimer = 0;
+
+ wheelSpeed = 0.06; // fast initial wheel speed (rad/frame)
+ ballSpeed = -0.09; // counter-rotating ball
+ ballR = R_BALL;
+ ballAngle = Math.random() * Math.PI * 2; // random start position
+
+ // We'll run for ~280 frames (~4.7s at 60fps)
+ spinDuration = 280;
+ spinElapsed = 0;
+
+ startWhirr();
+ rafId = requestAnimationFrame(spinFrame);
+}
+
+function spinFrame() {
+ spinElapsed++;
+ const t = spinElapsed / spinDuration; // 0→1
+ const eased = t < 1 ? t : 1;
+
+ // Ease out: speed decreases using cosine
+ const factor = Math.max(0, Math.cos(eased * Math.PI * 0.5));
+
+ wheelSpeed = 0.06 * factor;
+ ballSpeed = -0.09 * Math.max(0.1, factor * 1.2);
+
+ wheelAngle += wheelSpeed;
+ ballAngle += ballSpeed;
+
+ // Ball spirals inward as it slows
+ const inwardProgress = Math.pow(Math.max(0, eased - 0.5) / 0.5, 1.8);
+ ballR = R_BALL - (R_BALL - R_INNER * 0.85) * inwardProgress;
+
+ // Tick sound every ~8 pockets
+ tickTimer++;
+ if (tickTimer >= Math.max(4, 12 - Math.floor(eased * 8))) {
+ tickTimer = 0;
+ sounds.tick();
+ }
+
+ drawWheel();
+
+ if (spinElapsed >= spinDuration) {
+ // Snap ball to correct pocket
+ const targetPocketAngle = pocketAngle(resultPocketIdx) + wheelAngle - Math.PI / 2;
+ ballAngle = targetPocketAngle;
+ ballR = R_INNER * 0.72; // settled in pocket
+ ballSettled = true;
+ spinning = false;
+
+ drawWheel(resultPocketIdx);
+ sounds.settle();
+ setTimeout(onSpinComplete, 400);
+ return;
+ }
+
+ rafId = requestAnimationFrame(spinFrame);
+}
+
+// ════════════════════════════════════════════════════
+// BET RESOLUTION
+// ════════════════════════════════════════════════════
+function resolveNumber(n) {
+ // n is the actual number (0, '00', or 1-36)
+ const color = pocketColor(n);
+ let netGain = 0;
+ let hasAnyWin = false;
+
+ for (const [key, amount] of Object.entries(bets)) {
+ let win = false;
+ let mult = 0;
+
+ if (key.startsWith('n_')) {
+ const betNum = key.slice(2);
+ const betVal = betNum === '00' ? '00' : parseInt(betNum);
+ win = (betVal === n);
+ mult = PAYOUT.straight;
+ } else if (key === 'red') { win = color === 'red'; mult = PAYOUT.outside; }
+ else if (key === 'black') { win = color === 'black'; mult = PAYOUT.outside; }
+ else if (key === 'even') { win = typeof n === 'number' && n > 0 && n % 2 === 0; mult = PAYOUT.outside; }
+ else if (key === 'odd') { win = typeof n === 'number' && n > 0 && n % 2 === 1; mult = PAYOUT.outside; }
+ else if (key === 'low') { win = typeof n === 'number' && n >= 1 && n <= 18; mult = PAYOUT.outside; }
+ else if (key === 'high') { win = typeof n === 'number' && n >= 19 && n <= 36; mult = PAYOUT.outside; }
+ else if (key === 'dozen1') { win = typeof n === 'number' && n >= 1 && n <= 12; mult = PAYOUT.dozen; }
+ else if (key === 'dozen2') { win = typeof n === 'number' && n >= 13 && n <= 24; mult = PAYOUT.dozen; }
+ else if (key === 'dozen3') { win = typeof n === 'number' && n >= 25 && n <= 36; mult = PAYOUT.dozen; }
+ else if (key === 'col1') { win = typeof n === 'number' && n > 0 && n % 3 === 0; mult = PAYOUT.col; } // 3,6,9…
+ else if (key === 'col2') { win = typeof n === 'number' && n > 0 && n % 3 === 2; mult = PAYOUT.col; } // 2,5,8…
+ else if (key === 'col3') { win = typeof n === 'number' && n > 0 && n % 3 === 1; mult = PAYOUT.col; } // 1,4,7…
+
+ if (win) {
+ netGain += amount * mult; // profit
+ bankroll += amount * (mult + 1); // return bet + profit
+ hasAnyWin = true;
+ }
+ // losing bets stay deducted (already removed from bankroll on placement)
+ }
+
+ return { netGain, hasAnyWin };
+}
+
+function onSpinComplete() {
+ const n = lastResult; // the number
+ const color = pocketColor(n);
+ const { netGain, hasAnyWin } = resolveNumber(n);
+
+ if (netGain > bestWin) bestWin = netGain;
+ saveState();
+ updateBankrollUI();
+
+ // Show result banner
+ const numEl = document.getElementById('rbNumber');
+ const labelEl = document.getElementById('rbLabel');
+ const payoutEl = document.getElementById('rbPayout');
+
+ numEl.textContent = String(n);
+ numEl.className = 'rb-number ' + color;
+ labelEl.textContent = color.toUpperCase();
+
+ const tb = totalBet();
+ if (hasAnyWin && netGain > 0) {
+ payoutEl.textContent = `+${fmt(netGain)}`;
+ payoutEl.className = 'rb-payout win';
+ netGain > 200 ? sounds.bigWin() : sounds.win();
+ } else {
+ payoutEl.textContent = `−${fmt(tb)}`;
+ payoutEl.className = 'rb-payout lose';
+ sounds.lose();
+ }
+
+ document.getElementById('resultBanner').classList.add('show');
+ highlightWinners(n);
+
+ phase = 'result';
+ setStatus('Click SPIN or place new bets for another round');
+
+ // After a pause, re-enable betting
+ setTimeout(() => {
+ bets = {};
+ clearBetUI();
+ phase = 'betting';
+ document.getElementById('spinBtn').disabled = true;
+ setStatus('Place your bets');
+ if (bankroll <= 0) {
+ bankroll = 1000;
+ saveState();
+ updateBankrollUI();
+ setStatus('Bankrupt — reset to $1,000');
+ }
+ }, 3500);
+}
+
+// ════════════════════════════════════════════════════
+// DOM BUILDING
+// ════════════════════════════════════════════════════
+function buildGrid() {
+ const grid = document.getElementById('numberGrid');
+ grid.innerHTML = '';
+
+ // Zero column (two cells: 0 and 00, stacked)
+ const zeroCol = document.createElement('div');
+ zeroCol.className = 'cell zero-col';
+
+ ['0', '00'].forEach(z => {
+ const el = document.createElement('div');
+ el.className = 'zero-single num-green';
+ el.textContent = z;
+ el.dataset.bet = 'n_' + z;
+ el.setAttribute('role', 'button');
+ el.setAttribute('tabindex', '0');
+ el.setAttribute('aria-label', 'Bet on ' + z);
+ zeroCol.appendChild(el);
+ });
+ grid.appendChild(zeroCol);
+
+ // Number cells: 3 rows × 12 cols
+ for (let row = 0; row < 3; row++) {
+ for (let col = 0; col < 12; col++) {
+ const n = GRID_ROWS[row][col];
+ const col_class = REDS.has(n) ? 'num-red' : 'num-black';
+ const el = document.createElement('div');
+ el.className = `cell ${col_class}`;
+ el.textContent = n;
+ el.dataset.bet = 'n_' + n;
+ el.style.gridColumn = col + 2;
+ el.style.gridRow = row + 1;
+ el.setAttribute('role', 'button');
+ el.setAttribute('tabindex', '0');
+ el.setAttribute('aria-label', 'Bet on ' + n);
+ grid.appendChild(el);
+ }
+ }
+}
+
+function attachBetListeners() {
+ // Number cells + zero singles
+ document.querySelectorAll('[data-bet]').forEach(el => {
+ el.addEventListener('click', () => placeBet(el.dataset.bet, el));
+ el.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); placeBet(el.dataset.bet, el); } });
+ });
+}
+
+function placeBet(key, el) {
+ if (phase !== 'betting') return;
+ if (selectedChip > bankroll - totalBet()) {
+ setStatus('Not enough bankroll');
+ return;
+ }
+ bets[key] = (bets[key] || 0) + selectedChip;
+ sounds.chip();
+ updateChipDot(key, el, bets[key]);
+ document.getElementById('spinBtn').disabled = false;
+ updateBankrollUI();
+ setStatus('');
+}
+
+function updateChipDot(key, el, total) {
+ // find or create chip indicator
+ let dot = el.querySelector('.chip-dot');
+ if (!dot) {
+ dot = document.createElement('div');
+ dot.className = 'chip-dot';
+ el.appendChild(dot);
+ }
+ dot.textContent = total >= 1000 ? (total/1000).toFixed(1)+'k' : total;
+}
+
+function clearBetUI() {
+ document.querySelectorAll('.chip-dot').forEach(d => d.remove());
+ document.querySelectorAll('.winner').forEach(el => el.classList.remove('winner'));
+ document.getElementById('resultBanner').classList.remove('show');
+ updateBankrollUI();
+}
+
+function highlightWinners(n) {
+ const betKey = 'n_' + (n === '00' ? '00' : n);
+ const el = document.querySelector(`[data-bet="${betKey}"]`);
+ if (el) el.classList.add('winner');
+
+ // Outside bets
+ const color = pocketColor(n);
+ if (color === 'red') highlightOutside('red');
+ if (color === 'black') highlightOutside('black');
+ if (typeof n === 'number' && n > 0) {
+ if (n % 2 === 0) highlightOutside('even');
+ if (n % 2 === 1) highlightOutside('odd');
+ if (n >= 1 && n <= 18) highlightOutside('low');
+ if (n >= 19 && n <= 36) highlightOutside('high');
+ if (n >= 1 && n <= 12) highlightOutside('dozen1');
+ if (n >= 13 && n <= 24) highlightOutside('dozen2');
+ if (n >= 25 && n <= 36) highlightOutside('dozen3');
+ if (n % 3 === 0) highlightOutside('col1');
+ if (n % 3 === 2) highlightOutside('col2');
+ if (n % 3 === 1) highlightOutside('col3');
+ }
+}
+
+function highlightOutside(key) {
+ const el = document.querySelector(`[data-bet="${key}"]`);
+ if (el) el.classList.add('winner');
+}
+
+// ════════════════════════════════════════════════════
+// UI HELPERS
+// ════════════════════════════════════════════════════
+function fmt(n) { return '$' + n.toLocaleString(); }
+
+function updateBankrollUI() {
+ document.getElementById('bankrollDisplay').textContent = fmt(bankroll);
+ const tb = totalBet();
+ document.getElementById('totalBetDisplay').textContent = tb > 0 ? fmt(tb) : '—';
+ document.getElementById('bestWinDisplay').textContent = fmt(bestWin);
+}
+
+function setStatus(msg) { document.getElementById('statusMsg').textContent = msg; }
+
+// ════════════════════════════════════════════════════
+// CHIP SELECTOR
+// ════════════════════════════════════════════════════
+document.querySelectorAll('.chip-sel').forEach(chip => {
+ chip.addEventListener('click', () => {
+ document.querySelectorAll('.chip-sel').forEach(c => c.classList.remove('active'));
+ chip.classList.add('active');
+ selectedChip = parseInt(chip.dataset.val);
+ sounds.click();
+ });
+});
+// Default active chip
+document.querySelector('.chip-sel[data-val="25"]').classList.add('active');
+
+// ════════════════════════════════════════════════════
+// SPIN
+// ════════════════════════════════════════════════════
+document.getElementById('spinBtn').addEventListener('click', () => {
+ if (phase !== 'betting') return;
+ if (Object.keys(bets).length === 0) { setStatus('Place a bet first'); return; }
+
+ // Deduct all bets from bankroll now
+ const tb = totalBet();
+ if (tb > bankroll) { setStatus('Not enough bankroll'); return; }
+ bankroll -= tb;
+ saveState();
+ updateBankrollUI();
+
+ // Pick random pocket
+ const pocketIdx = Math.floor(Math.random() * NUM_POCKETS);
+ lastResult = WHEEL_ORDER[pocketIdx];
+
+ phase = 'spinning';
+ document.getElementById('spinBtn').disabled = true;
+ setStatus('No more bets…');
+
+ sounds.click();
+ drawWheel(); // clear any winner highlights
+ startSpin(pocketIdx);
+});
+
+// ════════════════════════════════════════════════════
+// CLEAR / RESET
+// ════════════════════════════════════════════════════
+document.getElementById('clearBtn').addEventListener('click', () => {
+ if (phase !== 'betting') return;
+ sounds.click();
+ bets = {};
+ clearBetUI();
+ document.getElementById('spinBtn').disabled = true;
+ setStatus('Bets cleared');
+ updateBankrollUI();
+});
+
+document.getElementById('resetBtn').addEventListener('click', () => {
+ bankroll = 1000;
+ bestWin = 0;
+ bets = {};
+ phase = 'betting';
+ if (rafId) cancelAnimationFrame(rafId);
+ spinning = false;
+ ballSettled = false;
+ clearBetUI();
+ drawWheel();
+ document.getElementById('spinBtn').disabled = true;
+ setStatus('Bankroll reset to $1,000');
+ saveState();
+ updateBankrollUI();
+ sounds.click();
+});
+
+// ════════════════════════════════════════════════════
+// MUTE
+// ════════════════════════════════════════════════════
+document.getElementById('muteBtn').addEventListener('click', () => {
+ muted = !muted;
+ document.getElementById('muteBtn').textContent = muted ? '🔇' : '🔊';
+});
+
+// ════════════════════════════════════════════════════
+// KEYBOARD SHORTCUTS
+// ════════════════════════════════════════════════════
+document.addEventListener('keydown', e => {
+ if (e.repeat) return;
+ const k = e.key.toLowerCase();
+ if (k === ' ' || k === 'enter') {
+ e.preventDefault();
+ if (phase === 'betting') document.getElementById('spinBtn').click();
+ }
+ if (k === 'c') document.getElementById('clearBtn').click();
+ if (k === 'm') document.getElementById('muteBtn').click();
+});
+
+// ════════════════════════════════════════════════════
+// IDLE WHEEL ANIMATION (slow rotation when not spinning)
+// ════════════════════════════════════════════════════
+let idleRaf = null;
+function idleFrame() {
+ if (!spinning) {
+ wheelAngle += 0.003;
+ drawWheel();
+ }
+ idleRaf = requestAnimationFrame(idleFrame);
+}
+
+// ════════════════════════════════════════════════════
+// INIT
+// ════════════════════════════════════════════════════
+loadState();
+buildGrid();
+attachBetListeners();
+updateBankrollUI();
+drawWheel();
+idleFrame();
+setStatus('Select a chip, then click to place bets');
+</script>
+</body>
+</html>
← 682fd46 Run on Claude Max subscription only: drop paid API models (k
·
back to Model Arena
·
Agent-driven high-detail: DW Mood Board Studio (frontend-dev 35ba974 →