← 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
M README.mdM games.jsonA games/tetris/index.html
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 →