← back to Games Agentabrams

games/tetris/index.html

512 lines

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<title>Abrams Blocks — Tetris</title>
<style>
  * { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
  html, body { height: 100%; margin: 0; }
  body {
    display: flex; align-items: center; justify-content: center;
    font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
    background: radial-gradient(120% 120% at 50% 0%, #1b2030 0%, #0c0e15 70%);
    color: #e8e9ef; overflow: hidden; user-select: none; touch-action: none;
  }
  #wrap {
    display: flex; gap: 18px; padding: 16px;
    align-items: flex-start; max-width: 100%;
  }
  canvas { display: block; }
  #board {
    background: #0a0c12;
    border: 1px solid #2b3140; border-radius: 10px;
    box-shadow: 0 0 0 1px #000 inset, 0 20px 60px rgba(0,0,0,.5);
  }
  #side {
    display: flex; flex-direction: column; gap: 14px; width: 138px;
  }
  .card {
    background: linear-gradient(180deg, #171b26, #12151d);
    border: 1px solid #2b3140; border-radius: 10px; padding: 10px 12px;
  }
  .card h2 {
    margin: 0 0 6px; font-size: 9.5px; font-weight: 700; letter-spacing: 2px;
    opacity: .45; text-transform: uppercase;
  }
  .stat { font-size: 22px; font-weight: 700; letter-spacing: 1px; line-height: 1.1; }
  .stat.small { font-size: 15px; }
  .pcanvas { display: block; margin: 0 auto; }
  #keys { font-size: 9.5px; opacity: .4; line-height: 1.9; letter-spacing: .5px; }
  #keys b { color: #7c5cff; font-weight: 700; }
  #brand { font-size: 10px; letter-spacing: 2px; opacity: .5; text-align: center; }
  #brand .dot { color: #7c5cff; }
  #btns { display: flex; gap: 8px; }
  #btns button {
    flex: 1; background: #1d2230; border: 1px solid #2f3648; color: #cfd3df;
    border-radius: 8px; padding: 7px 0; font: inherit; font-size: 11px; cursor: pointer;
  }
  #btns button:hover { background: #262d40; }
  /* overlay */
  #overlay {
    position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
    background: rgba(8,10,16,.72); backdrop-filter: blur(2px); z-index: 5;
  }
  #overlay.hidden { display: none; }
  #panel {
    text-align: center; padding: 28px 34px; border-radius: 14px;
    background: linear-gradient(180deg, #1a1f2c, #12151d);
    border: 1px solid #313a52; box-shadow: 0 24px 70px rgba(0,0,0,.6);
    max-width: 320px;
  }
  #panel .title { font-size: 26px; font-weight: 800; letter-spacing: 2px; margin-bottom: 4px; }
  #panel .title .dot { color: #7c5cff; }
  #panel .sub { font-size: 11px; opacity: .55; line-height: 1.8; margin: 10px 0 18px; }
  #panel button {
    background: #7c5cff; border: 0; color: #fff; font: inherit; font-weight: 700;
    letter-spacing: 1px; padding: 11px 26px; border-radius: 10px; cursor: pointer; font-size: 13px;
  }
  #panel button:hover { background: #8f72ff; }
  #panel .score { font-size: 13px; opacity: .8; margin-bottom: 4px; }
  #panel .score b { color: #ffd166; }
  @media (max-width: 620px) {
    #wrap { gap: 10px; padding: 8px; }
    #side { width: 84px; gap: 8px; }
    .card { padding: 7px 8px; }
    .stat { font-size: 16px; }
    #keys { display: none; }
  }
