← 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>