← back to Nineoh Guide
apps/web/public/games/blackjack.html
1354 lines
<!DOCTYPE html>
<!--
90210 BLACKJACK — PLAN
======================
Single-deck blackjack vs. house dealer.
RULES:
- Standard single deck (52 cards), reshuffled when below 15 cards remain.
- Player: hit, stand, double down (first two cards only, costs extra bet from bankroll).
- Dealer: hits on soft 16 or less, stands on hard/soft 17+.
- Aces count as 11 unless that would bust, then 1.
- Blackjack (natural 21 on first two cards) pays 3:2.
- Double down: bet doubled, exactly one more card drawn.
- Push (tie): bet returned.
- No split, no insurance (keeps logic clean and game tight).
STATE MACHINE: idle → betting → dealing → player-turn → dealer-turn → result
- Bet is placed by clicking chip buttons before deal.
- Card deal is animated via CSS transform (fly-in from deck position).
- Dealer's hole card is face-down until dealer's turn.
- Bankroll + best win streak persisted in localStorage.
SOUND: Web Audio API — card deal tick, chip clink, win fanfare, lose thud,
all procedurally generated. Mute toggle top-right.
UI: Gold-on-marble table. No CDN, no external assets. Everything inline.
Serif display font via system stack: "Cormorant Garamond", Georgia, serif.
Sans UI font: "Gill Sans", "Optima", sans-serif.
Colors: --gold #c9a961, --champagne #f0e6c8, --marble #f7f3ed,
--felt #1a3a2a (table felt), --ink #1a1209, --cream #faf6ef.
-->
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<title>90210 Blackjack — Beverly Hills Casino</title>
<style>
:root {
--gold: #c9a961;
--gold-light: #e8d49a;
--gold-dark: #9a7a3a;
--champagne: #f0e6c8;
--marble: #f7f3ed;
--marble2: #ede8e0;
--felt: #1c3d2a;
--felt-dark: #122a1c;
--felt-rail: #0f2018;
--ink: #1a1209;
--cream: #faf6ef;
--red-card: #8b1a1a;
--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; }
body {
background: #0e1a14;
font-family: var(--sans);
color: var(--cream);
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
overflow-x: hidden;
user-select: none;
}
/* ─── HEADER ─── */
header {
width: 100%;
text-align: center;
padding: 18px 20px 12px;
border-bottom: 1px solid rgba(201,169,97,0.25);
position: relative;
background: linear-gradient(180deg, #0a110d 0%, #0e1a14 100%);
}
.wordmark {
font-family: var(--serif);
font-size: clamp(22px, 4vw, 34px);
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(10px, 1.6vw, 13px);
letter-spacing: 0.4em;
color: rgba(201,169,97,0.55);
text-transform: uppercase;
margin-top: 3px;
}
#muteBtn {
position: absolute;
right: 16px;
top: 50%;
transform: translateY(-50%);
background: rgba(201,169,97,0.12);
border: 1px solid rgba(201,169,97,0.3);
color: var(--gold);
border-radius: 50%;
width: 36px;
height: 36px;
font-size: 16px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s;
}
#muteBtn:hover { background: rgba(201,169,97,0.22); }
/* ─── BANKROLL BAR ─── */
.bankroll-bar {
display: flex;
align-items: center;
justify-content: center;
gap: 32px;
padding: 10px 20px;
font-size: clamp(11px, 2vw, 13px);
letter-spacing: 0.12em;
color: rgba(240,230,200,0.7);
border-bottom: 1px solid rgba(201,169,97,0.1);
}
.bankroll-bar .val {
font-family: var(--serif);
font-size: clamp(15px, 2.5vw, 20px);
color: var(--gold);
letter-spacing: 0.05em;
}
/* ─── TABLE ─── */
.table-wrap {
flex: 1;
width: 100%;
max-width: 900px;
display: flex;
flex-direction: column;
align-items: center;
padding: 0 16px 20px;
}
.felt-table {
width: 100%;
background: radial-gradient(ellipse 80% 70% at 50% 45%, #26563a 0%, #1c3d2a 50%, #122a1c 100%);
border-radius: 16px;
border: 3px solid var(--gold-dark);
box-shadow:
0 0 0 6px #0a1a10,
0 0 0 8px var(--gold-dark),
inset 0 2px 8px rgba(0,0,0,0.5),
0 20px 60px rgba(0,0,0,0.7);
padding: 20px 24px 24px;
margin-top: 16px;
position: relative;
min-height: 400px;
display: flex;
flex-direction: column;
gap: 12px;
}
/* table rail ornament */
.felt-table::before {
content: '';
position: absolute;
inset: 8px;
border-radius: 10px;
border: 1px solid rgba(201,169,97,0.15);
pointer-events: none;
}
.zone-label {
font-family: var(--serif);
font-size: clamp(9px, 1.5vw, 11px);
letter-spacing: 0.35em;
color: rgba(201,169,97,0.35);
text-transform: uppercase;
text-align: center;
margin-bottom: 4px;
}
/* ─── CARD ZONES ─── */
.hand-zone {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
flex: 1;
}
.hand-row {
display: flex;
justify-content: center;
align-items: center;
min-height: 120px;
gap: 0;
position: relative;
}
/* ─── CARDS ─── */
.card {
width: clamp(56px, 8vw, 72px);
height: clamp(80px, 11.4vw, 102px);
border-radius: 6px;
border: 1.5px solid rgba(180,160,120,0.3);
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 4px 5px;
font-family: var(--serif);
font-size: clamp(14px, 2.5vw, 18px);
position: relative;
margin-left: -14px;
transition: transform 0.15s ease;
flex-shrink: 0;
box-shadow: 2px 3px 8px rgba(0,0,0,0.5);
/* animation origin */
transform-origin: center bottom;
animation: cardDeal 0.35s cubic-bezier(0.22, 1, 0.36, 1) forwards;
}
.card:first-child { margin-left: 0; }
@keyframes cardDeal {
from {
opacity: 0;
transform: translateY(-40px) scale(0.7) rotate(-8deg);
}
to {
opacity: 1;
transform: translateY(0) scale(1) rotate(0deg);
}
}
.card.face-up {
background: linear-gradient(145deg, #fdfaf4 0%, #f5efe3 100%);
color: var(--ink);
}
.card.face-down {
background:
repeating-linear-gradient(
45deg,
#1a3d2a 0px, #1a3d2a 3px,
#15321f 3px, #15321f 6px
);
border-color: rgba(201,169,97,0.4);
}
.card.face-down::after {
content: '';
position: absolute;
inset: 4px;
border: 1px solid rgba(201,169,97,0.25);
border-radius: 3px;
}
.card-corner {
display: flex;
flex-direction: column;
align-items: center;
line-height: 1;
}
.card-corner.bottom {
transform: rotate(180deg);
}
.card-rank {
font-size: inherit;
font-weight: 600;
line-height: 1;
}
.card-suit {
font-size: 0.7em;
line-height: 1;
margin-top: 1px;
}
.card.red .card-rank,
.card.red .card-suit { color: var(--red-card); }
.card-center {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: clamp(20px, 3.5vw, 28px);
opacity: 0.18;
}
.card.red .card-center { color: var(--red-card); }
/* ─── SCORE BADGE ─── */
.score-badge {
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(0,0,0,0.45);
border: 1px solid rgba(201,169,97,0.3);
border-radius: 20px;
padding: 2px 12px;
font-family: var(--serif);
font-size: clamp(13px, 2vw, 16px);
color: var(--champagne);
letter-spacing: 0.08em;
min-width: 36px;
text-align: center;
min-height: 24px;
}
.score-badge.bust { color: #e07070; border-color: rgba(200,80,80,0.4); }
.score-badge.blackjack { color: var(--gold); border-color: rgba(201,169,97,0.6); }
.score-badge.good { color: #7dd4a0; border-color: rgba(80,180,100,0.35); }
/* ─── DIVIDER ─── */
.table-divider {
display: flex;
align-items: center;
gap: 12px;
padding: 4px 0;
color: rgba(201,169,97,0.25);
font-size: 11px;
letter-spacing: 0.3em;
}
.table-divider::before, .table-divider::after {
content: '';
flex: 1;
height: 1px;
background: rgba(201,169,97,0.15);
}
/* ─── BET AREA ─── */
.bet-area {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 10px 0;
}
.current-bet-display {
font-family: var(--serif);
font-size: clamp(18px, 3vw, 26px);
color: var(--gold);
letter-spacing: 0.08em;
min-height: 32px;
text-align: center;
}
.chip-row {
display: flex;
gap: 10px;
flex-wrap: wrap;
justify-content: center;
}
.chip {
width: clamp(44px, 7vw, 56px);
height: clamp(44px, 7vw, 56px);
border-radius: 50%;
border: 3px dashed rgba(255,255,255,0.35);
display: flex;
align-items: center;
justify-content: center;
font-family: var(--serif);
font-size: clamp(11px, 1.8vw, 14px);
font-weight: 600;
cursor: pointer;
transition: transform 0.1s ease, box-shadow 0.15s ease;
position: relative;
box-shadow: 0 3px 8px rgba(0,0,0,0.4), inset 0 1px 3px rgba(255,255,255,0.15);
}
.chip:hover { transform: scale(1.08); }
.chip:active { transform: scale(0.96); }
.chip.c5 { background: radial-gradient(circle at 35% 35%, #e05050, #8b1a1a); color: #fde8e8; }
.chip.c10 { background: radial-gradient(circle at 35% 35%, #5080e0, #1a3a8b); color: #e8eefe; }
.chip.c25 { background: radial-gradient(circle at 35% 35%, #50b050, #1a5a1a); color: #e8fde8; }
.chip.c50 { background: radial-gradient(circle at 35% 35%, #c9a961, #7a5a1a); color: #fdf0d0; }
.chip.c100 { background: radial-gradient(circle at 35% 35%, #2a2a2a, #111); color: var(--gold); border-color: rgba(201,169,97,0.5); }
.chip.clear { background: radial-gradient(circle at 35% 35%, #555, #222); color: #aaa; font-size: clamp(9px, 1.4vw, 11px); border-color: rgba(255,255,255,0.15); }
/* ─── ACTION BUTTONS ─── */
.action-row {
display: flex;
gap: 10px;
justify-content: center;
flex-wrap: wrap;
padding: 6px 0;
}
.action-btn {
font-family: var(--serif);
font-size: clamp(13px, 2vw, 16px);
letter-spacing: 0.15em;
padding: clamp(9px, 2vw, 12px) clamp(18px, 4vw, 32px);
border-radius: 4px;
border: none;
cursor: pointer;
text-transform: uppercase;
transition: transform 0.1s ease, box-shadow 0.15s ease, opacity 0.2s;
position: relative;
overflow: hidden;
}
.action-btn:active { transform: scale(0.96); }
.action-btn:disabled { opacity: 0.35; cursor: not-allowed; transform: none; }
.btn-primary {
background: linear-gradient(135deg, var(--gold-light) 0%, var(--gold) 50%, var(--gold-dark) 100%);
color: var(--ink);
box-shadow: 0 3px 14px rgba(201,169,97,0.4);
}
.btn-primary:hover:not(:disabled) { box-shadow: 0 5px 20px rgba(201,169,97,0.6); }
.btn-secondary {
background: rgba(201,169,97,0.1);
color: var(--gold);
border: 1px solid rgba(201,169,97,0.4);
}
.btn-secondary:hover:not(:disabled) { background: rgba(201,169,97,0.18); }
.btn-danger {
background: rgba(160,50,50,0.15);
color: #e08888;
border: 1px solid rgba(180,60,60,0.35);
}
.btn-danger:hover:not(:disabled) { background: rgba(160,50,50,0.25); }
/* ─── STATUS MESSAGE ─── */
.status-msg {
font-family: var(--serif);
font-size: clamp(14px, 2.5vw, 18px);
letter-spacing: 0.12em;
color: var(--champagne);
text-align: center;
min-height: 26px;
padding: 4px 0;
}
/* ─── RESULT OVERLAY ─── */
#resultOverlay {
position: absolute;
inset: 0;
border-radius: 13px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
z-index: 10;
opacity: 0;
pointer-events: none;
transition: opacity 0.35s ease;
backdrop-filter: blur(2px);
}
#resultOverlay.show {
opacity: 1;
pointer-events: all;
}
.result-card {
background: rgba(10,18,13,0.92);
border: 2px solid var(--gold);
border-radius: 12px;
padding: 24px 40px;
text-align: center;
box-shadow: 0 20px 60px rgba(0,0,0,0.8), 0 0 40px rgba(201,169,97,0.15);
animation: resultPop 0.4s cubic-bezier(0.22, 1, 0.36, 1) forwards;
}
@keyframes resultPop {
from { transform: scale(0.7); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
.result-headline {
font-family: var(--serif);
font-size: clamp(28px, 5vw, 44px);
color: var(--gold);
letter-spacing: 0.1em;
line-height: 1;
margin-bottom: 6px;
}
.result-headline.lose { color: #e07070; }
.result-headline.push { color: var(--champagne); }
.result-sub {
font-family: var(--serif);
font-size: clamp(14px, 2vw, 18px);
color: rgba(240,230,200,0.7);
letter-spacing: 0.15em;
margin-bottom: 14px;
}
.result-amount {
font-family: var(--serif);
font-size: clamp(20px, 3.5vw, 30px);
letter-spacing: 0.05em;
margin-bottom: 18px;
}
.result-amount.win { color: #80d890; }
.result-amount.lose { color: #e07070; }
.result-amount.push { color: var(--champagne); }
/* ─── FOOTER ─── */
footer {
width: 100%;
text-align: center;
padding: 10px 20px;
font-size: 10px;
letter-spacing: 0.25em;
color: rgba(201,169,97,0.25);
text-transform: uppercase;
border-top: 1px solid rgba(201,169,97,0.08);
}
/* ─── STREAK BADGE ─── */
.streak-badge {
display: inline-flex;
align-items: center;
gap: 5px;
background: rgba(201,169,97,0.1);
border: 1px solid rgba(201,169,97,0.25);
border-radius: 20px;
padding: 2px 10px;
font-size: clamp(10px, 1.5vw, 12px);
color: rgba(240,230,200,0.65);
letter-spacing: 0.1em;
}
/* ─── RESPONSIVE ─── */
@media (max-width: 480px) {
.chip-row { gap: 7px; }
.action-row { gap: 7px; }
.action-btn { padding: 9px 16px; }
}
/* ─── BUST FLASH ─── */
@keyframes bustFlash {
0%, 100% { box-shadow: inset 0 0 0 transparent; }
50% { box-shadow: inset 0 0 30px rgba(200,60,60,0.4); }
}
.bust-flash { animation: bustFlash 0.5s ease 2; }
/* ─── CARD FLIP (hole card reveal) ─── */
@keyframes flipReveal {
0% { transform: rotateY(90deg) scale(0.9); opacity: 0; }
100% { transform: rotateY(0deg) scale(1); opacity: 1; }
}
.flip-reveal { animation: flipReveal 0.3s ease forwards; }
/* ─── BLACKJACK SHIMMER ─── */
@keyframes bjShimmer {
0%, 100% { border-color: var(--gold-dark); }
50% { border-color: var(--gold-light); box-shadow: 0 0 16px rgba(201,169,97,0.6); }
}
.bj-shimmer { animation: bjShimmer 0.6s ease 3; }
/* ─── NO SELECT ─── */
.card, .chip, .action-btn { -webkit-tap-highlight-color: transparent; }
</style>
</head>
<body>
<header>
<div class="wordmark">90210 <span>Blackjack</span></div>
<div class="tagline">Beverly Hills Casino Royale</div>
<button id="muteBtn" title="Toggle sound" aria-label="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.5);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.5);margin-bottom:2px;">CURRENT BET</div>
<div class="val" id="currentBetDisplay">—</div>
</div>
<div>
<div style="font-size:0.85em;letter-spacing:0.12em;color:rgba(240,230,200,0.5);margin-bottom:2px;">BEST WIN</div>
<div class="val" id="bestWinDisplay">$0</div>
</div>
<div class="streak-badge" id="streakBadge" style="display:none">
<span>🔥</span><span id="streakVal">0</span><span>streak</span>
</div>
</div>
<div class="table-wrap">
<div class="felt-table" id="feltTable">
<div id="resultOverlay">
<div class="result-card" id="resultCard">
<div class="result-headline" id="resultHeadline">WIN</div>
<div class="result-sub" id="resultSub">Blackjack!</div>
<div class="result-amount" id="resultAmount">+$150</div>
<button class="action-btn btn-primary" id="nextHandBtn" style="letter-spacing:0.2em">DEAL AGAIN</button>
</div>
</div>
<!-- Dealer zone -->
<div class="hand-zone">
<div class="zone-label">Dealer</div>
<div class="hand-row" id="dealerHand"></div>
<div class="score-badge" id="dealerScore" style="visibility:hidden">0</div>
</div>
<div class="table-divider">⟡ ⟡ ⟡</div>
<!-- Status -->
<div class="status-msg" id="statusMsg">Place your bet to begin</div>
<!-- Bet / action area -->
<div class="bet-area" id="betArea">
<div class="current-bet-display" id="betDisplay"></div>
<div class="chip-row" id="chipRow">
<button class="chip c5" data-val="5" aria-label="$5 chip">$5</button>
<button class="chip c10" data-val="10" aria-label="$10 chip">$10</button>
<button class="chip c25" data-val="25" aria-label="$25 chip">$25</button>
<button class="chip c50" data-val="50" aria-label="$50 chip">$50</button>
<button class="chip c100" data-val="100" aria-label="$100 chip">$100</button>
<button class="chip clear" id="clearBetBtn" aria-label="Clear bet">CLEAR</button>
</div>
</div>
<div class="action-row" id="actionRow" style="display:none">
<button class="action-btn btn-primary" id="hitBtn" disabled>Hit</button>
<button class="action-btn btn-secondary" id="standBtn" disabled>Stand</button>
<button class="action-btn btn-secondary" id="doubleBtn" disabled>Double</button>
</div>
<!-- Player zone -->
<div class="hand-zone" style="flex-direction:column-reverse">
<div class="zone-label" style="margin-bottom:0;margin-top:4px">You</div>
<div class="score-badge" id="playerScore" style="visibility:hidden">0</div>
<div class="hand-row" id="playerHand"></div>
</div>
<!-- Deal button (shown when not playing) -->
<div class="action-row" id="dealRow">
<button class="action-btn btn-primary" id="dealBtn" disabled>Deal</button>
<button class="action-btn btn-danger" id="resetBtn" title="Reset bankroll to $1,000">↺ Reset</button>
</div>
</div>
</div>
<footer>
90210 Blackjack · Single Deck · Dealer Stands on 17 · Blackjack Pays 3:2 · Beverly Hills Casino
</footer>
<script>
// ═══════════════════════════════════════════════════════
// AUDIO ENGINE — procedural Web Audio
// ═══════════════════════════════════════════════════════
const AudioCtx = window.AudioContext || window.webkitAudioContext;
let ctx = null;
let muted = false;
function getCtx() {
if (!ctx) ctx = new AudioCtx();
if (ctx.state === 'suspended') ctx.resume();
return ctx;
}
function playTone({ freq = 440, type = 'sine', vol = 0.25, dur = 0.12, attack = 0.01, decay = 0.05, detune = 0 } = {}) {
if (muted) return;
try {
const c = getCtx();
const osc = c.createOscillator();
const gain = c.createGain();
osc.connect(gain); gain.connect(c.destination);
osc.type = type;
osc.frequency.value = freq;
osc.detune.value = detune;
gain.gain.setValueAtTime(0, c.currentTime);
gain.gain.linearRampToValueAtTime(vol, c.currentTime + attack);
gain.gain.linearRampToValueAtTime(0, c.currentTime + dur);
osc.start(c.currentTime);
osc.stop(c.currentTime + dur + 0.02);
} catch(e) {}
}
function playNoise(vol = 0.08, dur = 0.06) {
if (muted) return;
try {
const c = getCtx();
const len = Math.floor(c.sampleRate * dur);
const buf = c.createBuffer(1, len, c.sampleRate);
const d = buf.getChannelData(0);
for (let i = 0; i < len; i++) d[i] = Math.random() * 2 - 1;
const src = c.createBufferSource();
src.buffer = buf;
const g = c.createGain();
g.gain.setValueAtTime(vol, c.currentTime);
g.gain.exponentialRampToValueAtTime(0.001, c.currentTime + dur);
const filt = c.createBiquadFilter();
filt.type = 'bandpass';
filt.frequency.value = 1200;
filt.Q.value = 2;
src.connect(filt); filt.connect(g); g.connect(c.destination);
src.start(); src.stop(c.currentTime + dur + 0.02);
} catch(e) {}
}
const sounds = {
deal() {
playNoise(0.12, 0.04);
setTimeout(() => playTone({ freq: 900, type: 'triangle', vol: 0.08, dur: 0.08 }), 20);
},
chip() {
playNoise(0.1, 0.03);
playTone({ freq: 1800, type: 'sine', vol: 0.06, dur: 0.06, attack: 0.005 });
},
win() {
[0, 80, 200, 360].forEach((ms, i) => {
setTimeout(() => {
const freqs = [523, 659, 784, 1047];
playTone({ freq: freqs[i], type: 'triangle', vol: 0.18 - i*0.02, dur: 0.25, attack: 0.01 });
}, ms);
});
},
blackjack() {
[0, 100, 220, 360, 520].forEach((ms, i) => {
setTimeout(() => {
const freqs = [523, 659, 784, 1047, 1319];
playTone({ freq: freqs[i], type: 'sine', vol: 0.22 - i*0.02, dur: 0.3, attack: 0.01 });
}, ms);
});
},
lose() {
playTone({ freq: 220, type: 'sawtooth', vol: 0.1, dur: 0.35, attack: 0.01 });
setTimeout(() => playTone({ freq: 165, type: 'sawtooth', vol: 0.08, dur: 0.4 }), 180);
},
push() {
playTone({ freq: 440, type: 'triangle', vol: 0.1, dur: 0.2 });
setTimeout(() => playTone({ freq: 440, type: 'triangle', vol: 0.07, dur: 0.2 }), 220);
},
bust() {
playTone({ freq: 300, type: 'sawtooth', vol: 0.12, dur: 0.15 });
setTimeout(() => playTone({ freq: 200, type: 'sawtooth', vol: 0.1, dur: 0.25 }), 100);
},
click() {
playTone({ freq: 600, type: 'sine', vol: 0.06, dur: 0.06 });
}
};
// ═══════════════════════════════════════════════════════
// CARD DECK
// ═══════════════════════════════════════════════════════
const SUITS = ['♠', '♥', '♦', '♣'];
const RANKS = ['A', '2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K'];
const RED_SUITS = new Set(['♥', '♦']);
function buildDeck() {
const deck = [];
for (const suit of SUITS)
for (const rank of RANKS)
deck.push({ rank, suit });
return deck;
}
function shuffle(arr) {
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
return arr;
}
function cardValue(rank) {
if (rank === 'A') return 11;
if (['J', 'Q', 'K'].includes(rank)) return 10;
return parseInt(rank);
}
function handTotal(cards) {
let total = 0, aces = 0;
for (const c of cards) {
total += cardValue(c.rank);
if (c.rank === 'A') aces++;
}
while (total > 21 && aces > 0) { total -= 10; aces--; }
return total;
}
function isSoft(cards) {
// returns true if hand has an ace counted as 11
let total = 0, aces = 0;
for (const c of cards) {
total += cardValue(c.rank);
if (c.rank === 'A') aces++;
}
let softAce = aces > 0 && total <= 21;
while (total > 21 && aces > 0) { total -= 10; aces--; softAce = false; }
return softAce;
}
function isBlackjack(cards) {
return cards.length === 2 && handTotal(cards) === 21;
}
// ═══════════════════════════════════════════════════════
// GAME STATE
// ═══════════════════════════════════════════════════════
let deck = [];
let playerCards = [];
let dealerCards = [];
let phase = 'betting'; // betting | player | dealer | done
let currentBet = 0;
let bankroll = 1000;
let bestWin = 0;
let streak = 0;
let doubled = false;
// Persist
function saveState() {
localStorage.setItem('bj90210_bankroll', bankroll);
localStorage.setItem('bj90210_bestWin', bestWin);
}
function loadState() {
bankroll = parseInt(localStorage.getItem('bj90210_bankroll') || '1000');
bestWin = parseInt(localStorage.getItem('bj90210_bestWin') || '0');
if (isNaN(bankroll) || bankroll < 1) bankroll = 1000;
if (isNaN(bestWin)) bestWin = 0;
}
// ═══════════════════════════════════════════════════════
// DOM REFS
// ═══════════════════════════════════════════════════════
const $ = id => document.getElementById(id);
const dh = $('dealerHand');
const ph = $('playerHand');
const dealerScore = $('dealerScore');
const playerScore = $('playerScore');
const statusMsg = $('statusMsg');
const betArea = $('betArea');
const actionRow = $('actionRow');
const dealRow = $('dealRow');
const betDisplay = $('betDisplay');
const bankrollDisp = $('bankrollDisplay');
const currentBetDisp= $('currentBetDisplay');
const bestWinDisp = $('bestWinDisplay');
const streakBadge = $('streakBadge');
const streakVal = $('streakVal');
const resultOverlay = $('resultOverlay');
const resultCard = $('resultCard');
const hitBtn = $('hitBtn');
const standBtn = $('standBtn');
const doubleBtn = $('doubleBtn');
const dealBtn = $('dealBtn');
const nextHandBtn = $('nextHandBtn');
const muteBtn = $('muteBtn');
// ═══════════════════════════════════════════════════════
// UI HELPERS
// ═══════════════════════════════════════════════════════
function fmt(n) { return '$' + n.toLocaleString(); }
function updateBankrollUI() {
bankrollDisp.textContent = fmt(bankroll);
currentBetDisp.textContent = currentBet > 0 ? fmt(currentBet) : '—';
bestWinDisp.textContent = fmt(bestWin);
betDisplay.textContent = currentBet > 0 ? `Bet: ${fmt(currentBet)}` : '';
dealBtn.disabled = currentBet <= 0 || currentBet > bankroll;
}
function updateStreak() {
if (streak >= 2) {
streakBadge.style.display = 'inline-flex';
streakVal.textContent = streak;
} else {
streakBadge.style.display = 'none';
}
}
function setStatus(msg) { statusMsg.textContent = msg; }
function showBettingUI() {
betArea.style.display = 'flex';
actionRow.style.display = 'none';
dealRow.style.display = 'flex';
setChipsEnabled(true);
}
function showActionUI(enableDouble) {
betArea.style.display = 'none';
actionRow.style.display = 'flex';
dealRow.style.display = 'none';
hitBtn.disabled = false;
standBtn.disabled = false;
doubleBtn.disabled = !enableDouble;
}
function hideAllActions() {
betArea.style.display = 'none';
actionRow.style.display = 'none';
dealRow.style.display = 'none';
}
function setChipsEnabled(on) {
document.querySelectorAll('.chip[data-val]').forEach(c => c.disabled = !on);
$('clearBetBtn').disabled = !on;
}
// ═══════════════════════════════════════════════════════
// CARD RENDERING
// ═══════════════════════════════════════════════════════
function makeCardEl(card, faceDown = false, animated = true) {
const el = document.createElement('div');
el.classList.add('card');
if (!animated) el.style.animation = 'none';
if (faceDown) {
el.classList.add('face-down');
el.dataset.card = JSON.stringify(card);
return el;
}
el.classList.add('face-up');
if (RED_SUITS.has(card.suit)) el.classList.add('red');
el.innerHTML = `
<div class="card-corner">
<div class="card-rank">${card.rank}</div>
<div class="card-suit">${card.suit}</div>
</div>
<div class="card-center">${card.suit}</div>
<div class="card-corner bottom">
<div class="card-rank">${card.rank}</div>
<div class="card-suit">${card.suit}</div>
</div>
`;
return el;
}
function revealHoleCard() {
const holeEl = dh.querySelector('.face-down');
if (!holeEl) return;
const card = JSON.parse(holeEl.dataset.card);
const newEl = makeCardEl(card, false, false);
newEl.classList.add('flip-reveal');
dh.replaceChild(newEl, holeEl);
}
function addCardToHand(hand, handEl, card, faceDown = false, delay = 0) {
return new Promise(resolve => {
setTimeout(() => {
hand.push(card);
const el = makeCardEl(card, faceDown);
handEl.appendChild(el);
sounds.deal();
resolve();
}, delay);
});
}
function updateScores(showDealer = false) {
const ps = handTotal(playerCards);
playerScore.style.visibility = 'visible';
playerScore.textContent = ps;
playerScore.className = 'score-badge';
if (ps > 21) playerScore.classList.add('bust');
else if (ps === 21) playerScore.classList.add('blackjack');
else if (ps >= 18) playerScore.classList.add('good');
if (showDealer) {
const ds = handTotal(dealerCards);
dealerScore.style.visibility = 'visible';
dealerScore.textContent = ds;
dealerScore.className = 'score-badge';
if (ds > 21) dealerScore.classList.add('bust');
else if (ds === 21) dealerScore.classList.add('blackjack');
else if (ds >= 17) dealerScore.classList.add('good');
} else {
// Show only first dealer card value
if (dealerCards.length > 0) {
dealerScore.style.visibility = 'visible';
dealerScore.textContent = cardValue(dealerCards[0].rank);
dealerScore.className = 'score-badge';
}
}
}
// ═══════════════════════════════════════════════════════
// DECK MANAGEMENT
// ═══════════════════════════════════════════════════════
function ensureDeck() {
if (deck.length < 15) {
deck = shuffle(buildDeck());
setStatus('Shuffling deck…');
}
}
function draw() {
ensureDeck();
return deck.pop();
}
// ═══════════════════════════════════════════════════════
// RESULT / OVERLAY
// ═══════════════════════════════════════════════════════
function showResult(headline, sub, amount, type) {
const h = $('resultHeadline');
const s = $('resultSub');
const a = $('resultAmount');
h.textContent = headline;
h.className = 'result-headline ' + (type === 'lose' ? 'lose' : type === 'push' ? 'push' : '');
s.textContent = sub;
a.textContent = amount;
a.className = 'result-amount ' + type;
resultCard.style.animation = 'none';
resultCard.offsetHeight; // reflow
resultCard.style.animation = '';
resultOverlay.classList.add('show');
}
function hideResult() {
resultOverlay.classList.remove('show');
}
// ═══════════════════════════════════════════════════════
// DEAL
// ═══════════════════════════════════════════════════════
async function deal() {
if (currentBet <= 0 || currentBet > bankroll || phase !== 'betting') return;
sounds.click();
// Deduct bet from bankroll immediately
bankroll -= currentBet;
doubled = false;
saveState();
// Clear hands
playerCards = [];
dealerCards = [];
dh.innerHTML = '';
ph.innerHTML = '';
dealerScore.style.visibility = 'hidden';
playerScore.style.visibility = 'hidden';
hideResult();
hideAllActions();
phase = 'dealing';
setStatus('Dealing…');
// Deal: player, dealer, player, dealer(face-down)
await addCardToHand(playerCards, ph, draw(), false, 0);
await addCardToHand(dealerCards, dh, draw(), false, 200);
await addCardToHand(playerCards, ph, draw(), false, 400);
await addCardToHand(dealerCards, dh, draw(), true, 600);
updateScores(false);
updateBankrollUI();
// Check player blackjack
if (isBlackjack(playerCards)) {
await new Promise(r => setTimeout(r, 200));
ph.classList.add('bj-shimmer');
revealHoleCard();
updateScores(true);
if (isBlackjack(dealerCards)) {
// Both blackjack = push
await new Promise(r => setTimeout(r, 600));
resolvePush('Both have Blackjack');
} else {
// Player BJ wins 3:2
const win = Math.floor(currentBet * 1.5);
await new Promise(r => setTimeout(r, 600));
resolveWin(win, 'Blackjack!', true);
}
return;
}
// Check dealer blackjack (peek)
if (isBlackjack(dealerCards)) {
await new Promise(r => setTimeout(r, 200));
revealHoleCard();
updateScores(true);
await new Promise(r => setTimeout(r, 600));
resolveLose('Dealer has Blackjack');
return;
}
// Normal play
phase = 'player';
const canDouble = bankroll >= currentBet; // has enough for another bet
showActionUI(canDouble);
setStatus('Your turn');
}
// ═══════════════════════════════════════════════════════
// PLAYER ACTIONS
// ═══════════════════════════════════════════════════════
function hit() {
if (phase !== 'player') return;
sounds.click();
const card = draw();
playerCards.push(card);
ph.appendChild(makeCardEl(card));
sounds.deal();
updateScores(false);
doubleBtn.disabled = true; // can't double after hitting
const total = handTotal(playerCards);
if (total > 21) {
playerScore.classList.add('bust');
$('feltTable').classList.add('bust-flash');
setTimeout(() => $('feltTable').classList.remove('bust-flash'), 1000);
hideAllActions();
setStatus('Bust!');
sounds.bust();
setTimeout(() => resolveLose('Busted'), 800);
} else if (total === 21) {
stand();
}
}
function stand() {
if (phase !== 'player') return;
sounds.click();
phase = 'dealer';
hideAllActions();
dealerTurn();
}
function doubleDown() {
if (phase !== 'player' || bankroll < currentBet) return;
sounds.click();
sounds.chip();
bankroll -= currentBet;
currentBet *= 2;
doubled = true;
saveState();
updateBankrollUI();
setStatus('Doubled down!');
const card = draw();
playerCards.push(card);
ph.appendChild(makeCardEl(card));
sounds.deal();
updateScores(false);
const total = handTotal(playerCards);
if (total > 21) {
playerScore.classList.add('bust');
$('feltTable').classList.add('bust-flash');
setTimeout(() => $('feltTable').classList.remove('bust-flash'), 1000);
setStatus('Bust!');
sounds.bust();
setTimeout(() => resolveLose('Busted on Double'), 800);
} else {
phase = 'dealer';
setTimeout(() => dealerTurn(), 400);
}
}
// ═══════════════════════════════════════════════════════
// DEALER TURN
// ═══════════════════════════════════════════════════════
async function dealerTurn() {
phase = 'dealer';
revealHoleCard();
await new Promise(r => setTimeout(r, 350));
updateScores(true);
let dealerTotal = handTotal(dealerCards);
setStatus(`Dealer: ${dealerTotal}`);
while (dealerTotal < 17 || (dealerTotal === 17 && isSoft(dealerCards))) {
await new Promise(r => setTimeout(r, 600));
const card = draw();
dealerCards.push(card);
dh.appendChild(makeCardEl(card));
sounds.deal();
dealerTotal = handTotal(dealerCards);
updateScores(true);
setStatus(`Dealer: ${dealerTotal}`);
}
await new Promise(r => setTimeout(r, 400));
const playerTotal = handTotal(playerCards);
if (dealerTotal > 21) {
resolveWin(currentBet, 'Dealer Busted', false);
} else if (dealerTotal > playerTotal) {
resolveLose(`Dealer ${dealerTotal} vs Your ${playerTotal}`);
} else if (playerTotal > dealerTotal) {
resolveWin(currentBet, `Your ${playerTotal} vs Dealer ${dealerTotal}`, false);
} else {
resolvePush(`${playerTotal} — Push`);
}
}
// ═══════════════════════════════════════════════════════
// RESOLUTION
// ═══════════════════════════════════════════════════════
function resolveWin(winAmount, sub, bj = false) {
bankroll += currentBet + winAmount; // return bet + profit
if (winAmount > bestWin) { bestWin = winAmount; }
streak++;
saveState();
updateBankrollUI();
updateStreak();
phase = 'done';
setStatus('');
if (bj) sounds.blackjack(); else sounds.win();
showResult('WIN', sub, `+${fmt(winAmount)}`, 'win');
}
function resolveLose(sub) {
// bet already deducted
streak = 0;
saveState();
updateBankrollUI();
updateStreak();
phase = 'done';
setStatus('');
sounds.lose();
showResult('LOSE', sub, `−${fmt(currentBet)}`, 'lose');
checkBankrupt();
}
function resolvePush(sub) {
bankroll += currentBet; // return bet
saveState();
updateBankrollUI();
phase = 'done';
setStatus('');
sounds.push();
showResult('PUSH', sub, fmt(currentBet) + ' returned', 'push');
}
function checkBankrupt() {
if (bankroll <= 0) {
setTimeout(() => {
setStatus('Bankrupt — resetting to $1,000');
bankroll = 1000;
saveState();
updateBankrollUI();
}, 2000);
}
}
// ═══════════════════════════════════════════════════════
// NEXT HAND / RESET
// ═══════════════════════════════════════════════════════
function nextHand() {
hideResult();
phase = 'betting';
currentBet = 0;
playerCards = [];
dealerCards = [];
dh.innerHTML = '';
ph.innerHTML = '';
dealerScore.style.visibility = 'hidden';
playerScore.style.visibility = 'hidden';
setStatus('Place your bet to begin');
showBettingUI();
updateBankrollUI();
}
function resetBankroll() {
bankroll = 1000;
bestWin = 0;
streak = 0;
saveState();
nextHand();
setStatus('Bankroll reset to $1,000');
updateStreak();
}
// ═══════════════════════════════════════════════════════
// KEYBOARD
// ═══════════════════════════════════════════════════════
document.addEventListener('keydown', e => {
if (e.repeat) return;
const k = e.key.toLowerCase();
if (phase === 'player') {
if (k === 'h' || k === 'arrowup') { e.preventDefault(); hit(); }
if (k === 's' || k === 'arrowdown') { e.preventDefault(); stand(); }
if (k === 'd') { e.preventDefault(); if (!doubleBtn.disabled) doubleDown(); }
}
if (phase === 'betting') {
if (k === 'enter' || k === ' ') { e.preventDefault(); if (!dealBtn.disabled) deal(); }
}
if (phase === 'done') {
if (k === 'enter' || k === ' ' || k === 'n') { e.preventDefault(); nextHand(); }
}
if (k === 'm') { muted = !muted; muteBtn.textContent = muted ? '🔇' : '🔊'; }
});
// ═══════════════════════════════════════════════════════
// EVENT LISTENERS
// ═══════════════════════════════════════════════════════
document.querySelectorAll('.chip[data-val]').forEach(chip => {
chip.addEventListener('click', () => {
if (phase !== 'betting') return;
const val = parseInt(chip.dataset.val);
const newBet = currentBet + val;
if (newBet > bankroll) return; // can't bet more than bankroll
currentBet = newBet;
sounds.chip();
updateBankrollUI();
});
});
$('clearBetBtn').addEventListener('click', () => {
if (phase !== 'betting') return;
currentBet = 0;
sounds.click();
updateBankrollUI();
});
dealBtn.addEventListener('click', deal);
hitBtn.addEventListener('click', hit);
standBtn.addEventListener('click', stand);
doubleBtn.addEventListener('click', doubleDown);
nextHandBtn.addEventListener('click', nextHand);
$('resetBtn').addEventListener('click', resetBankroll);
muteBtn.addEventListener('click', () => {
muted = !muted;
muteBtn.textContent = muted ? '🔇' : '🔊';
});
// ═══════════════════════════════════════════════════════
// INIT
// ═══════════════════════════════════════════════════════
loadState();
deck = shuffle(buildDeck());
updateBankrollUI();
updateStreak();
showBettingUI();
setStatus('Place your bet to begin');
</script>
</body>
</html>