</style>
</head>
<body>
<div id="wrap" style="position:relative">
  <canvas id="board"></canvas>
  <div id="side">
    <div id="brand">AGENT<span class="dot">·</span>ABRAMS</div>
    <div class="card">
      <h2>Score</h2><div class="stat" id="score">0</div>
    </div>
    <div class="card">
      <h2>Best</h2><div class="stat small" id="best">0</div>
    </div>
    <div class="card" style="display:flex;gap:12px;justify-content:space-between">
      <div><h2>Lines</h2><div class="stat small" id="lines">0</div></div>
      <div><h2>Level</h2><div class="stat small" id="level">1</div></div>
    </div>
    <div class="card">
      <h2>Next</h2><canvas id="next" class="pcanvas"></canvas>
    </div>
    <div class="card">
      <h2>Hold</h2><canvas id="hold" class="pcanvas"></canvas>
    </div>
    <div id="btns">
      <button id="pauseBtn">Pause</button>
      <button id="muteBtn">🔊</button>
    </div>
    <div id="keys">
      <b>← →</b> move<br>
      <b>↑ / X</b> rotate cw<br>
      <b>Z</b> rotate ccw<br>
      <b>↓</b> soft drop<br>
      <b>Space</b> hard drop<br>
      <b>C</b> hold<br>
      <b>P</b> pause · <b>M</b> mute
    </div>
  </div>

  <div id="overlay">
    <div id="panel">
      <div class="title">ABRAMS<span class="dot">·</span>BLOCKS</div>
      <div class="sub" id="panelSub">
        Stack the falling tetrominoes, clear lines, chase the level.<br>
        Arrows to move · ↑/Z rotate · Space hard-drop · C hold.
      </div>
      <div class="score" id="panelScore" style="display:none"></div>
      <button id="startBtn">Play</button>
    </div>
  </div>
</div>

