Showcase

The pool break, built by Gemini 4 Argon

Hostedvia DaBenchOpenRouter
Running live in your browserNew tab
Speed
87tok/s
First token included
Written
27Ktokens
Time to build
5m 10s
Cost to build
$0.14
Show the code the model wrote
HTML44,742 characters
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Eight-Ball Break</title>
<style>
  * {
    box-sizing: border-box;
    margin: 0;
    padding: 0;
  }
  html, body {
    width: 100%;
    height: 100%;
    background: radial-gradient(circle at 50% 42%, #1a222c 0%, #0d1117 65%, #07090c 100%);
    overflow: hidden;
    display: flex;
    align-items: center;
    justify-content: center;
    user-select: none;
    -webkit-user-select: none;
  }
  canvas {
    display: block;
    max-width: 98vw;
    max-height: 96vh;
    width: auto;
    height: auto;
    filter: drop-shadow(0 28px 50px rgba(0, 0, 0, 0.78));
  }
</style>
</head>
<body>
<canvas id="poolCanvas"></canvas>

<script>
(() => {
  const canvas = document.getElementById('poolCanvas');
  const ctx = canvas.getContext('2d');

  // Logical dimensions for crisp broadcast aspect ratio
  const LOGICAL_W = 1220;
  const LOGICAL_H = 740;

  function resizeCanvas() {
    const dpr = Math.min(window.devicePixelRatio || 1, 2.5);
    canvas.width = LOGICAL_W * dpr;
    canvas.height = LOGICAL_H * dpr;
    canvas.style.aspectRatio = `${LOGICAL_W} / ${LOGICAL_H}`;
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
  }
  window.addEventListener('resize', resizeCanvas);
  resizeCanvas();

  // ============================================================================
  // TABLE GEOMETRY & CONSTANTS (Exact 2:1 Regulation Playing Surface)
  // ============================================================================
  const PLAY_W = 940;
  const PLAY_H = 470;
  const PLAY_LEFT = (LOGICAL_W - PLAY_W) / 2;   // 140
  const PLAY_RIGHT = PLAY_LEFT + PLAY_W;        // 1080
  const PLAY_TOP = 72;
  const PLAY_BOTTOM = PLAY_TOP + PLAY_H;        // 542
  const PLAY_CENTER_X = (PLAY_LEFT + PLAY_RIGHT) / 2; // 610
  const PLAY_CENTER_Y = (PLAY_TOP + PLAY_BOTTOM) / 2; // 307

  const CUSHION_WIDTH = 23; // Rubber cushion nose to wood rail
  const RAIL_WIDTH = 44;    // Wood rail width outside cushion
  const WOOD_LEFT = PLAY_LEFT - CUSHION_WIDTH;
  const WOOD_RIGHT = PLAY_RIGHT + CUSHION_WIDTH;
  const WOOD_TOP = PLAY_TOP - CUSHION_WIDTH;
  const WOOD_BOTTOM = PLAY_BOTTOM + CUSHION_WIDTH;

  const OUTER_LEFT = WOOD_LEFT - RAIL_WIDTH;
  const OUTER_RIGHT = WOOD_RIGHT + RAIL_WIDTH;
  const OUTER_TOP = WOOD_TOP - RAIL_WIDTH;
  const OUTER_BOTTOM = WOOD_BOTTOM + RAIL_WIDTH;

  const HEAD_STRING_X = PLAY_LEFT + PLAY_W * 0.25; // 375
  const HEAD_SPOT_X = HEAD_STRING_X;
  const FOOT_SPOT_X = PLAY_LEFT + PLAY_W * 0.75;   // 845
  const SPOT_Y = PLAY_CENTER_Y;                    // 307

  const BALL_R = 13.0;
  const BALL_D = BALL_R * 2;

  // Pocket openings & centers
  const CORNER_CUT = 28; // Nose cut distance from corner
  const SIDE_CUT = 24;   // Half-opening at side pocket nose

  const POCKETS = [
    // 4 Corner pockets
    { id: 0, x: PLAY_LEFT - 10,  y: PLAY_TOP - 10,    r: 24.5, captureR: 20, type: 'corner' },
    { id: 1, x: PLAY_RIGHT + 10, y: PLAY_TOP - 10,    r: 24.5, captureR: 20, type: 'corner' },
    { id: 2, x: PLAY_LEFT - 10,  y: PLAY_BOTTOM + 10, r: 24.5, captureR: 20, type: 'corner' },
    { id: 3, x: PLAY_RIGHT + 10, y: PLAY_BOTTOM + 10, r: 24.5, captureR: 20, type: 'corner' },
    // 2 Side pockets
    { id: 4, x: PLAY_CENTER_X,   y: PLAY_TOP - 16,    r: 23.0, captureR: 19, type: 'side' },
    { id: 5, x: PLAY_CENTER_X,   y: PLAY_BOTTOM + 16, r: 23.0, captureR: 19, type: 'side' }
  ];

  // Six Cushion Polygons (each defined clockwise from wood-back-start, nose-start, nose-end, wood-back-end)
  const CUSHIONS = [
    // Top-Left Cushion
    [
      { x: WOOD_LEFT + CORNER_CUT - 15, y: WOOD_TOP },
      { x: PLAY_LEFT + CORNER_CUT,      y: PLAY_TOP },
      { x: PLAY_CENTER_X - SIDE_CUT,    y: PLAY_TOP },
      { x: PLAY_CENTER_X - SIDE_CUT + 7, y: WOOD_TOP }
    ],
    // Top-Right Cushion
    [
      { x: PLAY_CENTER_X + SIDE_CUT - 7, y: WOOD_TOP },
      { x: PLAY_CENTER_X + SIDE_CUT,     y: PLAY_TOP },
      { x: PLAY_RIGHT - CORNER_CUT,      y: PLAY_TOP },
      { x: WOOD_RIGHT - CORNER_CUT + 15, y: WOOD_TOP }
    ],
    // Bottom-Right Cushion
    [
      { x: WOOD_RIGHT - CORNER_CUT + 15, y: WOOD_BOTTOM },
      { x: PLAY_RIGHT - CORNER_CUT,      y: PLAY_BOTTOM },
      { x: PLAY_CENTER_X + SIDE_CUT,     y: PLAY_BOTTOM },
      { x: PLAY_CENTER_X + SIDE_CUT - 7, y: WOOD_BOTTOM }
    ],
    // Bottom-Left Cushion
    [
      { x: PLAY_CENTER_X - SIDE_CUT + 7, y: WOOD_BOTTOM },
      { x: PLAY_CENTER_X - SIDE_CUT,     y: PLAY_BOTTOM },
      { x: PLAY_LEFT + CORNER_CUT,       y: PLAY_BOTTOM },
      { x: WOOD_LEFT + CORNER_CUT - 15,  y: WOOD_BOTTOM }
    ],
    // Left (Head) Cushion
    [
      { x: WOOD_LEFT, y: WOOD_BOTTOM - CORNER_CUT + 15 },
      { x: PLAY_LEFT, y: PLAY_BOTTOM - CORNER_CUT },
      { x: PLAY_LEFT, y: PLAY_TOP + CORNER_CUT },
      { x: WOOD_LEFT, y: WOOD_TOP + CORNER_CUT - 15 }
    ],
    // Right (Foot) Cushion
    [
      { x: WOOD_RIGHT, y: WOOD_TOP + CORNER_CUT - 15 },
      { x: PLAY_RIGHT, y: PLAY_TOP + CORNER_CUT },
      { x: PLAY_RIGHT, y: PLAY_BOTTOM - CORNER_CUT },
      { x: WOOD_RIGHT, y: WOOD_BOTTOM - CORNER_CUT + 15 }
    ]
  ];

  // Build collision segments from the 6 cushions (jaw bevels + main nose + back guard)
  const COLLISION_SEGMENTS = [];
  for (const poly of CUSHIONS) {
    // Segments: (0->1) jaw1, (1->2) main nose, (2->3) jaw2, (3->0) wood back
    for (let i = 0; i < 4; i++) {
      const p1 = poly[i];
      const p2 = poly[(i + 1) % 4];
      const dx = p2.x - p1.x;
      const dy = p2.y - p1.y;
      const len = Math.hypot(dx, dy);
      // Outward normal for clockwise polygon in screen coords (y down) is (dy/len, -dx/len)
      // Let's verify with Top-Left main nose: p1=(168, 72) -> p2=(586, 72), dx>0, dy=0.
      // We want normal pointing DOWN (0, +1), which is (-dy/len, dx/len)!
      const nx = -dy / len;
      const ny = dx / len;
      COLLISION_SEGMENTS.push({
        x1: p1.x, y1: p1.y,
        x2: p2.x, y2: p2.y,
        dx, dy, lenSq: len * len,
        nx, ny,
        isJaw: (i === 0 || i === 2)
      });
    }
  }

  // Official Pool Ball Colors
  const BALL_DEFS = {
    0:  { color: '#F8F6EC', stripe: false, label: '' },
    1:  { color: '#F5C414', stripe: false, label: '1' }, // Yellow
    2:  { color: '#144BA8', stripe: false, label: '2' }, // Blue
    3:  { color: '#D92222', stripe: false, label: '3' }, // Red
    4:  { color: '#532484', stripe: false, label: '4' }, // Purple
    5:  { color: '#F26818', stripe: false, label: '5' }, // Orange
    6:  { color: '#117A3D', stripe: false, label: '6' }, // Green
    7:  { color: '#70191E', stripe: false, label: '7' }, // Maroon
    8:  { color: '#16181B', stripe: false, label: '8' }, // Black
    9:  { color: '#F5C414', stripe: true,  label: '9' },
    10: { color: '#144BA8', stripe: true,  label: '10' },
    11: { color: '#D92222', stripe: true,  label: '11' },
    12: { color: '#532484', stripe: true,  label: '12' },
    13: { color: '#F26818', stripe: true,  label: '13' },
    14: { color: '#117A3D', stripe: true,  label: '14' },
    15: { color: '#70191E', stripe: true,  label: '15' }
  };

  // ============================================================================
  // PRE-RENDER STATIC TABLE SURFACE FOR PERFORMANCE & BROADCAST DETAIL
  // ============================================================================
  const tableBuffer = document.createElement('canvas');
  tableBuffer.width = LOGICAL_W * 2;
  tableBuffer.height = LOGICAL_H * 2;
  const tCtx = tableBuffer.getContext('2d');
  tCtx.scale(2, 2);

  function renderStaticTable(c) {
    // 1. Outer Mahogany Frame with Rounded Corners
    const frameRadius = 26;
    c.save();
    roundRectPath(c, OUTER_LEFT, OUTER_TOP, OUTER_RIGHT - OUTER_LEFT, OUTER_BOTTOM - OUTER_TOP, frameRadius);
    const woodGrad = c.createLinearGradient(OUTER_LEFT, OUTER_TOP, OUTER_RIGHT, OUTER_BOTTOM);
    woodGrad.addColorStop(0,   '#4d2414');
    woodGrad.addColorStop(0.2, '#5c2c18');
    woodGrad.addColorStop(0.5, '#3f1c0f');
    woodGrad.addColorStop(0.8, '#582916');
    woodGrad.addColorStop(1,   '#38170b');
    c.fillStyle = woodGrad;
    c.fill();

    // Subtle wood grain lines on rails
    c.clip();
    c.strokeStyle = 'rgba(20, 6, 2, 0.22)';
    c.lineWidth = 1.2;
    for (let i = -200; i < 1500; i += 7) {
      c.beginPath();
      c.moveTo(i, OUTER_TOP);
      c.bezierCurveTo(i + 40, PLAY_CENTER_Y - 100, i - 30, PLAY_CENTER_Y + 100, i + 20, OUTER_BOTTOM);
      c.stroke();
    }

    // Outer metallic bevel trim
    roundRectPath(c, OUTER_LEFT + 1.5, OUTER_TOP + 1.5, OUTER_RIGHT - OUTER_LEFT - 3, OUTER_BOTTOM - OUTER_TOP - 3, frameRadius - 1);
    c.strokeStyle = 'rgba(255, 215, 175, 0.16)';
    c.lineWidth = 2;
    c.stroke();

    // Inner rail lip bevel
    c.strokeStyle = '#240d05';
    c.lineWidth = 3;
    c.strokeRect(WOOD_LEFT - 1.5, WOOD_TOP - 1.5, WOOD_RIGHT - WOOD_LEFT + 3, WOOD_BOTTOM - WOOD_TOP + 3);
    c.restore();

    // 2. Metallic Corner & Side Pocket Castings (Flush Broadcast Style)
    drawPocketCastings(c);

    // 3. Green Worsted Baize Cloth Bed
    c.save();
    c.beginPath();
    c.rect(WOOD_LEFT, WOOD_TOP, WOOD_RIGHT - WOOD_LEFT, WOOD_BOTTOM - WOOD_TOP);
    c.clip();

    const clothGrad = c.createRadialGradient(
      PLAY_CENTER_X, PLAY_CENTER_Y, 40,
      PLAY_CENTER_X, PLAY_CENTER_Y, 640
    );
    clothGrad.addColorStop(0,   '#178549');
    clothGrad.addColorStop(0.55,'#12703d');
    clothGrad.addColorStop(1,   '#0b522b');
    c.fillStyle = clothGrad;
    c.fillRect(WOOD_LEFT, WOOD_TOP, WOOD_RIGHT - WOOD_LEFT, WOOD_BOTTOM - WOOD_TOP);

    // Fine procedural cloth weave texture
    c.fillStyle = 'rgba(255,255,255,0.015)';
    for (let x = WOOD_LEFT; x < WOOD_RIGHT; x += 3) {
      for (let y = WOOD_TOP; y < WOOD_BOTTOM; y += 3) {
        if ((x + y) % 6 === 0) c.fillRect(x, y, 1.5, 1.5);
      }
    }

    // 4. Faint Table Markings: Head String, Head Spot, Foot Spot, Rack Outline
    c.strokeStyle = 'rgba(225, 255, 238, 0.17)';
    c.lineWidth = 1.4;
    c.beginPath();
    c.moveTo(HEAD_STRING_X, PLAY_TOP);
    c.lineTo(HEAD_STRING_X, PLAY_BOTTOM);
    c.stroke();

    // Faint Head Spot
    c.fillStyle = 'rgba(225, 255, 238, 0.22)';
    c.beginPath();
    c.arc(HEAD_SPOT_X, SPOT_Y, 2.5, 0, Math.PI * 2);
    c.fill();

    // Faint Rack Triangle Line
    const rackRowDx = BALL_D * Math.cos(Math.PI / 6);
    c.strokeStyle = 'rgba(225, 255, 238, 0.11)';
    c.lineWidth = 1.1;
    c.beginPath();
    c.moveTo(FOOT_SPOT_X - BALL_R, SPOT_Y);
    c.lineTo(FOOT_SPOT_X + 4 * rackRowDx + BALL_R * 0.8, SPOT_Y - 2 * BALL_D - BALL_R);
    c.lineTo(FOOT_SPOT_X + 4 * rackRowDx + BALL_R * 0.8, SPOT_Y + 2 * BALL_D + BALL_R);
    c.closePath();
    c.stroke();

    // Foot Spot (dark cloth sticker with tiny pale center dot)
    c.fillStyle = 'rgba(8, 36, 20, 0.65)';
    c.beginPath();
    c.arc(FOOT_SPOT_X, SPOT_Y, 5.2, 0, Math.PI * 2);
    c.fill();
    c.fillStyle = 'rgba(225, 255, 238, 0.45)';
    c.beginPath();
    c.arc(FOOT_SPOT_X, SPOT_Y, 1.5, 0, Math.PI * 2);
    c.fill();

    // 5. Cushion Shadows onto the Playing Surface
    c.fillStyle = 'rgba(0, 18, 8, 0.34)';
    c.fillRect(PLAY_LEFT, PLAY_TOP, PLAY_W, 7);
    c.fillRect(PLAY_LEFT, PLAY_BOTTOM - 5, PLAY_W, 5);
    c.fillRect(PLAY_LEFT, PLAY_TOP, 6, PLAY_H);
    c.fillRect(PLAY_RIGHT - 6, PLAY_TOP, 6, PLAY_H);

    // 6. Render the 6 Molded Cloth-Covered Rubber Cushions
    for (let idx = 0; idx < CUSHIONS.length; idx++) {
      const poly = CUSHIONS[idx];
      c.beginPath();
      c.moveTo(poly[0].x, poly[0].y);
      c.lineTo(poly[1].x, poly[1].y);
      c.lineTo(poly[2].x, poly[2].y);
      c.lineTo(poly[3].x, poly[3].y);
      c.closePath();

      // Top/Left cushions catch slightly different overhead angle than Bottom/Right
      let cGrad;
      if (idx === 0 || idx === 1) { // Top cushions
        cGrad = c.createLinearGradient(0, WOOD_TOP, 0, PLAY_TOP);
        cGrad.addColorStop(0, '#0c4d29');
        cGrad.addColorStop(0.75, '#167d44');
        cGrad.addColorStop(1, '#1e9654');
      } else if (idx === 2 || idx === 3) { // Bottom cushions
        cGrad = c.createLinearGradient(0, WOOD_BOTTOM, 0, PLAY_BOTTOM);
        cGrad.addColorStop(0, '#0a4223');
        cGrad.addColorStop(0.75, '#136e3b');
        cGrad.addColorStop(1, '#1b894d');
      } else if (idx === 4) { // Left cushion
        cGrad = c.createLinearGradient(WOOD_LEFT, 0, PLAY_LEFT, 0);
        cGrad.addColorStop(0, '#0b4726');
        cGrad.addColorStop(0.75, '#14753f');
        cGrad.addColorStop(1, '#1c8e50');
      } else { // Right cushion
        cGrad = c.createLinearGradient(WOOD_RIGHT, 0, PLAY_RIGHT, 0);
        cGrad.addColorStop(0, '#0b4726');
        cGrad.addColorStop(0.75, '#14753f');
        cGrad.addColorStop(1, '#1c8e50');
      }
      c.fillStyle = cGrad;
      c.fill();

      // Crisp highlight along the cushion nose edge
      c.beginPath();
      c.moveTo(poly[0].x, poly[0].y);
      c.lineTo(poly[1].x, poly[1].y);
      c.lineTo(poly[2].x, poly[2].y);
      c.lineTo(poly[3].x, poly[3].y);
      c.strokeStyle = 'rgba(165, 255, 200, 0.22)';
      c.lineWidth = 1.2;
      c.stroke();
    }
    c.restore();

    // 7. Six Pockets (Deep dark well + leather rim)
    for (const p of POCKETS) {
      // Leather pocket liner back
      c.save();
      c.beginPath();
      c.arc(p.x, p.y, p.r + 5, 0, Math.PI * 2);
      c.fillStyle = '#181412';
      c.fill();
      c.strokeStyle = '#2d2521';
      c.lineWidth = 2;
      c.stroke();

      // Deep pocket hole with subtle rim gradient
      const pGrad = c.createRadialGradient(p.x + 2, p.y + 2, 2, p.x, p.y, p.r);
      pGrad.addColorStop(0, '#020304');
      pGrad.addColorStop(0.72, '#07090c');
      pGrad.addColorStop(0.92, '#14181f');
      pGrad.addColorStop(1, '#222830');
      c.beginPath();
      c.arc(p.x, p.y, p.r, 0, Math.PI * 2);
      c.fillStyle = pGrad;
      c.fill();

      // Subtle inner pocket net/leather stitch arc
      c.beginPath();
      c.arc(p.x, p.y, p.r - 3, 0, Math.PI * 2);
      c.strokeStyle = 'rgba(255,255,255,0.06)';
      c.lineWidth = 1;
      c.stroke();
      c.restore();
    }

    // 8. Eighteen Diamond Markers (Sights) on the Wooden Rails
    // Long rails: 8 segments (diamonds at 1/8, 2/8, 3/8, 5/8, 6/8, 7/8; 4/8 is the side pocket)
    // Short rails: 4 segments (diamonds at 1/4, 2/4, 3/4)
    const railTopY = WOOD_TOP - RAIL_WIDTH * 0.5;
    const railBottomY = WOOD_BOTTOM + RAIL_WIDTH * 0.5;
    const railLeftX = WOOD_LEFT - RAIL_WIDTH * 0.5;
    const railRightX = WOOD_RIGHT + RAIL_WIDTH * 0.5;

    for (let i = 1; i <= 7; i++) {
      if (i === 4) continue; // Side pocket
      const x = PLAY_LEFT + (PLAY_W / 8) * i;
      drawDiamond(c, x, railTopY, true);
      drawDiamond(c, x, railBottomY, true);
    }
    for (let j = 1; j <= 3; j++) {
      const y = PLAY_TOP + (PLAY_H / 4) * j;
      drawDiamond(c, railLeftX, y, false);
      drawDiamond(c, railRightX, y, false);
    }

    // 9. Potted Balls Return Tray Below the Table
    drawTrayBackground(c);
  }

  function roundRectPath(c, x, y, w, h, r) {
    c.beginPath();
    c.moveTo(x + r, y);
    c.lineTo(x + w - r, y);
    c.quadraticCurveTo(x + w, y, x + w, y + r);
    c.lineTo(x + w, y + h - r);
    c.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
    c.lineTo(x + r, y + h);
    c.quadraticCurveTo(x, y + h, x, y + h - r);
    c.lineTo(x, y + r);
    c.quadraticCurveTo(x, y, x + r, y);
    c.closePath();
  }

  function drawPocketCastings(c) {
    c.save();
    roundRectPath(c, OUTER_LEFT, OUTER_TOP, OUTER_RIGHT - OUTER_LEFT, OUTER_BOTTOM - OUTER_TOP, 26);
    c.clip();

    const metalGrad = c.createLinearGradient(OUTER_LEFT, OUTER_TOP, OUTER_RIGHT, OUTER_BOTTOM);
    metalGrad.addColorStop(0,   '#2b3036');
    metalGrad.addColorStop(0.3, '#434a54');
    metalGrad.addColorStop(0.5, '#23272d');
    metalGrad.addColorStop(0.8, '#3b424b');
    metalGrad.addColorStop(1,   '#1d2024');
    c.fillStyle = metalGrad;

    // 4 Corners
    const cw = 72;
    c.fillRect(OUTER_LEFT, OUTER_TOP, cw, cw);
    c.fillRect(OUTER_RIGHT - cw, OUTER_TOP, cw, cw);
    c.fillRect(OUTER_LEFT, OUTER_BOTTOM - cw, cw, cw);
    c.fillRect(OUTER_RIGHT - cw, OUTER_BOTTOM - cw, cw, cw);

    // 2 Side Castings
    const sw = 68;
    c.fillRect(PLAY_CENTER_X - sw / 2, OUTER_TOP, sw, RAIL_WIDTH + 6);
    c.fillRect(PLAY_CENTER_X - sw / 2, WOOD_BOTTOM - 6, sw, RAIL_WIDTH + 6);
    c.restore();
  }

  function drawDiamond(c, x, y, horizontalRail) {
    const rx = horizontalRail ? 3.8 : 5.6;
    const ry = horizontalRail ? 5.6 : 3.8;
    c.save();
    c.beginPath();
    c.moveTo(x, y - ry);
    c.lineTo(x + rx, y);
    c.lineTo(x, y + ry);
    c.lineTo(x - rx, y);
    c.closePath();
    const dGrad = c.createLinearGradient(x - rx, y - ry, x + rx, y + ry);
    dGrad.addColorStop(0, '#fffdf5');
    dGrad.addColorStop(0.5, '#e8dfc5');
    dGrad.addColorStop(1, '#b8ab8a');
    c.fillStyle = dGrad;
    c.fill();
    c.strokeStyle = 'rgba(20, 8, 2, 0.55)';
    c.lineWidth = 0.8;
    c.stroke();
    c.restore();
  }

  // Tray coordinates
  const TRAY_W = 540;
  const TRAY_H = 52;
  const TRAY_X = (LOGICAL_W - TRAY_W) / 2;
  const TRAY_Y = OUTER_BOTTOM + 36;

  function drawTrayBackground(c) {
    c.save();
    // Wooden outer housing
    roundRectPath(c, TRAY_X - 8, TRAY_Y - 6, TRAY_W + 16, TRAY_H + 12, 26);
    const trayWood = c.createLinearGradient(TRAY_X, TRAY_Y - 6, TRAY_X, TRAY_Y + TRAY_H + 6);
    trayWood.addColorStop(0, '#4f2413');
    trayWood.addColorStop(0.5, '#36160b');
    trayWood.addColorStop(1, '#240d05');
    c.fillStyle = trayWood;
    c.fill();
    c.strokeStyle = 'rgba(255, 210, 170, 0.14)';
    c.lineWidth = 1.2;
    c.stroke();

    // Inner recessed dark felt trough
    roundRectPath(c, TRAY_X, TRAY_Y, TRAY_W, TRAY_H, 20);
    const troughGrad = c.createLinearGradient(TRAY_X, TRAY_Y, TRAY_X, TRAY_Y + TRAY_H);
    troughGrad.addColorStop(0, '#071c10');
    troughGrad.addColorStop(0.35, '#0c2e1b');
    troughGrad.addColorStop(1, '#092113');
    c.fillStyle = troughGrad;
    c.fill();

    // Inner shadow
    c.strokeStyle = 'rgba(0,0,0,0.65)';
    c.lineWidth = 2;
    c.stroke();

    // 15 subtle circular dimples in the tray
    for (let i = 0; i < 15; i++) {
      const sx = TRAY_X + 31 + i * 34;
      const sy = TRAY_Y + TRAY_H / 2;
      c.beginPath();
      c.arc(sx, sy, BALL_R - 2, 0, Math.PI * 2);
      c.fillStyle = 'rgba(0, 0, 0, 0.25)';
      c.fill();
    }
    c.restore();
  }

  renderStaticTable(tCtx);

  // ============================================================================
  // BALL STATE & REFEREE EIGHT-BALL RACKING
  // ============================================================================
  let balls = [];
  let pottedTrayBalls = [];
  let breakCount = 0;

  // State machine
  const STATE_RACKED = 0;     // Hold rack for 1.0s
  const STATE_STRIKING = 1;   // Cue stick draws back and drives cue ball into rack
  const STATE_SIMULATING = 2; // Physics running until all balls stop
  const STATE_REST_WAIT = 3;  // Wait 2.0s after all balls stop

  let simState = STATE_RACKED;
  let stateTimer = 0;

  // Cue stick animation state
  let cueAimAngle = 0;
  let cueOffset = 0;
  let cueAlpha = 1;
  let plannedCueSpeed = 1120;
  let plannedTopSpinX = 0;
  let plannedTopSpinY = 0;

  function shuffleArray(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 setupRefereeRack() {
    balls = [];
    pottedTrayBalls = [];

    // Cue ball on the head string (vary Y position each break for unique realistic breaks)
    const ySpread = (breakCount === 0)
      ? -28
      : (Math.random() - 0.5) * 150;
    const cueBallY = SPOT_Y + ySpread;
    const cueBallX = HEAD_STRING_X;

    balls.push(createBall(0, cueBallX, cueBallY));

    // Referee 8-Ball Rack Rules:
    // - 15 balls in a 5-row triangle with apex ball centered on the Foot Spot.
    // - 8-ball MUST be at the center of the triangle: row 2, col 1 (0-indexed).
    // - Bottom two corners: row 4, col 0 and row 4, col 4 MUST be one solid and one stripe.
    // - 1-ball placed at the apex (row 0, col 0), remaining solids & stripes mixed cleanly.
    const remainingSolids = shuffleArray([2, 3, 4, 5, 6, 7]);
    const remainingStripes = shuffleArray([9, 10, 11, 12, 13, 14, 15]);

    const cornerSolid = remainingSolids.pop();
    const cornerStripe = remainingStripes.pop();
    const swapCorners = Math.random() < 0.5;

    // Pool remaining 11 balls (5 solids, 6 stripes) and interleave/shuffle so suits are well mixed
    const pool = shuffleArray([...remainingSolids, ...remainingStripes]);

    const rowSpacingX = BALL_D * Math.cos(Math.PI / 6) + 0.04;
    const colSpacingY = BALL_D + 0.04;

    for (let row = 0; row < 5; row++) {
      for (let col = 0; col <= row; col++) {
        let id;
        if (row === 0 && col === 0) {
          id = 1; // Apex ball on foot spot
        } else if (row === 2 && col === 1) {
          id = 8; // 8-ball in the center of row 3
        } else if (row === 4 && col === 0) {
          id = swapCorners ? cornerSolid : cornerStripe;
        } else if (row === 4 && col === 4) {
          id = swapCorners ? cornerStripe : cornerSolid;
        } else {
          id = pool.pop();
        }

        // Base lattice coordinates + microscopic physical tolerance (~0.08px)
        // so contact wave disperses authentically across all 5 rows
        const bx = FOOT_SPOT_X + row * rowSpacingX + (Math.random() - 0.5) * 0.14;
        const by = SPOT_Y + (col - row * 0.5) * colSpacingY + (Math.random() - 0.5) * 0.14;

        balls.push(createBall(id, bx, by));
      }
    }

    // Relax rack positions so no two balls overlap at all (strictly d >= 2R) while remaining tight
    for (let iter = 0; iter < 40; iter++) {
      for (let i = 1; i < balls.length; i++) {
        for (let j = i + 1; j < balls.length; j++) {
          const b1 = balls[i];
          const b2 = balls[j];
          const dx = b2.x - b1.x;
          const dy = b2.y - b1.y;
          const dist = Math.hypot(dx, dy);
          const minDist = BALL_D + 0.002;
          if (dist < minDist && dist > 0.0001) {
            const overlap = (minDist - dist) * 0.5;
            const nx = dx / dist;
            const ny = dy / dist;
            // Keep apex ball anchored on the foot spot
            if (i === 1) {
              b2.x += nx * overlap * 2;
              b2.y += ny * overlap * 2;
            } else {
              b1.x -= nx * overlap;
              b1.y -= ny * overlap;
              b2.x += nx * overlap;
              b2.y += ny * overlap;
            }
          }
        }
      }
    }

    // Aim cue stick at the apex 1-ball with slight realistic cut/power variation
    const apex = balls[1];
    const targetYOffset = (Math.random() - 0.5) * 3.4;
    const aimDx = apex.x - BALL_D * 0.88 - cueBallX;
    const aimDy = (apex.y + targetYOffset) - cueBallY;
    cueAimAngle = Math.atan2(aimDy, aimDx);

    plannedCueSpeed = 1130 + Math.random() * 90;
    // Slight topspin/follow + tiny side english like a real power break
    const spinSpeed = 240 + Math.random() * 110;
    const spinAngle = cueAimAngle + (Math.random() - 0.5) * 0.28;
    plannedTopSpinX = Math.cos(spinAngle) * spinSpeed;
    plannedTopSpinY = Math.sin(spinAngle) * spinSpeed;

    cueOffset = 8;
    cueAlpha = 1;
    breakCount++;
  }

  function createBall(id, x, y) {
    return {
      id,
      x,
      y,
      vx: 0,
      vy: 0,
      spinVx: 0,
      spinVy: 0,
      stripeAngle: (Math.random() - 0.5) * Math.PI,
      rollPhaseX: (Math.random() - 0.5) * 1.4,
      rollPhaseY: (Math.random() - 0.5) * 1.4,
      potted: false,
      potting: false,
      potTimer: 0,
      potPocket: null,
      potStartX: 0,
      potStartY: 0,
      scale: 1
    };
  }

  // ============================================================================
  // 2D PHYSICS ENGINE (SUB-STEPPED, MOMENTUM CONSERVING, ZERO OVERLAP)
  // ============================================================================
  const SUBSTEPS = 28;
  const RESTITUTION_BALL = 0.965;
  const RESTITUTION_RAIL = 0.79;
  const ROLLING_FRICTION = 44.0;   // Constant deceleration on baize (px/s^2)
  const SLIDING_DRAG = 0.26;       // Velocity-proportional drag at high speed

  function stepPhysics(dt) {
    const subDt = dt / SUBSTEPS;

    for (let step = 0; step < SUBSTEPS; step++) {
      // 1. Integrate positions, rolling friction, and topspin follow
      for (let i = 0; i < balls.length; i++) {
        const b = balls[i];
        if (b.potted || b.potting) continue;

        // Advance position
        b.x += b.vx * subDt;
        b.y += b.vy * subDt;

        // Transfer residual topspin into linear velocity on cloth contact
        if (b.spinVx !== 0 || b.spinVy !== 0) {
          const transfer = Math.min(1, subDt * 6.5);
          b.vx += b.spinVx * transfer;
          b.vy += b.spinVy * transfer;
          b.spinVx *= (1 - transfer);
          b.spinVy *= (1 - transfer);
          if (Math.hypot(b.spinVx, b.spinVy) < 1) {
            b.spinVx = 0;
            b.spinVy = 0;
          }
        }

        // Apply rolling resistance + high-speed cloth drag
        const speed = Math.hypot(b.vx, b.vy);
        if (speed > 0) {
          // Visual rolling rotation update
          const moveDist = speed * subDt;
          const moveAngle = Math.atan2(b.vy, b.vx);
          b.stripeAngle += Math.sin(moveAngle - b.stripeAngle) * (moveDist / BALL_R) * 0.45;
          b.rollPhaseX = Math.max(-3.0, Math.min(3.0, b.rollPhaseX + (b.vx * subDt) / BALL_R * 0.35));
          b.rollPhaseY = Math.max(-3.0, Math.min(3.0, b.rollPhaseY + (b.vy * subDt) / BALL_R * 0.35));
          // Gently keep number circle near the top hemisphere while still rolling visibly
          b.rollPhaseX *= 0.997;
          b.rollPhaseY *= 0.997;

          const decel = ROLLING_FRICTION + speed * SLIDING_DRAG;
          const newSpeed = Math.max(0, speed - decel * subDt);
          if (newSpeed < 1.2) {
            b.vx = 0;
            b.vy = 0;
          } else {
            const ratio = newSpeed / speed;
            b.vx *= ratio;
            b.vy *= ratio;
          }
        }
      }

      // 2. Check Pocket Gravity & Pocket Capture
      for (let i = 0; i < balls.length; i++) {
        const b = balls[i];
        if (b.potted || b.potting) continue;

        for (let pIdx = 0; pIdx < POCKETS.length; pIdx++) {
          const p = POCKETS[pIdx];
          const dx = p.x - b.x;
          const dy = p.y - b.y;
          const dist = Math.hypot(dx, dy);

          // Pocket throat bevel pull when over the slate lip
          if (dist < p.r + 6 && dist > 0.001) {
            const pull = 360 * subDt;
            b.vx += (dx / dist) * pull;
            b.vy += (dy / dist) * pull;
          }

          // Captured by pocket hole
          if (dist <= p.captureR) {
            b.potting = true;
            b.potTimer = 0;
            b.potPocket = p;
            b.potStartX = b.x;
            b.potStartY = b.y;
            b.vx = 0;
            b.vy = 0;
            b.spinVx = 0;
            b.spinVy = 0;
            break;
          }
        }
      }

      // 3. Cushion & Rail Collisions
      solveCushionCollisions();

      // 4. Multi-pass Ball-to-Ball Collisions & Strict Non-Overlap Enforcement
      for (let iter = 0; iter < 3; iter++) {
        solveBallCollisions();
      }

      // 5. Final Cushion Clamp so a ball pushed by another ball never penetrates a rail
      solveCushionCollisions();
    }

    // Animate potting balls & potted tray balls
    updatePottingAnimations(dt);
  }

  function solveCushionCollisions() {
    for (let i = 0; i < balls.length; i++) {
      const b = balls[i];
      if (b.potted || b.potting) continue;

      for (let s = 0; s < COLLISION_SEGMENTS.length; s++) {
        const seg = COLLISION_SEGMENTS[s];
        const px = b.x - seg.x1;
        const py = b.y - seg.y1;
        let t = (px * seg.dx + py * seg.dy) / seg.lenSq;
        if (t < 0) t = 0;
        else if (t > 1) t = 1;

        const cx = seg.x1 + t * seg.dx;
        const cy = seg.y1 + t * seg.dy;
        const dx = b.x - cx;
        const dy = b.y - cy;
        const distSq = dx * dx + dy * dy;

        if (distSq < BALL_R * BALL_R) {
          const dist = Math.sqrt(distSq);
          let nx, ny;
          if (dist > 0.0001) {
            nx = dx / dist;
            ny = dy / dist;
          } else {
            nx = seg.nx;
            ny = seg.ny;
          }

          // Push ball strictly outside cushion segment
          const overlap = BALL_R - dist;
          b.x += nx * overlap;
          b.y += ny * overlap;

          // Reflect velocity if moving into the cushion
          const vn = b.vx * nx + b.vy * ny;
          if (vn < 0) {
            const tx = -ny;
            const ty = nx;
            const vt = b.vx * tx + b.vy * ty;
            const e = seg.isJaw ? 0.68 : RESTITUTION_RAIL;
            const newVn = -vn * e;
            const newVt = vt * 0.96;
            b.vx = nx * newVn + tx * newVt;
            b.vy = ny * newVn + ty * newVt;
          }
        }
      }

      // Hard outer safety boundary (inside pocket leather backs)
      if (b.x < WOOD_LEFT - 12)  { b.x = WOOD_LEFT - 12;  b.vx = Math.abs(b.vx) * 0.5; }
      if (b.x > WOOD_RIGHT + 12) { b.x = WOOD_RIGHT + 12; b.vx = -Math.abs(b.vx) * 0.5; }
      if (b.y < WOOD_TOP - 12)   { b.y = WOOD_TOP - 12;   b.vy = Math.abs(b.vy) * 0.5; }
      if (b.y > WOOD_BOTTOM + 12){ b.y = WOOD_BOTTOM + 12;b.vy = -Math.abs(b.vy) * 0.5; }
    }
  }

  function solveBallCollisions() {
    const n = balls.length;
    for (let i = 0; i < n; i++) {
      const b1 = balls[i];
      if (b1.potted || b1.potting) continue;

      for (let j = i + 1; j < n; j++) {
        const b2 = balls[j];
        if (b2.potted || b2.potting) continue;

        const dx = b2.x - b1.x;
        const dy = b2.y - b1.y;
        const distSq = dx * dx + dy * dy;

        if (distSq < BALL_D * BALL_D) {
          const dist = Math.sqrt(distSq) || 0.0001;
          const nx = dx / dist;
          const ny = dy / dist;

          // 1. Positional separation: strictly prevent overlap
          const overlap = BALL_D - dist;
          b1.x -= nx * (overlap * 0.5);
          b1.y -= ny * (overlap * 0.5);
          b2.x += nx * (overlap * 0.5);
          b2.y += ny * (overlap * 0.5);

          // 2. Momentum-conserving equal-mass elastic impulse
          const rvx = b2.vx - b1.vx;
          const rvy = b2.vy - b1.vy;
          const velAlongNormal = rvx * nx + rvy * ny;

          if (velAlongNormal < 0) {
            const impulse = -(1 + RESTITUTION_BALL) * velAlongNormal * 0.5;
            const ix = impulse * nx;
            const iy = impulse * ny;

            b1.vx -= ix;
            b1.vy -= iy;
            b2.vx += ix;
            b2.vy += iy;

            // Subtle tangential throw friction between colliding phenolic balls
            const tx = -ny;
            const ty = nx;
            const velAlongTangent = (rvx * tx + rvy * ty) * 0.015;
            b1.vx += velAlongTangent * tx;
            b1.vy += velAlongTangent * ty;
            b2.vx -= velAlongTangent * tx;
            b2.vy -= velAlongTangent * ty;
          }
        }
      }
    }
  }

  function updatePottingAnimations(dt) {
    for (let i = 0; i < balls.length; i++) {
      const b = balls[i];
      if (b.potting) {
        b.potTimer += dt;
        const duration = 0.24;
        const t = Math.min(1, b.potTimer / duration);
        const ease = t * t;
        b.x = b.potStartX + (b.potPocket.x - b.potStartX) * ease;
        b.y = b.potStartY + (b.potPocket.y - b.potStartY) * ease;
        b.scale = 1 - 0.48 * ease;

        if (t >= 1) {
          b.potting = false;
          b.potted = true;
          // Add to potted tray below the table (rolls in from right end of tray)
          const slotIndex = pottedTrayBalls.length;
          const targetX = TRAY_X + 31 + slotIndex * 34;
          const startX = TRAY_X + TRAY_W - 22;
          pottedTrayBalls.push({
            id: b.id,
            x: Math.max(targetX, startX),
            y: TRAY_Y + TRAY_H / 2,
            targetX,
            vx: -340,
            stripeAngle: b.stripeAngle,
            rollPhaseX: 0,
            rollPhaseY: 0,
            settled: false
          });
        }
      }
    }

    // Roll potted balls smoothly into their tray slots
    for (let i = 0; i < pottedTrayBalls.length; i++) {
      const tb = pottedTrayBalls[i];
      if (!tb.settled) {
        tb.x += tb.vx * dt;
        tb.stripeAngle -= (tb.vx * dt) / BALL_R * 0.6;
        if (tb.x <= tb.targetX) {
          tb.x = tb.targetX;
          tb.vx = 0;
          tb.settled = true;
        }
      }
    }
  }

  function areAllBallsStopped() {
    for (let i = 0; i < balls.length; i++) {
      const b = balls[i];
      if (b.potting) return false;
      if (!b.potted && (Math.abs(b.vx) > 0.05 || Math.abs(b.vy) > 0.05 || Math.abs(b.spinVx) > 0.05)) {
        return false;
      }
    }
    for (let i = 0; i < pottedTrayBalls.length; i++) {
      if (!pottedTrayBalls[i].settled) return false;
    }
    return true;
  }

  // ============================================================================
  // RENDERING BALLS, SHADOWS, CUE STICK & TRAY
  // ============================================================================
  function drawBall(c, x, y, r, id, stripeAngle, rollPhaseX, rollPhaseY, alpha = 1, inPocket = false) {
    const def = BALL_DEFS[id];
    c.save();
    c.globalAlpha = alpha;

    // 1. Base sphere (ivory white for cue ball and stripes, solid color for 1-8)
    c.beginPath();
    c.arc(x, y, r, 0, Math.PI * 2);
    c.fillStyle = def.stripe ? '#F7F4E8' : def.color;
    c.fill();

    // Clip to ball circle for stripe, number circle, and 3D shading
    c.clip();

    // 2. Stripe band for balls 9-15
    if (def.stripe) {
      c.save();
      c.translate(x, y);
      c.rotate(stripeAngle);

      const bandHalfWidth = r * 0.58;
      const curveOffset = rollPhaseY * 0.7;
      c.fillStyle = def.color;
      c.beginPath();
      c.moveTo(-r * 1.2, -bandHalfWidth + curveOffset);
      c.quadraticCurveTo(0, -bandHalfWidth - curveOffset * 1.5, r * 1.2, -bandHalfWidth + curveOffset);
      c.lineTo(r * 1.2, bandHalfWidth + curveOffset);
      c.quadraticCurveTo(0, bandHalfWidth - curveOffset * 1.5, -r * 1.2, bandHalfWidth + curveOffset);
      c.closePath();
      c.fill();
      c.restore();
    }

    // 3. Small White Number Circle + Crisp Number (for balls 1-15)
    if (id > 0) {
      const numX = x + rollPhaseX;
      const numY = y + rollPhaseY;
      const circleR = r * 0.45;

      c.beginPath();
      c.arc(numX, numY, circleR, 0, Math.PI * 2);
      c.fillStyle = '#FAF8F0';
      c.fill();

      c.save();
      c.translate(numX, numY);
      c.rotate(stripeAngle * 0.18);
      c.fillStyle = '#111317';
      c.font = `800 ${id >= 10 ? 7.6 : 8.5}px system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif`;
      c.textAlign = 'center';
      c.textBaseline = 'middle';
      c.fillText(def.label, 0, 0.5);
      c.restore();
    }

    // 4. 3D Spherical Rim Shading
    const shadeGrad = c.createRadialGradient(
      x - r * 0.28, y - r * 0.28, r * 0.15,
      x, y, r
    );
    shadeGrad.addColorStop(0,   'rgba(0, 0, 0, 0)');
    shadeGrad.addColorStop(0.62,'rgba(0, 0, 0, 0.08)');
    shadeGrad.addColorStop(0.88,'rgba(0, 0, 0, 0.28)');
    shadeGrad.addColorStop(1,   'rgba(0, 0, 0, 0.52)');
    c.fillStyle = shadeGrad;
    c.fillRect(x - r, y - r, r * 2, r * 2);

    // If dropping into pocket, darken slightly as it descends
    if (inPocket) {
      c.fillStyle = `rgba(0, 0, 0, ${(1 - r / BALL_R) * 1.35})`;
      c.fillRect(x - r, y - r, r * 2, r * 2);
    }

    // 5. Single Crisp Overhead Broadcast Highlight
    c.save();
    c.translate(x - r * 0.34, y - r * 0.34);
    c.rotate(-Math.PI / 4);
    c.beginPath();
    c.ellipse(0, 0, r * 0.27, r * 0.16, 0, 0, Math.PI * 2);
    c.fillStyle = 'rgba(255, 255, 255, 0.72)';
    c.fill();
    c.restore();

    c.restore();
  }

  function drawBallShadow(c, x, y, r) {
    // Directional shadow cast from overhead table light at center of table
    const lightDx = (x - PLAY_CENTER_X) / 480;
    const lightDy = (y - PLAY_CENTER_Y) / 360;
    const sx = x + lightDx * 4.5 + 1.8;
    const sy = y + lightDy * 4.5 + 2.8;

    c.save();
    // Soft outer cast shadow
    const sGrad = c.createRadialGradient(sx, sy, r * 0.2, sx, sy, r * 1.35);
    sGrad.addColorStop(0,   'rgba(0, 12, 5, 0.52)');
    sGrad.addColorStop(0.6, 'rgba(0, 12, 5, 0.24)');
    sGrad.addColorStop(1,   'rgba(0, 12, 5, 0)');
    c.fillStyle = sGrad;
    c.beginPath();
    c.arc(sx, sy, r * 1.35, 0, Math.PI * 2);
    c.fill();

    // Tight ambient occlusion contact shadow right under ball
    const aoGrad = c.createRadialGradient(x, y + 0.8, r * 0.1, x, y + 0.8, r * 0.98);
    aoGrad.addColorStop(0,   'rgba(0, 0, 0, 0.55)');
    aoGrad.addColorStop(0.75,'rgba(0, 0, 0, 0.22)');
    aoGrad.addColorStop(1,   'rgba(0, 0, 0, 0)');
    c.fillStyle = aoGrad;
    c.beginPath();
    c.arc(x, y + 0.8, r * 0.98, 0, Math.PI * 2);
    c.fill();
    c.restore();
  }

  function drawCueStick(c, cueBall, angle, distFromBall, alpha) {
    if (alpha <= 0.01) return;
    const stickLen = 430;
    const tipRadius = 4.2;
    const buttRadius = 9.5;

    c.save();
    c.globalAlpha = alpha;
    c.translate(cueBall.x, cueBall.y);
    c.rotate(angle + Math.PI); // Point backwards from cue ball along aim line

    const x0 = BALL_R + distFromBall;

    // Soft drop shadow of the cue stick on cloth & rail
    c.save();
    c.translate(3, 9);
    c.beginPath();
    c.moveTo(x0, -tipRadius);
    c.lineTo(x0 + stickLen, -buttRadius);
    c.lineTo(x0 + stickLen, buttRadius);
    c.lineTo(x0, tipRadius);
    c.closePath();
    c.fillStyle = 'rgba(0, 0, 0, 0.36)';
    c.fill();
    c.restore();

    // Main Cue Stick Tapered Clip Path
    c.beginPath();
    c.moveTo(x0, -tipRadius);
    c.lineTo(x0 + stickLen, -buttRadius);
    c.lineTo(x0 + stickLen, buttRadius);
    c.lineTo(x0, tipRadius);
    c.closePath();
    c.save();
    c.clip();

    // 1. Blue chalked leather tip (0..5px)
    c.fillStyle = '#2574b8';
    c.fillRect(x0, -12, 5.5, 24);

    // 2. White ivory ferrule (5.5..16px)
    c.fillStyle = '#f5f2e6';
    c.fillRect(x0 + 5.5, -12, 10.5, 24);

    // 3. Hard rock maple shaft (16..215px)
    const mapleGrad = c.createLinearGradient(0, -buttRadius, 0, buttRadius);
    mapleGrad.addColorStop(0,   '#d1b184');
    mapleGrad.addColorStop(0.35,'#f3dcb7');
    mapleGrad.addColorStop(0.7, '#cfa977');
    mapleGrad.addColorStop(1,   '#9e7849');
    c.fillStyle = mapleGrad;
    c.fillRect(x0 + 16, -12, 200, 24);

    // 4. Brass joint collar (216..226px)
    const brassGrad = c.createLinearGradient(0, -buttRadius, 0, buttRadius);
    brassGrad.addColorStop(0, '#9c7a32');
    brassGrad.addColorStop(0.4, '#fae08c');
    brassGrad.addColorStop(1, '#755719');
    c.fillStyle = brassGrad;
    c.fillRect(x0 + 216, -12, 10, 24);

    // 5. Ebony / Rosewood butt forearm & sleeve (226..422px)
    const buttGrad = c.createLinearGradient(0, -buttRadius, 0, buttRadius);
    buttGrad.addColorStop(0,   '#2b140c');
    buttGrad.addColorStop(0.35,'#4f2517');
    buttGrad.addColorStop(0.7, '#261009');
    buttGrad.addColorStop(1,   '#140703');
    c.fillStyle = buttGrad;
    c.fillRect(x0 + 226, -12, 196, 24);

    // Decorative splice points on forearm
    c.fillStyle = '#e5cfa3';
    for (let s = -1; s <= 1; s += 2) {
      c.beginPath();
      c.moveTo(x0 + 226, s * 2.2);
      c.lineTo(x0 + 285, s * 4.5);
      c.lineTo(x0 + 226, s * 6.8);
      c.closePath();
      c.fill();
    }

    // Black & white linen wrap section (300..395px)
    c.fillStyle = '#1b1d20';
    c.fillRect(x0 + 300, -12, 95, 24);

    // Rubber bumper (422..430px)
    c.fillStyle = '#111';
    c.fillRect(x0 + 422, -12, 10, 24);

    c.restore();
    c.restore();
  }

  // ============================================================================
  // MAIN ANIMATION LOOP & STATE MACHINE
  // ============================================================================
  setupRefereeRack();
  let lastTimestamp = performance.now();

  function tick(now) {
    const rawDt = (now - lastTimestamp) / 1000;
    lastTimestamp = now;
    // Clamp dt so background tab switching never causes large jumps
    const dt = Math.min(rawDt, 0.035);

    stateTimer += dt;

    if (simState === STATE_RACKED) {
      // Hold freshly racked balls for 1.0 second on load / re-rack
      cueOffset = 8;
      cueAlpha = 1;
      if (stateTimer >= 1.0) {
        simState = STATE_STRIKING;
        stateTimer = 0;
      }
    } else if (simState === STATE_STRIKING) {
      // Cue stick draws back over 0.65s, holds briefly, then drives forward into cue ball
      const drawbackDur = 0.62;
      const pauseDur = 0.10;
      const driveDur = 0.13;
      const maxDrawback = 128;

      if (stateTimer < drawbackDur) {
        // Smooth ease-in-out drawback
        const t = stateTimer / drawbackDur;
        const ease = 0.5 - 0.5 * Math.cos(t * Math.PI);
        cueOffset = 8 + ease * maxDrawback;
      } else if (stateTimer < drawbackDur + pauseDur) {
        cueOffset = 8 + maxDrawback;
      } else if (stateTimer < drawbackDur + pauseDur + driveDur) {
        // Explosive forward acceleration (cubic ease-in)
        const t = (stateTimer - drawbackDur - pauseDur) / driveDur;
        const ease = t * t * t;
        cueOffset = (8 + maxDrawback) * (1 - ease);
      } else {
        // Impact! Launch cue ball into the rack
        cueOffset = 0;
        const cueBall = balls[0];
        cueBall.vx = Math.cos(cueAimAngle) * plannedCueSpeed;
        cueBall.vy = Math.sin(cueAimAngle) * plannedCueSpeed;
        cueBall.spinVx = plannedTopSpinX;
        cueBall.spinVy = plannedTopSpinY;

        simState = STATE_SIMULATING;
        stateTimer = 0;
      }
    } else if (simState === STATE_SIMULATING) {
      // Follow-through & smooth retraction of the cue stick after impact
      if (stateTimer < 0.08) {
        cueOffset = -16 * (stateTimer / 0.08);
      } else if (stateTimer < 0.42) {
        const t = (stateTimer - 0.08) / 0.34;
        cueOffset = -16 + t * 55;
        cueAlpha = Math.max(0, 1 - t);
      } else {
        cueAlpha = 0;
      }

      stepPhysics(dt);

      if (stateTimer > 0.5 && areAllBallsStopped()) {
        simState = STATE_REST_WAIT;
        stateTimer = 0;
      }
    } else if (simState === STATE_REST_WAIT) {
      cueAlpha = 0;
      // Wait 2.0 seconds after every ball has stopped, then re-rack and break again
      if (stateTimer >= 2.0) {
        setupRefereeRack();
        simState = STATE_RACKED;
        stateTimer = 0;
      }
    }

    // ==========================================================================
    // DRAW SCENE
    // ==========================================================================
    ctx.clearRect(0, 0, LOGICAL_W, LOGICAL_H);

    // 1. Static Table + Tray Buffer
    ctx.drawImage(tableBuffer, 0, 0, LOGICAL_W, LOGICAL_H);

    // 2. Balls currently dropping into pockets (drawn inside pocket wells)
    for (let i = 0; i < balls.length; i++) {
      const b = balls[i];
      if (b.potting) {
        drawBall(ctx, b.x, b.y, BALL_R * b.scale, b.id, b.stripeAngle, b.rollPhaseX, b.rollPhaseY, 1, true);
      }
    }

    // 3. Soft Shadows for all active balls on the table
    for (let i = 0; i < balls.length; i++) {
      const b = balls[i];
      if (!b.potted && !b.potting) {
        drawBallShadow(ctx, b.x, b.y, BALL_R);
      }
    }

    // 4. Active Balls on the Table
    for (let i = 0; i < balls.length; i++) {
      const b = balls[i];
      if (!b.potted && !b.potting) {
        drawBall(ctx, b.x, b.y, BALL_R, b.id, b.stripeAngle, b.rollPhaseX, b.rollPhaseY, 1, false);
      }
    }

    // 5. Potted Balls in the Tray Below the Table
    for (let i = 0; i < pottedTrayBalls.length; i++) {
      const tb = pottedTrayBalls[i];
      drawBallShadow(ctx, tb.x, tb.y, BALL_R);
      drawBall(ctx, tb.x, tb.y, BALL_R, tb.id, tb.stripeAngle, tb.rollPhaseX, tb.rollPhaseY, 1, false);
    }

    // 6. Cue Stick
    if (cueAlpha > 0 && !balls[0].potted) {
      drawCueStick(ctx, balls[0], cueAimAngle, cueOffset, cueAlpha);
    }

    requestAnimationFrame(tick);
  }

  requestAnimationFrame(tick);
})();
</script>
</body>
</html>

One email when a new model lands on the board or in the showcase. Unsubscribe anytime.