[object Object]

← back to Games Agentabrams

Add Abrams Blocks (Tetris) to the arcade — a genuinely different genre

8784aaf5133eb49769f753d5ef2147767645baa4 · 2026-07-24 08:37:21 -0700 · Steve Abrams

Full-featured Tetris: SRS rotation with wall kicks, 7-bag randomizer,
ghost piece, hold, next-queue, level speed-up, standard line-clear scoring.
Keyboard + touch (swipe move, tap rotate, fling hard-drop), procedural
Web Audio, persistent best, pause/mute. Pure Canvas 2D, zero dependencies.
Headless-verified: 8/8 playability checks, error-free game + hub deep-link.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 8784aaf5133eb49769f753d5ef2147767645baa4
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Fri Jul 24 08:37:21 2026 -0700

    Add Abrams Blocks (Tetris) to the arcade — a genuinely different genre
    
    Full-featured Tetris: SRS rotation with wall kicks, 7-bag randomizer,
    ghost piece, hold, next-queue, level speed-up, standard line-clear scoring.
    Keyboard + touch (swipe move, tap rotate, fling hard-drop), procedural
    Web Audio, persistent best, pause/mute. Pure Canvas 2D, zero dependencies.
    Headless-verified: 8/8 playability checks, error-free game + hub deep-link.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 README.md               |   5 +-
 games.json              |   8 +
 games/tetris/index.html | 511 ++++++++++++++++++++++++++++++++++++++++++++++++
 3 files changed, 523 insertions(+), 1 deletion(-)

diff --git a/README.md b/README.md
index 512b784..a88c7b4 100644
--- a/README.md
+++ b/README.md
@@ -22,7 +22,10 @@ The hub auto-selects the last-played game (localStorage) and supports `#<id>` de
 
 | File | What | Source |
 |---|---|---|
-| `RubiksCube.aa` | 3D Rubik's cube, pure CSS 3D — orbit, zoom, scramble, undo, timer, solved detection | synced from `~/Projects/rubik-cube-3d` |
+| `AbramsCube.aa` | 3D Rubik's cube, pure CSS 3D — orbit, zoom, scramble, undo, timer, solved detection | synced from `~/Projects/rubik-cube-3d` |
+| `AtmosSkyfire.aa` | Three.js arcade flyer — bank through the sky, fire missiles, dodge/shoot birds | vendored locally |
+| `AbramsBlocks.aa` | Full Tetris — SRS rotation + wall kicks, 7-bag, ghost, hold, next-queue, levels, line-clear scoring, Web Audio, touch | pure Canvas 2D, zero-dep |
+| `FlappyClone.aa` / `FlappySky.aa` / `FlappyFlight.aa` | Flappy-Bird-style flyers — flap through pipes/gates, medals, persistent best | pure Canvas 2D, zero-dep |
 
 ## Tests
 
diff --git a/games.json b/games.json
index f6863d0..e2b3ebf 100644
--- a/games.json
+++ b/games.json
@@ -32,6 +32,14 @@
       "path": "games/flappy-sky/",
       "added": "2026-07-24"
     },
+    {
+      "id": "tetris",
+      "file": "AbramsBlocks.aa",
+      "title": "Abrams Blocks",
+      "desc": "Full-featured Tetris — SRS rotation with wall kicks, 7-bag randomizer, ghost piece, hold, next-queue, level speed-up, line-clear scoring. Arrows move, ↑/Z rotate, Space hard-drop, C hold. Touch: swipe to move, tap to rotate, fling down to drop. Pure Canvas 2D, procedural Web Audio, zero dependencies.",
+      "path": "games/tetris/",
+      "added": "2026-07-24"
+    },
     {
       "id": "flappy-flight",
       "file": "FlappyFlight.aa",
diff --git a/games/tetris/index.html b/games/tetris/index.html
new file mode 100644
index 0000000..fef354e
--- /dev/null
+++ b/games/tetris/index.html
@@ -0,0 +1,511 @@
+<!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>

← 3e43c0f auto-save: 2026-07-24T07:56:29 (1 files) — index.html  ·  back to Games Agentabrams  ·  Add 3 new-genre arcade games: Abrams Snake, Abrams Break, Ab c24ee2b →