<script>
(() => {
  "use strict";

  // ---------- constants ----------
  const COLS = 10, ROWS = 20, HIDDEN = 2; // 2 hidden rows above the top
  const TOTAL_ROWS = ROWS + HIDDEN;
  const COLORS = {
    I: "#38e1e1", O: "#f5d442", T: "#b46bff", S: "#5be36a",
    Z: "#ff5d6c", J: "#4d7dff", L: "#ff9f43", G: "#2a3145"
  };
  // SRS piece definitions — each is a list of 4 rotation states, cells as [x,y] in a 4x4 box
  const SHAPES = {
    I: [[[0,1],[1,1],[2,1],[3,1]], [[2,0],[2,1],[2,2],[2,3]], [[0,2],[1,2],[2,2],[3,2]], [[1,0],[1,1],[1,2],[1,3]]],
    J: [[[0,0],[0,1],[1,1],[2,1]], [[1,0],[2,0],[1,1],[1,2]], [[0,1],[1,1],[2,1],[2,2]], [[1,0],[1,1],[0,2],[1,2]]],
    L: [[[2,0],[0,1],[1,1],[2,1]], [[1,0],[1,1],[1,2],[2,2]], [[0,1],[1,1],[2,1],[0,2]], [[0,0],[1,0],[1,1],[1,2]]],
    O: [[[1,0],[2,0],[1,1],[2,1]], [[1,0],[2,0],[1,1],[2,1]], [[1,0],[2,0],[1,1],[2,1]], [[1,0],[2,0],[1,1],[2,1]]],
    S: [[[1,0],[2,0],[0,1],[1,1]], [[1,0],[1,1],[2,1],[2,2]], [[1,1],[2,1],[0,2],[1,2]], [[0,0],[0,1],[1,1],[1,2]]],
    T: [[[1,0],[0,1],[1,1],[2,1]], [[1,0],[1,1],[2,1],[1,2]], [[0,1],[1,1],[2,1],[1,2]], [[1,0],[0,1],[1,1],[1,2]]],
    Z: [[[0,0],[1,0],[1,1],[2,1]], [[2,0],[1,1],[2,1],[1,2]], [[0,1],[1,1],[1,2],[2,2]], [[1,0],[0,1],[1,1],[0,2]]]
  };
  // SRS wall-kick data (JLSTZ). I-piece has its own table.
  const KICKS = {
    "0>1":[[0,0],[-1,0],[-1,1],[0,-2],[-1,-2]], "1>0":[[0,0],[1,0],[1,-1],[0,2],[1,2]],
    "1>2":[[0,0],[1,0],[1,-1],[0,2],[1,2]],     "2>1":[[0,0],[-1,0],[-1,1],[0,-2],[-1,-2]],
    "2>3":[[0,0],[1,0],[1,1],[0,-2],[1,-2]],    "3>2":[[0,0],[-1,0],[-1,-1],[0,2],[-1,2]],
    "3>0":[[0,0],[-1,0],[-1,-1],[0,2],[-1,2]],  "0>3":[[0,0],[1,0],[1,1],[0,-2],[1,-2]]
  };
  const KICKS_I = {
    "0>1":[[0,0],[-2,0],[1,0],[-2,-1],[1,2]], "1>0":[[0,0],[2,0],[-1,0],[2,1],[-1,-2]],
    "1>2":[[0,0],[-1,0],[2,0],[-1,2],[2,-1]], "2>1":[[0,0],[1,0],[-2,0],[1,-2],[-2,1]],
    "2>3":[[0,0],[2,0],[-1,0],[2,1],[-1,-2]], "3>2":[[0,0],[-2,0],[1,0],[-2,-1],[1,2]],
    "3>0":[[0,0],[1,0],[-2,0],[1,-2],[-2,1]], "0>3":[[0,0],[-1,0],[2,0],[-1,2],[2,-1]]
  };
  const TYPES = ["I","J","L","O","S","T","Z"];

  // ---------- canvas sizing ----------
  const board = document.getElementById("board");
  const bctx = board.getContext("2d");
  let CELL = 30; // px, recomputed on resize
  const nextCv = document.getElementById("next"), nctx = nextCv.getContext("2d");
  const holdCv = document.getElementById("hold"), hctx = holdCv.getContext("2d");

  function fitCanvas() {
    const maxH = Math.min(window.innerHeight - 40, 720);
    const maxW = Math.min(window.innerWidth - 190, 480);
    CELL = Math.max(14, Math.floor(Math.min(maxH / ROWS, maxW / COLS)));
    const dpr = window.devicePixelRatio || 1;
    board.width = COLS * CELL * dpr; board.height = ROWS * CELL * dpr;
    board.style.width = COLS * CELL + "px"; board.style.height = ROWS * CELL + "px";
    bctx.setTransform(dpr,0,0,dpr,0,0);
    const pc = Math.max(12, Math.floor(CELL * 0.62));
    for (const [cv,ctx] of [[nextCv,nctx],[holdCv,hctx]]) {
      cv.width = 4*pc*dpr; cv.height = 4*pc*dpr;
      cv.style.width = 4*pc+"px"; cv.style.height = 4*pc+"px";
      ctx.setTransform(dpr,0,0,dpr,0,0);
    }
    nctx._c = pc; hctx._c = pc;
    draw();
  }
  window.addEventListener("resize", fitCanvas);

  // ---------- audio (procedural, zero-dep) ----------
  let audioCtx = null, muted = (localStorage.getItem("blocks.muted") === "1");
  function ac() { if (!audioCtx) { try { audioCtx = new (window.AudioContext||window.webkitAudioContext)(); } catch(e){} } return audioCtx; }
  function beep(freq, dur, type="square", vol=0.05) {
    if (muted) return; const c = ac(); if (!c) return;
    const o = c.createOscillator(), g = c.createGain();
    o.type = type; o.frequency.value = freq;
    g.gain.setValueAtTime(vol, c.currentTime);
    g.gain.exponentialRampToValueAtTime(0.0001, c.currentTime + dur);
    o.connect(g); g.connect(c.destination); o.start(); o.stop(c.currentTime + dur);
  }
  const S = {
    move:  () => beep(220, .03, "square", .03),
    rotate:() => beep(330, .04, "square", .035),
    drop:  () => beep(140, .08, "sawtooth", .05),
    lock:  () => beep(180, .05, "triangle", .04),
    clear: (n) => { const base=[440,554,659,880][Math.min(n-1,3)]; beep(base,.10,"square",.06); setTimeout(()=>beep(base*1.5,.12,"square",.05),90); },
    hold:  () => beep(400, .05, "triangle", .04),
    over:  () => { beep(200,.2,"sawtooth",.06); setTimeout(()=>beep(120,.35,"sawtooth",.06),160); },
    level: () => { beep(660,.08,"square",.05); setTimeout(()=>beep(990,.12,"square",.05),80); }
  };

  // ---------- game state ----------
  let grid, cur, nextQ, holdType, canHold, bag;
  let score, best, lines, level, dropMs, dropAcc, lastT;
  let state = "menu"; // menu | playing | paused | over
  best = +(localStorage.getItem("blocks.best") || 0);

  function newBag() {
    const b = TYPES.slice();
    for (let i=b.length-1;i>0;i--){ const j=Math.floor(Math.random()*(i+1)); [b[i],b[j]]=[b[j],b[i]]; }
    return b;
  }
  function pullType() {
    if (!bag || bag.length === 0) bag = newBag();
    return bag.pop();
  }
  function spawn(type) {
    const p = { type, rot: 0, x: 3, y: 0 };
    // I and O spawn a touch higher/centered per SRS box; y offset into hidden area
    p.y = (type === "I") ? -1 : 0;
    return p;
  }
  function cellsOf(p, rot = p.rot, x = p.x, y = p.y) {
    return SHAPES[p.type][((rot % 4) + 4) % 4].map(([cx,cy]) => [x+cx, y+cy]);
  }
  function collides(p, rot = p.rot, x = p.x, y = p.y) {
    for (const [cx,cy] of cellsOf(p, rot, x, y)) {
      if (cx < 0 || cx >= COLS || cy >= ROWS) return true;
      if (cy >= 0 && grid[cy][cx]) return true;
    }
    return false;
  }

  function reset() {
    grid = Array.from({length: ROWS}, () => Array(COLS).fill(null));
    bag = newBag();
    nextQ = [pullType(), pullType(), pullType()];
    holdType = null; canHold = true;
    score = 0; lines = 0; level = 1;
    dropMs = levelSpeed(1); dropAcc = 0;
    nextPiece();
  }
  function levelSpeed(lv) { return Math.max(60, Math.round(800 * Math.pow(0.83, lv - 1))); }

  function nextPiece() {
    const t = nextQ.shift();
    nextQ.push(pullType());
    cur = spawn(t);
    canHold = true;
    if (collides(cur)) { gameOver(); }
  }

  function move(dx) {
    if (!collides(cur, cur.rot, cur.x + dx, cur.y)) { cur.x += dx; S.move(); draw(); return true; }
    return false;
  }
  function rotate(dir) {
    const from = cur.rot, to = ((from + dir) % 4 + 4) % 4;
    const table = cur.type === "I" ? KICKS_I : KICKS;
    if (cur.type === "O") { return; }
    const kicks = table[`${from}>${to}`] || [[0,0]];
    for (const [kx,ky] of kicks) {
      if (!collides(cur, to, cur.x + kx, cur.y - ky)) {
        cur.rot = to; cur.x += kx; cur.y -= ky; S.rotate(); draw(); return;
      }
    }
  }
  function softDrop() {
    if (!collides(cur, cur.rot, cur.x, cur.y + 1)) { cur.y++; score += 1; updateHUD(); draw(); }
    else lock();
  }
  function hardDrop() {
    let d = 0;
    while (!collides(cur, cur.rot, cur.x, cur.y + 1)) { cur.y++; d++; }
    score += d * 2; S.drop();
    lock();
  }
  function hold() {
    if (!canHold) return;
    S.hold();
    if (holdType == null) { holdType = cur.type; nextPiece(); }
    else { const t = holdType; holdType = cur.type; cur = spawn(t); if (collides(cur)) gameOver(); }
    canHold = false;
    draw();
  }
  function lock() {
    for (const [cx,cy] of cellsOf(cur)) { if (cy >= 0) grid[cy][cx] = cur.type; }
    S.lock();
    // clear lines
    let cleared = 0;
    for (let y = ROWS - 1; y >= 0; y--) {
      if (grid[y].every(c => c)) {
        grid.splice(y, 1); grid.unshift(Array(COLS).fill(null)); cleared++; y++;
      }
    }
    if (cleared > 0) {
      const pts = [0, 100, 300, 500, 800][cleared] * level;
      score += pts; lines += cleared;
      const newLevel = Math.floor(lines / 10) + 1;
      if (newLevel > level) { level = newLevel; dropMs = levelSpeed(level); S.level(); }
      else S.clear(cleared);
    }
    updateHUD();
    if (state === "playing") nextPiece();
    draw();
  }

  function ghostY() {
    let y = cur.y;
    while (!collides(cur, cur.rot, cur.x, y + 1)) y++;
    return y;
  }

  // ---------- rendering ----------
  function roundedCell(ctx, x, y, s, color, alpha = 1) {
    ctx.globalAlpha = alpha;
    ctx.fillStyle = color;
    const r = Math.max(2, s * 0.14), pad = Math.max(1, s * 0.06);
    const px = x + pad, py = y + pad, ps = s - pad * 2;
    ctx.beginPath();
    ctx.roundRect(px, py, ps, ps, r);
    ctx.fill();
    // top highlight
    ctx.globalAlpha = alpha * 0.28; ctx.fillStyle = "#ffffff";
    ctx.beginPath(); ctx.roundRect(px, py, ps, ps * 0.42, r); ctx.fill();
    ctx.globalAlpha = 1;
  }

  function draw() {
    bctx.clearRect(0, 0, COLS * CELL, ROWS * CELL);
    // grid dots
    bctx.strokeStyle = "rgba(255,255,255,0.03)";
    bctx.lineWidth = 1;
    for (let x = 1; x < COLS; x++) { bctx.beginPath(); bctx.moveTo(x*CELL,0); bctx.lineTo(x*CELL,ROWS*CELL); bctx.stroke(); }
    for (let y = 1; y < ROWS; y++) { bctx.beginPath(); bctx.moveTo(0,y*CELL); bctx.lineTo(COLS*CELL,y*CELL); bctx.stroke(); }

    // settled
    for (let y = 0; y < ROWS; y++) for (let x = 0; x < COLS; x++) {
      if (grid[y][x]) roundedCell(bctx, x*CELL, y*CELL, CELL, COLORS[grid[y][x]]);
    }

    if (cur && (state === "playing" || state === "paused")) {
      // ghost
      const gy = ghostY();
      for (const [cx,cy] of cellsOf(cur, cur.rot, cur.x, gy)) {
        if (cy >= 0) roundedCell(bctx, cx*CELL, cy*CELL, CELL, COLORS[cur.type], 0.18);
      }
      // active
      for (const [cx,cy] of cellsOf(cur)) {
        if (cy >= 0) roundedCell(bctx, cx*CELL, cy*CELL, CELL, COLORS[cur.type]);
      }
    }

    if (state === "paused") {
      bctx.fillStyle = "rgba(8,10,16,.6)";
      bctx.fillRect(0,0,COLS*CELL,ROWS*CELL);
      bctx.fillStyle = "#e8e9ef"; bctx.font = `700 ${CELL*0.7}px ui-monospace, monospace`;
      bctx.textAlign = "center"; bctx.textBaseline = "middle";
      bctx.fillText("PAUSED", COLS*CELL/2, ROWS*CELL/2);
    }
    drawPreview(nctx, nextQ && nextQ[0]);
    drawPreview(hctx, holdType, !canHold && holdType);
  }

  function drawPreview(ctx, type, dim) {
    const pc = ctx._c || 18;
    ctx.clearRect(0,0,4*pc,4*pc);
    if (!type) return;
    const cells = SHAPES[type][0];
    const xs = cells.map(c=>c[0]), ys = cells.map(c=>c[1]);
    const minx = Math.min(...xs), maxx = Math.max(...xs);
    const miny = Math.min(...ys), maxy = Math.max(...ys);
    const w = maxx - minx + 1, h = maxy - miny + 1;
    const ox = (4 - w) / 2 - minx, oy = (4 - h) / 2 - miny;
    for (const [cx,cy] of cells) roundedCell(ctx, (cx+ox)*pc, (cy+oy)*pc, pc, COLORS[type], dim ? 0.35 : 1);
  }

  function updateHUD() {
    document.getElementById("score").textContent = score.toLocaleString();
    document.getElementById("lines").textContent = lines;
    document.getElementById("level").textContent = level;
    if (score > best) { best = score; localStorage.setItem("blocks.best", best); }
    document.getElementById("best").textContent = best.toLocaleString();
  }

  // ---------- loop ----------
  function tick(t) {
    if (state !== "playing") { lastT = t; requestAnimationFrame(tick); return; }
    const dt = t - lastT; lastT = t;
    dropAcc += dt;
    if (dropAcc >= dropMs) {
      dropAcc = 0;
      if (!collides(cur, cur.rot, cur.x, cur.y + 1)) cur.y++;
      else lock();
      draw();
    }
    requestAnimationFrame(tick);
  }

  // ---------- state transitions ----------
  const overlay = document.getElementById("overlay");
  const panelSub = document.getElementById("panelSub");
  const panelScore = document.getElementById("panelScore");
  const startBtn = document.getElementById("startBtn");

  function startGame() {
    ac();
    reset(); updateHUD();
    state = "playing"; lastT = performance.now(); dropAcc = 0;
    overlay.classList.add("hidden");
    draw();
  }
  function gameOver() {
    state = "over"; S.over();
    panelScore.style.display = "block";
    panelScore.innerHTML = `Score <b>${score.toLocaleString()}</b> · Best <b>${best.toLocaleString()}</b> · Lines ${lines}`;
    document.querySelector("#panel .title").innerHTML = 'GAME<span class="dot">·</span>OVER';
    panelSub.innerHTML = "The stack topped out.<br>Clear lines faster to survive higher levels.";
    startBtn.textContent = "Play Again";
    overlay.classList.remove("hidden");
  }
  function togglePause() {
    if (state === "playing") { state = "paused"; draw(); document.getElementById("pauseBtn").textContent = "Resume"; }
    else if (state === "paused") { state = "playing"; lastT = performance.now(); document.getElementById("pauseBtn").textContent = "Pause"; draw(); }
  }

  // ---------- input ----------
  const KEYMAP = {
    ArrowLeft: () => move(-1), ArrowRight: () => move(1),
    ArrowDown: () => softDrop(),
    ArrowUp: () => rotate(1), x: () => rotate(1), X: () => rotate(1),
    z: () => rotate(-1), Z: () => rotate(-1),
    c: () => hold(), C: () => hold(),
    " ": () => hardDrop()
  };
  window.addEventListener("keydown", (e) => {
    if (["ArrowLeft","ArrowRight","ArrowUp","ArrowDown"," "].includes(e.key)) e.preventDefault();
    if (e.key === "p" || e.key === "P" || e.key === "Escape") { if (state==="playing"||state==="paused") togglePause(); return; }
    if (e.key === "m" || e.key === "M") { toggleMute(); return; }
    if (e.repeat && (e.key===" "||e.key==="c"||e.key==="C"||e.key==="z"||e.key==="Z"||e.key==="x"||e.key==="X"||e.key==="ArrowUp")) return;
    if (state !== "playing") { if ((e.key===" "||e.key==="Enter") && state!=="playing") startBtn.click(); return; }
    const fn = KEYMAP[e.key]; if (fn) fn();
  });

  function toggleMute() {
    muted = !muted; localStorage.setItem("blocks.muted", muted ? "1" : "0");
    document.getElementById("muteBtn").textContent = muted ? "🔇" : "🔊";
  }

  document.getElementById("pauseBtn").addEventListener("click", () => { if (state==="playing"||state==="paused") togglePause(); });
  document.getElementById("muteBtn").addEventListener("click", toggleMute);
  startBtn.addEventListener("click", startGame);

  // auto-pause when tab/frame loses focus
  window.addEventListener("blur", () => { if (state === "playing") togglePause(); });

  // ---------- touch controls ----------
  let tStart = null, tMoved = false, lastTapT = 0;
  const SWIPE = 24;
  board.addEventListener("touchstart", (e) => {
    if (state === "menu" || state === "over") { startBtn.click(); return; }
    if (state === "paused") { togglePause(); return; }
    tStart = { x: e.touches[0].clientX, y: e.touches[0].clientY, t: performance.now(), cx: cur.x }; tMoved = false;
  }, { passive: true });
  board.addEventListener("touchmove", (e) => {
    if (!tStart || state !== "playing") return;
    const dx = e.touches[0].clientX - tStart.x, dy = e.touches[0].clientY - tStart.y;
    if (Math.abs(dx) > Math.abs(dy)) {
      const target = tStart.cx + Math.round(dx / (CELL));
      while (cur.x < target) { if (!move(1)) break; }
      while (cur.x > target) { if (!move(-1)) break; }
      if (Math.abs(dx) > SWIPE) tMoved = true;
    } else if (dy > SWIPE * 2) { softDrop(); tStart.y = e.touches[0].clientY; tMoved = true; }
  }, { passive: true });
  board.addEventListener("touchend", (e) => {
    if (!tStart || state !== "playing") { tStart = null; return; }
    const dur = performance.now() - tStart.t;
    const dy = (e.changedTouches[0].clientY - tStart.y);
    if (!tMoved && dur < 250) {
      // tap = rotate; double-tap-ish fast downward fling handled above
      rotate(1);
    } else if (dy > CELL * 6 && dur < 260) {
      hardDrop();
    }
    tStart = null;
  }, { passive: true });

  // ---------- boot ----------
  document.getElementById("muteBtn").textContent = muted ? "🔇" : "🔊";
  document.getElementById("best").textContent = best.toLocaleString();
  // idle empty board behind the menu — init grid BEFORE first draw (fitCanvas draws)
  grid = Array.from({length: ROWS}, () => Array(COLS).fill(null));
  fitCanvas();
  draw();
  requestAnimationFrame(tick);
})();
</script>
</body>
</html>