← back to Games Agentabrams
Add 8 new arcade games: Asteroids, Minesweeper, Simon, Connect 4, Stack, Maze, Lights Out, Whack (16→24)
94a2079ea0f0caf510acf979329a8b82729c6386 · 2026-07-24 19:23:47 -0700 · Steve Abrams
Files touched
M games.jsonA games/asteroids/index.htmlA games/connect-four/index.htmlA games/lights-out/index.htmlA games/maze/index.htmlA games/minesweeper/index.htmlA games/simon/index.htmlA games/tower-stack/index.htmlA games/whack-a-mole/index.html
Diff
commit 94a2079ea0f0caf510acf979329a8b82729c6386
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Fri Jul 24 19:23:47 2026 -0700
Add 8 new arcade games: Asteroids, Minesweeper, Simon, Connect 4, Stack, Maze, Lights Out, Whack (16→24)
---
games.json | 64 ++++
games/asteroids/index.html | 828 ++++++++++++++++++++++++++++++++++++++++++
games/connect-four/index.html | 720 ++++++++++++++++++++++++++++++++++++
games/lights-out/index.html | 471 ++++++++++++++++++++++++
games/maze/index.html | 692 +++++++++++++++++++++++++++++++++++
games/minesweeper/index.html | 820 +++++++++++++++++++++++++++++++++++++++++
games/simon/index.html | 632 ++++++++++++++++++++++++++++++++
games/tower-stack/index.html | 513 ++++++++++++++++++++++++++
games/whack-a-mole/index.html | 728 +++++++++++++++++++++++++++++++++++++
9 files changed, 5468 insertions(+)
diff --git a/games.json b/games.json
index ed96445..6ac0e7b 100644
--- a/games.json
+++ b/games.json
@@ -1,5 +1,69 @@
{
"games": [
+ {
+ "id": "asteroids",
+ "file": "AbramsAsteroids.aa",
+ "title": "Abrams Asteroids",
+ "desc": "A neon vector shooter: thrust, spin, and blast splitting asteroids across wrapping space through escalating waves, with hyperspace and sound. Pure Canvas 2D, zero dependencies.",
+ "path": "games/asteroids/",
+ "added": "2026-07-24"
+ },
+ {
+ "id": "minesweeper",
+ "file": "AbramsMines.aa",
+ "title": "Abrams Minesweeper",
+ "desc": "Classic Minesweeper reborn in neon-on-dark — flood-fill sweeps, tap-to-flag, first-click-safe, best-time chasing across three difficulties. Pure vanilla JS, zero dependencies.",
+ "path": "games/minesweeper/",
+ "added": "2026-07-24"
+ },
+ {
+ "id": "simon",
+ "file": "AbramsSimon.aa",
+ "title": "Abrams Simon",
+ "desc": "Neon four-pad Simon memory game with Web Audio tones, speeding rounds, strict mode, and a saved best score. Pure vanilla JS, zero dependencies.",
+ "path": "games/simon/",
+ "added": "2026-07-24"
+ },
+ {
+ "id": "connect-four",
+ "file": "AbramsConnect4.aa",
+ "title": "Abrams Connect 4",
+ "desc": "Drop glowing neon discs to line up four in a row and outsmart an alpha-beta minimax AI, with silky physics drops and Web Audio. Pure vanilla JS, zero dependencies.",
+ "path": "games/connect-four/",
+ "added": "2026-07-24"
+ },
+ {
+ "id": "tower-stack",
+ "file": "AbramsStack.aa",
+ "title": "Abrams Stack",
+ "desc": "Time your taps to stack neon blocks into a sky-high tower — nail a perfect drop for combo bonuses before the overhang slices you down. Pure Canvas 2D, zero dependencies.",
+ "path": "games/tower-stack/",
+ "added": "2026-07-24"
+ },
+ {
+ "id": "maze",
+ "file": "AbramsMaze.aa",
+ "title": "Abrams Maze",
+ "desc": "A neon procedurally-generated maze runner: race the timer through ever-larger perfect mazes, drop breadcrumbs, and flash the solution path. Pure Canvas 2D, zero dependencies.",
+ "path": "games/maze/",
+ "added": "2026-07-24"
+ },
+ {
+ "id": "lights-out",
+ "file": "AbramsLightsOut.aa",
+ "title": "Abrams Lights Out",
+ "desc": "Toggle glowing neon cells and their neighbors to switch every light off in this slick, solvable-only Lights Out puzzle. Pure vanilla JS, zero dependencies.",
+ "path": "games/lights-out/",
+ "added": "2026-07-24"
+ },
+ {
+ "id": "whack-a-mole",
+ "file": "AbramsWhack.aa",
+ "title": "Abrams Whack",
+ "desc": "A neon whack-a-mole arcade sprint — smash glowing moles for combo multipliers, snag gold jackpots, dodge bombs that cost lives. Pure vanilla JS, zero dependencies.",
+ "path": "games/whack-a-mole/",
+ "added": "2026-07-24"
+ },
{
"id": "dvd-bounce",
"file": "DVDBounce.aa",
diff --git a/games/asteroids/index.html b/games/asteroids/index.html
new file mode 100644
index 0000000..b0f04be
--- /dev/null
+++ b/games/asteroids/index.html
@@ -0,0 +1,828 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
+<title>Abrams Asteroids</title>
+<style>
+ :root {
+ --neon: #00f0ff;
+ --neon2: #ff2bd6;
+ --accent: #b6ff3a;
+ --bg0: #04060f;
+ --bg1: #0a0f24;
+ }
+ * { box-sizing: border-box; margin: 0; padding: 0; }
+ html, body {
+ width: 100%; height: 100%;
+ overflow: hidden;
+ background: var(--bg0);
+ font-family: "Segoe UI", "Helvetica Neue", Arial, sans-serif;
+ -webkit-user-select: none; user-select: none;
+ -webkit-tap-highlight-color: transparent;
+ touch-action: none;
+ }
+ #wrap {
+ position: fixed; inset: 0;
+ background:
+ radial-gradient(ellipse at 50% 30%, #0d1636 0%, var(--bg0) 70%),
+ var(--bg0);
+ }
+ canvas { position: absolute; inset: 0; display: block; }
+
+ /* HUD */
+ #hud {
+ position: absolute; top: 0; left: 0; right: 0;
+ display: flex; justify-content: space-between; align-items: flex-start;
+ padding: 14px 18px;
+ pointer-events: none;
+ font-variant-numeric: tabular-nums;
+ text-shadow: 0 0 8px currentColor;
+ z-index: 5;
+ }
+ #hud .col { display: flex; flex-direction: column; gap: 4px; }
+ #hud .col.right { align-items: flex-end; text-align: right; }
+ .label { font-size: 11px; letter-spacing: 3px; color: #6f7bb5; text-shadow: none; }
+ .val { font-size: 22px; font-weight: 700; letter-spacing: 1px; color: var(--neon); }
+ .val.best { color: var(--accent); font-size: 16px; }
+ #lives { display: flex; gap: 6px; margin-top: 2px; }
+ #lives svg { filter: drop-shadow(0 0 4px var(--neon)); }
+ #wave { color: var(--neon2); font-size: 16px; }
+
+ /* Overlay */
+ #overlay {
+ position: absolute; inset: 0;
+ display: flex; flex-direction: column;
+ align-items: center; justify-content: center;
+ text-align: center;
+ background: radial-gradient(ellipse at 50% 45%, rgba(6,10,28,.72) 0%, rgba(2,4,12,.94) 75%);
+ z-index: 10;
+ padding: 24px;
+ backdrop-filter: blur(2px);
+ }
+ #overlay.hidden { display: none; }
+ #title {
+ font-size: clamp(34px, 9vw, 84px);
+ font-weight: 800;
+ letter-spacing: clamp(2px, 1.5vw, 12px);
+ color: #eafcff;
+ text-shadow: 0 0 18px var(--neon), 0 0 40px var(--neon), 0 0 4px #fff;
+ line-height: 1;
+ }
+ #title .sub { display:block; font-size: clamp(13px,3.5vw,22px); letter-spacing: clamp(3px,2vw,16px); color: var(--neon2); text-shadow: 0 0 12px var(--neon2); margin-top: 10px; }
+ #ovMsg { margin-top: 18px; font-size: clamp(14px,4vw,20px); color: #b9c6ff; min-height: 1.2em; text-shadow: 0 0 8px rgba(120,150,255,.4);}
+ #ovScore { margin-top: 6px; font-size: clamp(13px,3.6vw,18px); color: var(--accent); }
+ #playBtn {
+ margin-top: 26px;
+ pointer-events: auto; cursor: pointer;
+ background: linear-gradient(180deg, rgba(0,240,255,.16), rgba(0,240,255,.04));
+ border: 2px solid var(--neon);
+ color: #eafcff;
+ font-size: clamp(16px,4.5vw,22px); font-weight: 700; letter-spacing: 4px;
+ padding: 14px 40px; border-radius: 40px;
+ text-shadow: 0 0 10px var(--neon);
+ box-shadow: 0 0 22px rgba(0,240,255,.35), inset 0 0 18px rgba(0,240,255,.18);
+ transition: transform .08s ease, box-shadow .2s ease;
+ }
+ #playBtn:hover { box-shadow: 0 0 34px rgba(0,240,255,.6), inset 0 0 24px rgba(0,240,255,.28); }
+ #playBtn:active { transform: scale(.96); }
+ #hint {
+ margin-top: 26px; font-size: clamp(11px,3vw,14px); color: #6f7bb5;
+ letter-spacing: 1px; line-height: 1.9; max-width: 560px;
+ }
+ #hint b { color: var(--neon); font-weight: 700; }
+
+ /* Toast / status pills */
+ #statusPills {
+ position: absolute; top: 56px; left: 50%; transform: translateX(-50%);
+ display: flex; gap: 10px; z-index: 6; pointer-events: none;
+ }
+ .pill {
+ font-size: 11px; letter-spacing: 2px; padding: 4px 10px; border-radius: 20px;
+ border: 1px solid rgba(120,150,255,.4); color: #9fb0f0; background: rgba(10,16,40,.6);
+ opacity: 0; transition: opacity .3s;
+ }
+ .pill.on { opacity: 1; }
+ .pill.paused { color: var(--accent); border-color: var(--accent); }
+ .pill.muted { color: var(--neon2); border-color: var(--neon2); }
+
+ #centerToast {
+ position: absolute; top: 42%; left: 50%; transform: translate(-50%,-50%);
+ font-size: clamp(20px,7vw,48px); font-weight: 800; letter-spacing: 6px;
+ color: #eafcff; text-shadow: 0 0 20px var(--neon2), 0 0 40px var(--neon2);
+ z-index: 7; pointer-events: none; opacity: 0; transition: opacity .4s;
+ }
+ #centerToast.on { opacity: 1; }
+
+ /* Touch controls */
+ #touch { position: absolute; inset: 0; z-index: 8; display: none; pointer-events: none; }
+ #touch.show { display: block; }
+ .tbtn {
+ position: absolute; pointer-events: auto;
+ display: flex; align-items: center; justify-content: center;
+ border-radius: 50%;
+ border: 2px solid rgba(0,240,255,.55);
+ background: rgba(8,14,36,.42);
+ color: var(--neon); font-weight: 700; font-size: 13px; letter-spacing: 1px;
+ box-shadow: 0 0 14px rgba(0,240,255,.22), inset 0 0 12px rgba(0,240,255,.12);
+ backdrop-filter: blur(2px);
+ -webkit-user-select: none; user-select: none;
+ transition: background .08s, transform .08s;
+ }
+ .tbtn:active, .tbtn.active { background: rgba(0,240,255,.28); transform: scale(.94); }
+ .tbtn svg { width: 44%; height: 44%; fill: var(--neon); filter: drop-shadow(0 0 4px var(--neon)); }
+ #tLeft { left: 20px; bottom: 96px; width: 74px; height: 74px; }
+ #tRight { left: 110px; bottom: 30px; width: 74px; height: 74px; }
+ #tThrust{ right: 110px; bottom: 96px; width: 82px; height: 82px; }
+ #tFire { right: 20px; bottom: 30px; width: 82px; height: 82px; border-color: var(--neon2); color: var(--neon2); box-shadow: 0 0 14px rgba(255,43,214,.3), inset 0 0 12px rgba(255,43,214,.14); }
+ #tFire svg { fill: var(--neon2); filter: drop-shadow(0 0 4px var(--neon2)); }
+ #tHyper { right: 24px; top: 84px; width: 56px; height: 56px; font-size: 11px; border-color: var(--accent); color: var(--accent); }
+</style>
+</head>
+<body>
+<div id="wrap">
+ <canvas id="game"></canvas>
+
+ <div id="hud">
+ <div class="col">
+ <span class="label">SCORE</span>
+ <span class="val" id="score">0</span>
+ <span class="val best" id="best">BEST 0</span>
+ </div>
+ <div class="col right">
+ <span class="label">WAVE</span>
+ <span class="val" id="wave">1</span>
+ <span class="label" style="margin-top:6px;">SHIPS</span>
+ <div id="lives"></div>
+ </div>
+ </div>
+
+ <div id="statusPills">
+ <span class="pill paused" id="pillPaused">PAUSED</span>
+ <span class="pill muted" id="pillMuted">MUTED</span>
+ </div>
+
+ <div id="centerToast"></div>
+
+ <!-- Touch controls -->
+ <div id="touch">
+ <div class="tbtn" id="tLeft"><svg viewBox="0 0 100 100"><path d="M65 15 L30 50 L65 85 Z"/></svg></div>
+ <div class="tbtn" id="tRight"><svg viewBox="0 0 100 100"><path d="M35 15 L70 50 L35 85 Z"/></svg></div>
+ <div class="tbtn" id="tThrust"><svg viewBox="0 0 100 100"><path d="M50 12 L80 78 L50 62 L20 78 Z"/></svg></div>
+ <div class="tbtn" id="tFire"><svg viewBox="0 0 100 100"><circle cx="50" cy="50" r="26"/></svg></div>
+ <div class="tbtn" id="tHyper">HYP</div>
+ </div>
+
+ <div id="overlay">
+ <div id="title">ABRAMS<span class="sub">ASTEROIDS</span></div>
+ <div id="ovMsg">Clear the field. Survive the swarm.</div>
+ <div id="ovScore"></div>
+ <button id="playBtn">LAUNCH</button>
+ <div id="hint">
+ <b>▲ / W</b> Thrust <b>◀ ▶ / A D</b> Rotate <b>SPACE</b> Fire<br>
+ <b>SHIFT</b> Hyperspace <b>P</b> Pause <b>M</b> Mute <b>R</b> Restart<br>
+ <span style="opacity:.75">Touch devices: use the on-screen pads.</span>
+ </div>
+ </div>
+</div>
+
+<script>
+(function () {
+ "use strict";
+
+ // ---------- Setup ----------
+ var canvas = document.getElementById("game");
+ var ctx = canvas.getContext("2d");
+ var wrap = document.getElementById("wrap");
+
+ var W = 0, H = 0, DPR = 1;
+
+ function resize() {
+ DPR = Math.min(window.devicePixelRatio || 1, 2);
+ var r = wrap.getBoundingClientRect();
+ W = Math.max(320, r.width);
+ H = Math.max(240, r.height);
+ canvas.width = Math.floor(W * DPR);
+ canvas.height = Math.floor(H * DPR);
+ canvas.style.width = W + "px";
+ canvas.style.height = H + "px";
+ ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
+ }
+ window.addEventListener("resize", resize);
+ resize();
+
+ // ---------- Audio (Web Audio, procedural) ----------
+ var Audio = (function () {
+ var actx = null, master = null;
+ var muted = false;
+ function ensure() {
+ if (actx) return;
+ try {
+ var AC = window.AudioContext || window.webkitAudioContext;
+ actx = new AC();
+ master = actx.createGain();
+ master.gain.value = 0.35;
+ master.connect(actx.destination);
+ } catch (e) { actx = null; }
+ }
+ function resume() { if (actx && actx.state === "suspended") actx.resume(); }
+ function tone(freq, dur, type, vol, slideTo) {
+ if (muted || !actx) return;
+ var t = actx.currentTime;
+ var o = actx.createOscillator();
+ var g = actx.createGain();
+ o.type = type || "square";
+ o.frequency.setValueAtTime(freq, t);
+ if (slideTo) o.frequency.exponentialRampToValueAtTime(Math.max(20, slideTo), t + dur);
+ g.gain.setValueAtTime(0.0001, t);
+ g.gain.exponentialRampToValueAtTime(vol || 0.3, t + 0.008);
+ g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
+ o.connect(g); g.connect(master);
+ o.start(t); o.stop(t + dur + 0.02);
+ }
+ function noise(dur, vol, freq) {
+ if (muted || !actx) return;
+ var t = actx.currentTime;
+ var n = Math.floor(actx.sampleRate * dur);
+ var buf = actx.createBuffer(1, n, actx.sampleRate);
+ var d = buf.getChannelData(0);
+ for (var i = 0; i < n; i++) d[i] = (Math.random() * 2 - 1) * (1 - i / n);
+ var src = actx.createBufferSource(); src.buffer = buf;
+ var flt = actx.createBiquadFilter();
+ flt.type = "lowpass"; flt.frequency.value = freq || 1200;
+ var g = actx.createGain();
+ g.gain.setValueAtTime(vol || 0.4, t);
+ g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
+ src.connect(flt); flt.connect(g); g.connect(master);
+ src.start(t);
+ }
+ return {
+ init: function () { ensure(); resume(); },
+ resume: resume,
+ isMuted: function () { return muted; },
+ toggleMute: function () { muted = !muted; return muted; },
+ shoot: function () { tone(880, 0.09, "square", 0.14, 300); },
+ thrust: function () { noise(0.05, 0.06, 500); },
+ bangBig: function () { noise(0.32, 0.5, 700); tone(70, 0.3, "sawtooth", 0.2, 40); },
+ bangMed: function () { noise(0.22, 0.4, 1000); tone(120, 0.2, "sawtooth", 0.15, 60); },
+ bangSmall: function () { noise(0.14, 0.3, 1600); tone(200, 0.12, "square", 0.12, 90); },
+ hyper: function () { tone(200, 0.35, "sine", 0.25, 1400); },
+ death: function () { noise(0.6, 0.55, 500); tone(160, 0.6, "sawtooth", 0.3, 30); },
+ wave: function () { tone(523, 0.12, "triangle", 0.2); setTimeout(function(){tone(784,0.16,"triangle",0.2);}, 120); },
+ extraLife: function () { tone(660, 0.1, "triangle", 0.2); setTimeout(function(){tone(990,0.14,"triangle",0.22);}, 100); }
+ };
+ })();
+
+ // ---------- Input ----------
+ var keys = {};
+ var touch = { left: false, right: false, thrust: false, fire: false };
+
+ function down(code) {
+ if (code === "KeyM") { toggleMute(); return; }
+ if (code === "KeyP") { togglePause(); return; }
+ if (code === "KeyR") { if (state === "play" || state === "over") restart(); return; }
+ keys[code] = true;
+ }
+ function up(code) { keys[code] = false; }
+
+ window.addEventListener("keydown", function (e) {
+ var c = e.code;
+ if (["ArrowUp","ArrowDown","ArrowLeft","ArrowRight","Space"].indexOf(c) >= 0) e.preventDefault();
+ if (state !== "play") {
+ if (c === "Space" || c === "Enter") {
+ if (state === "start" || state === "over") { startGame(); return; }
+ }
+ }
+ Audio.init();
+ down(c);
+ });
+ window.addEventListener("keyup", function (e) { up(e.code); });
+
+ // Touch buttons
+ function bindTouch(id, prop) {
+ var el = document.getElementById(id);
+ function on(e) { e.preventDefault(); Audio.init(); touch[prop] = true; el.classList.add("active"); }
+ function off(e) { e.preventDefault(); touch[prop] = false; el.classList.remove("active"); }
+ el.addEventListener("touchstart", on, { passive: false });
+ el.addEventListener("touchend", off, { passive: false });
+ el.addEventListener("touchcancel", off, { passive: false });
+ el.addEventListener("mousedown", on);
+ el.addEventListener("mouseup", off);
+ el.addEventListener("mouseleave", off);
+ }
+ bindTouch("tLeft", "left");
+ bindTouch("tRight", "right");
+ bindTouch("tThrust", "thrust");
+ bindTouch("tFire", "fire");
+ (function () {
+ var el = document.getElementById("tHyper");
+ function on(e) { e.preventDefault(); Audio.init(); doHyperspace(); el.classList.add("active"); }
+ function off(e) { e.preventDefault(); el.classList.remove("active"); }
+ el.addEventListener("touchstart", on, { passive: false });
+ el.addEventListener("touchend", off, { passive: false });
+ el.addEventListener("mousedown", on);
+ el.addEventListener("mouseup", off);
+ })();
+
+ var isTouch = ("ontouchstart" in window) || navigator.maxTouchPoints > 0;
+ if (isTouch) document.getElementById("touch").classList.add("show");
+
+ // ---------- Game constants ----------
+ var SHIP_R = 14;
+ var TURN = 4.2; // rad/sec
+ var THRUST = 340; // px/sec^2
+ var FRICTION = 0.72; // per sec (velocity retained ~)
+ var MAX_SPEED = 460;
+ var BULLET_SPEED = 560;
+ var BULLET_LIFE = 0.85;
+ var FIRE_COOLDOWN = 0.16;
+ var INVULN_TIME = 2.4;
+ var START_LIVES = 3;
+ var EXTRA_LIFE_EVERY = 10000;
+
+ var AST_SIZES = { big: 46, med: 26, small: 15 };
+ var AST_SCORE = { big: 20, med: 50, small: 100 };
+ var AST_SPEED = { big: 42, med: 66, small: 92 };
+
+ // ---------- State ----------
+ var state = "start"; // start | play | paused | over
+ var ship, bullets, asteroids, particles;
+ var score, best, lives, wave, fireTimer, hyperCooldown, nextExtra;
+ var thrustSoundTimer = 0;
+
+ best = parseInt(localStorage.getItem("abrams-asteroids-best") || "0", 10) || 0;
+
+ function rand(a, b) { return a + Math.random() * (b - a); }
+ function wrapPos(o) {
+ if (o.x < 0) o.x += W; else if (o.x >= W) o.x -= W;
+ if (o.y < 0) o.y += H; else if (o.y >= H) o.y -= H;
+ }
+
+ function makeShip() {
+ return { x: W / 2, y: H / 2, a: -Math.PI / 2, vx: 0, vy: 0, r: SHIP_R, invuln: INVULN_TIME, thrusting: false, alive: true };
+ }
+
+ function makeAsteroid(x, y, size) {
+ var speed = AST_SPEED[size] * rand(0.7, 1.35);
+ var ang = rand(0, Math.PI * 2);
+ var r = AST_SIZES[size];
+ var verts = [], n = Math.floor(rand(9, 14));
+ for (var i = 0; i < n; i++) {
+ verts.push(rand(0.72, 1.15));
+ }
+ return {
+ x: x, y: y, size: size, r: r,
+ vx: Math.cos(ang) * speed, vy: Math.sin(ang) * speed,
+ rot: rand(-1, 1), spin: rand(-0.9, 0.9),
+ verts: verts, hue: rand(180, 300)
+ };
+ }
+
+ function spawnWave(n) {
+ asteroids = [];
+ for (var i = 0; i < n; i++) {
+ // spawn away from ship center
+ var x, y, tries = 0;
+ do {
+ x = rand(0, W); y = rand(0, H);
+ tries++;
+ } while (ship && Math.hypot(x - ship.x, y - ship.y) < 180 && tries < 30);
+ asteroids.push(makeAsteroid(x, y, "big"));
+ }
+ }
+
+ function resetRound() {
+ ship = makeShip();
+ bullets = [];
+ particles = [];
+ fireTimer = 0;
+ hyperCooldown = 0;
+ }
+
+ function startGame() {
+ Audio.init();
+ score = 0; lives = START_LIVES; wave = 1; nextExtra = EXTRA_LIFE_EVERY;
+ resetRound();
+ spawnWave(4);
+ state = "play";
+ hideOverlay();
+ Audio.wave();
+ lastT = performance.now();
+ showToast("WAVE 1", 900);
+ }
+
+ function restart() {
+ startGame();
+ }
+
+ function nextWave() {
+ wave++;
+ spawnWave(Math.min(4 + wave, 12));
+ ship.invuln = Math.max(ship.invuln, 1.2);
+ Audio.wave();
+ showToast("WAVE " + wave, 900);
+ }
+
+ function gameOver() {
+ state = "over";
+ if (score > best) {
+ best = score;
+ localStorage.setItem("abrams-asteroids-best", String(best));
+ }
+ updateHud();
+ showOverlay(
+ "GAME OVER",
+ "The field claimed your fleet.",
+ "SCORE " + score + " • BEST " + best,
+ "PLAY AGAIN"
+ );
+ }
+
+ // ---------- Actions ----------
+ function fire() {
+ if (fireTimer > 0 || !ship || !ship.alive) return;
+ if (bullets.length > 8) return;
+ var nx = Math.cos(ship.a), ny = Math.sin(ship.a);
+ bullets.push({
+ x: ship.x + nx * ship.r, y: ship.y + ny * ship.r,
+ vx: ship.vx + nx * BULLET_SPEED, vy: ship.vy + ny * BULLET_SPEED,
+ life: BULLET_LIFE
+ });
+ fireTimer = FIRE_COOLDOWN;
+ Audio.shoot();
+ }
+
+ function doHyperspace() {
+ if (state !== "play" || !ship || !ship.alive || hyperCooldown > 0) return;
+ hyperCooldown = 1.2;
+ // small risk: 12% chance of misjump explosion
+ ship.x = rand(40, W - 40);
+ ship.y = rand(40, H - 40);
+ ship.vx = 0; ship.vy = 0;
+ ship.invuln = Math.max(ship.invuln, 0.8);
+ Audio.hyper();
+ for (var i = 0; i < 14; i++) spawnParticle(ship.x, ship.y, 200, "#00f0ff");
+ if (Math.random() < 0.12) {
+ killShip();
+ }
+ }
+
+ function killShip() {
+ if (!ship.alive) return;
+ ship.alive = false;
+ Audio.death();
+ for (var i = 0; i < 26; i++) spawnParticle(ship.x, ship.y, 240, "#ff2bd6");
+ lives--;
+ updateHud();
+ if (lives <= 0) {
+ setTimeout(gameOver, 900);
+ } else {
+ setTimeout(function () {
+ if (state !== "play") return;
+ ship = makeShip();
+ }, 900);
+ }
+ }
+
+ function splitAsteroid(a, idx) {
+ // score
+ addScore(AST_SCORE[a.size]);
+ // particles
+ var col = "hsl(" + a.hue + ",90%,65%)";
+ var pc = a.size === "big" ? 16 : a.size === "med" ? 11 : 7;
+ for (var i = 0; i < pc; i++) spawnParticle(a.x, a.y, a.size === "big" ? 220 : 160, col);
+ if (a.size === "big") Audio.bangBig();
+ else if (a.size === "med") Audio.bangMed();
+ else Audio.bangSmall();
+
+ asteroids.splice(idx, 1);
+ if (a.size === "big") {
+ asteroids.push(makeAsteroid(a.x, a.y, "med"));
+ asteroids.push(makeAsteroid(a.x, a.y, "med"));
+ } else if (a.size === "med") {
+ asteroids.push(makeAsteroid(a.x, a.y, "small"));
+ asteroids.push(makeAsteroid(a.x, a.y, "small"));
+ }
+ }
+
+ function addScore(n) {
+ score += n;
+ if (score >= nextExtra) {
+ lives++;
+ nextExtra += EXTRA_LIFE_EVERY;
+ Audio.extraLife();
+ showToast("EXTRA SHIP", 800);
+ }
+ updateHud();
+ }
+
+ function spawnParticle(x, y, spd, color) {
+ var a = rand(0, Math.PI * 2);
+ var s = rand(spd * 0.25, spd);
+ particles.push({
+ x: x, y: y, vx: Math.cos(a) * s, vy: Math.sin(a) * s,
+ life: rand(0.4, 0.9), max: 0.9, color: color
+ });
+ }
+
+ // ---------- Update ----------
+ function update(dt) {
+ if (state !== "play") return;
+
+ // ship controls
+ if (ship && ship.alive) {
+ var left = keys["ArrowLeft"] || keys["KeyA"] || touch.left;
+ var right = keys["ArrowRight"] || keys["KeyD"] || touch.right;
+ var thr = keys["ArrowUp"] || keys["KeyW"] || touch.thrust;
+ var shift = keys["ShiftLeft"] || keys["ShiftRight"];
+
+ if (left) ship.a -= TURN * dt;
+ if (right) ship.a += TURN * dt;
+
+ ship.thrusting = !!thr;
+ if (thr) {
+ ship.vx += Math.cos(ship.a) * THRUST * dt;
+ ship.vy += Math.sin(ship.a) * THRUST * dt;
+ thrustSoundTimer -= dt;
+ if (thrustSoundTimer <= 0) { Audio.thrust(); thrustSoundTimer = 0.06; }
+ }
+ // clamp speed
+ var sp = Math.hypot(ship.vx, ship.vy);
+ if (sp > MAX_SPEED) { ship.vx *= MAX_SPEED / sp; ship.vy *= MAX_SPEED / sp; }
+ // friction
+ var f = Math.pow(FRICTION, dt);
+ ship.vx *= f; ship.vy *= f;
+
+ ship.x += ship.vx * dt; ship.y += ship.vy * dt;
+ wrapPos(ship);
+
+ if (shift && hyperCooldown <= 0) doHyperspace();
+
+ if (keys["Space"] || touch.fire) fire();
+
+ if (ship.invuln > 0) ship.invuln -= dt;
+ }
+
+ if (fireTimer > 0) fireTimer -= dt;
+ if (hyperCooldown > 0) hyperCooldown -= dt;
+
+ // bullets
+ for (var i = bullets.length - 1; i >= 0; i--) {
+ var b = bullets[i];
+ b.x += b.vx * dt; b.y += b.vy * dt;
+ wrapPos(b);
+ b.life -= dt;
+ if (b.life <= 0) { bullets.splice(i, 1); continue; }
+ }
+
+ // asteroids
+ for (var j = 0; j < asteroids.length; j++) {
+ var a = asteroids[j];
+ a.x += a.vx * dt; a.y += a.vy * dt;
+ a.rot += a.spin * dt;
+ wrapPos(a);
+ }
+
+ // bullet vs asteroid
+ for (var bi = bullets.length - 1; bi >= 0; bi--) {
+ var bu = bullets[bi];
+ for (var ai = asteroids.length - 1; ai >= 0; ai--) {
+ var as = asteroids[ai];
+ if ((bu.x - as.x) * (bu.x - as.x) + (bu.y - as.y) * (bu.y - as.y) < as.r * as.r) {
+ bullets.splice(bi, 1);
+ splitAsteroid(as, ai);
+ break; // this bullet is gone; move to next bullet
+ }
+ }
+ }
+
+ // ship vs asteroid
+ if (ship && ship.alive && ship.invuln <= 0) {
+ for (var k = 0; k < asteroids.length; k++) {
+ var ak = asteroids[k];
+ var rr = ak.r + ship.r * 0.7;
+ if ((ship.x - ak.x) * (ship.x - ak.x) + (ship.y - ak.y) * (ship.y - ak.y) < rr * rr) {
+ killShip();
+ break;
+ }
+ }
+ }
+
+ // particles
+ for (var p = particles.length - 1; p >= 0; p--) {
+ var pt = particles[p];
+ pt.x += pt.vx * dt; pt.y += pt.vy * dt;
+ pt.vx *= Math.pow(0.4, dt); pt.vy *= Math.pow(0.4, dt);
+ pt.life -= dt;
+ if (pt.life <= 0) particles.splice(p, 1);
+ }
+
+ // wave clear
+ if (asteroids.length === 0) nextWave();
+ }
+
+ // ---------- Render ----------
+ var starField = null;
+ function buildStars() {
+ starField = [];
+ var n = Math.floor((W * H) / 6500);
+ for (var i = 0; i < n; i++) {
+ starField.push({ x: Math.random() * W, y: Math.random() * H, r: Math.random() * 1.4 + 0.2, a: Math.random() * 0.6 + 0.2 });
+ }
+ }
+ buildStars();
+ var lastStarKey = W + "x" + H;
+
+ function drawStars() {
+ var key = W + "x" + H;
+ if (key !== lastStarKey || !starField) { buildStars(); lastStarKey = key; }
+ ctx.save();
+ for (var i = 0; i < starField.length; i++) {
+ var s = starField[i];
+ ctx.globalAlpha = s.a;
+ ctx.fillStyle = "#9fc7ff";
+ ctx.beginPath();
+ ctx.arc(s.x, s.y, s.r, 0, Math.PI * 2);
+ ctx.fill();
+ }
+ ctx.restore();
+ }
+
+ function drawShip() {
+ if (!ship || !ship.alive) return;
+ if (ship.invuln > 0 && Math.floor(ship.invuln * 12) % 2 === 0) return; // blink
+ ctx.save();
+ ctx.translate(ship.x, ship.y);
+ ctx.rotate(ship.a);
+ // thrust flame
+ if (ship.thrusting && Math.random() > 0.35) {
+ ctx.beginPath();
+ ctx.moveTo(-ship.r * 0.6, -5);
+ ctx.lineTo(-ship.r * 1.5 - Math.random() * 8, 0);
+ ctx.lineTo(-ship.r * 0.6, 5);
+ ctx.strokeStyle = "#ff9b2b";
+ ctx.lineWidth = 2;
+ ctx.shadowBlur = 14; ctx.shadowColor = "#ff6a00";
+ ctx.stroke();
+ }
+ // hull
+ ctx.beginPath();
+ ctx.moveTo(ship.r, 0);
+ ctx.lineTo(-ship.r * 0.7, -ship.r * 0.75);
+ ctx.lineTo(-ship.r * 0.4, 0);
+ ctx.lineTo(-ship.r * 0.7, ship.r * 0.75);
+ ctx.closePath();
+ ctx.strokeStyle = "#eafcff";
+ ctx.lineWidth = 2;
+ ctx.shadowBlur = 12; ctx.shadowColor = "#00f0ff";
+ ctx.stroke();
+ ctx.restore();
+ }
+
+ function drawAsteroid(a) {
+ ctx.save();
+ ctx.translate(a.x, a.y);
+ ctx.rotate(a.rot);
+ ctx.beginPath();
+ var n = a.verts.length;
+ for (var i = 0; i < n; i++) {
+ var ang = (i / n) * Math.PI * 2;
+ var rr = a.r * a.verts[i];
+ var px = Math.cos(ang) * rr, py = Math.sin(ang) * rr;
+ if (i === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py);
+ }
+ ctx.closePath();
+ ctx.strokeStyle = "hsl(" + a.hue + ",85%,68%)";
+ ctx.lineWidth = 2;
+ ctx.shadowBlur = 12; ctx.shadowColor = "hsl(" + a.hue + ",90%,55%)";
+ ctx.stroke();
+ ctx.restore();
+ }
+
+ function drawBullets() {
+ ctx.save();
+ ctx.fillStyle = "#eafcff";
+ ctx.shadowBlur = 10; ctx.shadowColor = "#00f0ff";
+ for (var i = 0; i < bullets.length; i++) {
+ var b = bullets[i];
+ ctx.beginPath();
+ ctx.arc(b.x, b.y, 2.4, 0, Math.PI * 2);
+ ctx.fill();
+ }
+ ctx.restore();
+ }
+
+ function drawParticles() {
+ ctx.save();
+ for (var i = 0; i < particles.length; i++) {
+ var p = particles[i];
+ ctx.globalAlpha = Math.max(0, p.life / p.max);
+ ctx.fillStyle = p.color;
+ ctx.beginPath();
+ ctx.arc(p.x, p.y, 2, 0, Math.PI * 2);
+ ctx.fill();
+ }
+ ctx.restore();
+ }
+
+ function render() {
+ ctx.clearRect(0, 0, W, H);
+ drawStars();
+ if (state === "play" || state === "paused" || state === "over") {
+ drawParticles();
+ for (var i = 0; i < (asteroids ? asteroids.length : 0); i++) drawAsteroid(asteroids[i]);
+ drawBullets();
+ drawShip();
+ }
+ }
+
+ // ---------- HUD / overlay ----------
+ var elScore = document.getElementById("score");
+ var elBest = document.getElementById("best");
+ var elWave = document.getElementById("wave");
+ var elLives = document.getElementById("lives");
+ var overlay = document.getElementById("overlay");
+ var ovTitle = document.getElementById("title");
+ var ovMsg = document.getElementById("ovMsg");
+ var ovScore = document.getElementById("ovScore");
+ var playBtn = document.getElementById("playBtn");
+ var pillPaused = document.getElementById("pillPaused");
+ var pillMuted = document.getElementById("pillMuted");
+ var centerToast = document.getElementById("centerToast");
+
+ function shipIcon() {
+ return '<svg width="16" height="16" viewBox="0 0 100 100"><path d="M85 50 L20 20 L38 50 L20 80 Z" fill="none" stroke="#00f0ff" stroke-width="8"/></svg>';
+ }
+
+ function updateHud() {
+ elScore.textContent = score != null ? score : 0;
+ elBest.textContent = "BEST " + best;
+ elWave.textContent = wave != null ? wave : 1;
+ var html = "";
+ var n = Math.max(0, lives != null ? lives : 0);
+ for (var i = 0; i < Math.min(n, 6); i++) html += shipIcon();
+ if (n > 6) html += '<span style="color:#00f0ff;font-size:14px;align-self:center;">x' + n + '</span>';
+ elLives.innerHTML = html;
+ }
+
+ function showOverlay(title, msg, sc, btn) {
+ ovTitle.innerHTML = title === "ABRAMS" ? 'ABRAMS<span class="sub">ASTEROIDS</span>' :
+ (title + '<span class="sub" style="visibility:hidden">.</span>');
+ if (title === "GAME OVER") {
+ ovTitle.innerHTML = 'GAME<span class="sub">OVER</span>';
+ }
+ ovMsg.textContent = msg || "";
+ ovScore.textContent = sc || "";
+ playBtn.textContent = btn || "LAUNCH";
+ overlay.classList.remove("hidden");
+ }
+ function hideOverlay() { overlay.classList.add("hidden"); }
+
+ playBtn.addEventListener("click", function () { Audio.init(); startGame(); });
+
+ var toastTimer = null;
+ function showToast(text, ms) {
+ centerToast.textContent = text;
+ centerToast.classList.add("on");
+ if (toastTimer) clearTimeout(toastTimer);
+ toastTimer = setTimeout(function () { centerToast.classList.remove("on"); }, ms || 800);
+ }
+
+ function togglePause() {
+ if (state === "play") {
+ state = "paused";
+ pillPaused.classList.add("on");
+ } else if (state === "paused") {
+ state = "play";
+ pillPaused.classList.remove("on");
+ lastT = performance.now();
+ }
+ }
+ function toggleMute() {
+ var m = Audio.toggleMute();
+ if (m) pillMuted.classList.add("on"); else pillMuted.classList.remove("on");
+ }
+
+ // ---------- Main loop ----------
+ var lastT = performance.now();
+ function loop(now) {
+ var dt = (now - lastT) / 1000;
+ lastT = now;
+ if (dt > 0.05) dt = 0.05; // clamp to avoid huge steps
+ if (dt < 0) dt = 0;
+
+ update(dt);
+ render();
+ requestAnimationFrame(loop);
+ }
+
+ // init HUD + overlay
+ score = 0; lives = START_LIVES; wave = 1;
+ updateHud();
+ showOverlay("ABRAMS", "Clear the field. Survive the swarm.", "", "LAUNCH");
+
+ requestAnimationFrame(loop);
+})();
+</script>
+</body>
+</html>
diff --git a/games/connect-four/index.html b/games/connect-four/index.html
new file mode 100644
index 0000000..091370a
--- /dev/null
+++ b/games/connect-four/index.html
@@ -0,0 +1,720 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
+<title>Abrams Connect 4</title>
+<style>
+ :root{
+ --bg0:#070912;
+ --bg1:#0d1226;
+ --board:#0b1030;
+ --board-edge:#1b2a6b;
+ --cell:#050814;
+ --p1:#ff2d78; /* neon pink */
+ --p1b:#ff8fc0;
+ --p2:#22e0ff; /* neon cyan */
+ --p2b:#a9f4ff;
+ --ink:#eaf0ff;
+ --muted:#8b96c4;
+ --line:#2a3670;
+ --win:#ffe14d;
+ --accent:#8a6bff;
+ }
+ *{box-sizing:border-box;-webkit-tap-highlight-color:transparent;}
+ html,body{margin:0;height:100%;}
+ body{
+ background:
+ radial-gradient(1200px 700px at 15% -10%, #16205a55, transparent 60%),
+ radial-gradient(1000px 700px at 110% 120%, #4a1e6b55, transparent 55%),
+ linear-gradient(160deg,var(--bg1),var(--bg0));
+ color:var(--ink);
+ font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
+ display:flex;flex-direction:column;align-items:center;
+ min-height:100%;overflow:hidden;
+ user-select:none;
+ }
+ .wrap{
+ width:100%;max-width:640px;height:100dvh;
+ display:flex;flex-direction:column;
+ padding:10px clamp(8px,3vw,18px);
+ gap:10px;
+ }
+ header{display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap;}
+ .title{
+ font-weight:800;letter-spacing:.5px;font-size:clamp(18px,5vw,26px);
+ background:linear-gradient(90deg,var(--p1),var(--accent),var(--p2));
+ -webkit-background-clip:text;background-clip:text;color:transparent;
+ text-shadow:0 0 24px #8a6bff33;
+ display:flex;align-items:center;gap:8px;
+ }
+ .title .dot{width:12px;height:12px;border-radius:50%;background:var(--p1);box-shadow:0 0 12px var(--p1);}
+ .controls{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
+ select,button{
+ font:inherit;color:var(--ink);
+ background:linear-gradient(180deg,#141c40,#0c1330);
+ border:1px solid var(--line);border-radius:10px;
+ padding:8px 10px;cursor:pointer;transition:.15s;
+ font-size:13px;
+ }
+ button:hover,select:hover{border-color:var(--accent);box-shadow:0 0 0 1px #8a6bff44;}
+ button:active{transform:translateY(1px);}
+ .btn-primary{background:linear-gradient(180deg,#6d54ff,#4b32d6);border-color:#7a63ff;font-weight:700;}
+ .iconbtn{padding:8px 11px;}
+
+ .scorebar{
+ display:flex;gap:8px;align-items:stretch;justify-content:center;
+ }
+ .score{
+ flex:1;max-width:190px;
+ display:flex;align-items:center;gap:10px;
+ background:linear-gradient(180deg,#0f1638,#0a1029);
+ border:1px solid var(--line);border-radius:12px;
+ padding:8px 12px;position:relative;overflow:hidden;
+ }
+ .score.active{border-color:currentColor;box-shadow:0 0 18px -4px currentColor, inset 0 0 22px -12px currentColor;}
+ .score.p1{color:var(--p1);}
+ .score.p2{color:var(--p2);}
+ .score .chip{
+ width:26px;height:26px;border-radius:50%;flex:0 0 auto;
+ background:radial-gradient(circle at 35% 30%,#fff8,currentColor 60%);
+ box-shadow:0 0 12px currentColor;
+ }
+ .score .meta{display:flex;flex-direction:column;line-height:1.15;}
+ .score .name{font-size:12px;color:var(--muted);}
+ .score .val{font-size:20px;font-weight:800;color:var(--ink);}
+
+ .status{
+ text-align:center;font-size:14px;min-height:20px;color:var(--muted);
+ font-weight:600;letter-spacing:.3px;
+ }
+ .status b.p1{color:var(--p1);} .status b.p2{color:var(--p2);}
+
+ .boardwrap{flex:1;display:flex;align-items:center;justify-content:center;min-height:0;}
+ canvas{
+ display:block;width:100%;height:100%;
+ touch-action:none;border-radius:16px;
+ cursor:pointer;
+ }
+ footer{
+ text-align:center;font-size:11px;color:#5b6699;padding-bottom:4px;
+ }
+ .thinking{color:var(--accent);}
+ @media (max-width:420px){
+ .score .val{font-size:18px;}
+ }
+</style>
+</head>
+<body>
+<div class="wrap">
+ <header>
+ <div class="title"><span class="dot"></span>Abrams Connect 4</div>
+ <div class="controls">
+ <select id="mode" title="Game mode">
+ <option value="ai">vs Computer</option>
+ <option value="2p">2 Players</option>
+ </select>
+ <select id="diff" title="Difficulty">
+ <option value="1">Easy</option>
+ <option value="3">Medium</option>
+ <option value="5" selected>Hard</option>
+ <option value="7">Expert</option>
+ </select>
+ <button id="mute" class="iconbtn" title="Toggle sound">🔊</button>
+ <button id="newgame" class="btn-primary">New Game</button>
+ </div>
+ </header>
+
+ <div class="scorebar">
+ <div class="score p1 active" id="score1">
+ <span class="chip"></span>
+ <span class="meta"><span class="name" id="name1">You</span><span class="val" id="val1">0</span></span>
+ </div>
+ <div class="score p2" id="score2">
+ <span class="chip"></span>
+ <span class="meta"><span class="name" id="name2">Computer</span><span class="val" id="val2">0</span></span>
+ </div>
+ </div>
+
+ <div class="status" id="status">Your turn — drop a disc</div>
+
+ <div class="boardwrap">
+ <canvas id="board"></canvas>
+ </div>
+
+ <footer>Connect four in a row — horizontal, vertical, or diagonal · Pure vanilla JS, zero dependencies.</footer>
+</div>
+
+<script>
+(() => {
+ "use strict";
+
+ // ---------- Constants ----------
+ const COLS = 7, ROWS = 6, NEED = 4;
+ const EMPTY = 0, P1 = 1, P2 = 2;
+
+ // ---------- DOM ----------
+ const canvas = document.getElementById('board');
+ const ctx = canvas.getContext('2d');
+ const $ = id => document.getElementById(id);
+ const modeSel = $('mode'), diffSel = $('diff'), muteBtn = $('mute');
+ const statusEl = $('status');
+ const val1 = $('val1'), val2 = $('val2'), name1 = $('name1'), name2 = $('name2');
+ const score1El = $('score1'), score2El = $('score2');
+
+ // ---------- State ----------
+ let board = []; // board[r][c], r=0 is TOP
+ let current = P1;
+ let scores = { 1:0, 2:0 };
+ let gameOver = false;
+ let winLine = null; // array of [r,c]
+ let winPlayer = 0;
+ let aiThinking = false;
+ let hoverCol = -1;
+ let muted = false;
+ let animating = false;
+
+ // Animated / falling discs
+ const discs = []; // {r,c,player,y (px), targetY, vy, settled}
+ let dropAnim = null;
+
+ // Layout (recomputed on resize)
+ let L = { size: 0, pad: 0, cell: 0, radius: 0, boardX: 0, boardY: 0, boardW: 0, boardH: 0, top: 0 };
+
+ function newBoard() {
+ board = Array.from({length: ROWS}, () => new Array(COLS).fill(EMPTY));
+ }
+
+ // ---------- Audio (procedural, optional) ----------
+ let audioCtx = null;
+ function ac() {
+ if (muted) return null;
+ if (!audioCtx) {
+ try { audioCtx = new (window.AudioContext || window.webkitAudioContext)(); }
+ catch (e) { return null; }
+ }
+ if (audioCtx.state === 'suspended') audioCtx.resume();
+ return audioCtx;
+ }
+ function tone(freq, dur, type = 'sine', gain = 0.12, slideTo = null) {
+ const a = ac(); if (!a) return;
+ const o = a.createOscillator(), g = a.createGain();
+ o.type = type; o.frequency.value = freq;
+ if (slideTo) o.frequency.exponentialRampToValueAtTime(slideTo, a.currentTime + dur);
+ g.gain.setValueAtTime(0, a.currentTime);
+ g.gain.linearRampToValueAtTime(gain, a.currentTime + 0.008);
+ g.gain.exponentialRampToValueAtTime(0.0001, a.currentTime + dur);
+ o.connect(g); g.connect(a.destination);
+ o.start(); o.stop(a.currentTime + dur + 0.02);
+ }
+ const sfx = {
+ drop(){ tone(220, 0.14, 'triangle', 0.13, 140); },
+ land(){ tone(120, 0.10, 'sine', 0.16); tone(180, 0.06, 'square', 0.05); },
+ hover(){ tone(660, 0.03, 'sine', 0.04); },
+ win(){ [523,659,784,1046].forEach((f,i)=>setTimeout(()=>tone(f,0.22,'triangle',0.14),i*110)); },
+ draw(){ tone(300,0.2,'sine',0.1,180); },
+ click(){ tone(440,0.04,'square',0.05); }
+ };
+
+ // ---------- Layout ----------
+ function resize() {
+ const wrap = canvas.parentElement;
+ const availW = wrap.clientWidth;
+ const availH = wrap.clientHeight;
+ // board aspect: COLS wide, ROWS+~1 (drop lane) tall
+ const laneRows = ROWS + 1;
+ const targetRatio = COLS / laneRows;
+ let w = availW, h = w / targetRatio;
+ if (h > availH) { h = availH; w = h * targetRatio; }
+ const dpr = Math.min(window.devicePixelRatio || 1, 2.5);
+ canvas.style.width = w + 'px';
+ canvas.style.height = h + 'px';
+ canvas.width = Math.round(w * dpr);
+ canvas.height = Math.round(h * dpr);
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
+
+ L.cell = w / COLS;
+ L.top = L.cell; // drop lane height (one cell)
+ L.boardX = 0;
+ L.boardY = L.top;
+ L.boardW = w;
+ L.boardH = L.cell * ROWS;
+ L.radius = L.cell * 0.40;
+ L.w = w; L.h = h;
+ // sync any settled disc pixel positions
+ for (const d of discs) if (d.settled) d.y = cellCenterY(d.r);
+ draw();
+ }
+
+ function cellCenterX(c){ return L.cell * c + L.cell/2; }
+ function cellCenterY(r){ return L.top + L.cell * r + L.cell/2; }
+
+ // ---------- Drawing ----------
+ function roundRect(x,y,w,h,r){
+ ctx.beginPath();
+ ctx.moveTo(x+r,y);
+ ctx.arcTo(x+w,y,x+w,y+h,r);
+ ctx.arcTo(x+w,y+h,x,y+h,r);
+ ctx.arcTo(x,y+h,x,y,r);
+ ctx.arcTo(x,y,x+w,y,r);
+ ctx.closePath();
+ }
+
+ function discColors(p){
+ return p === P1
+ ? { a:'#ff2d78', b:'#ff8fc0', glow:'#ff2d78' }
+ : { a:'#22e0ff', b:'#a9f4ff', glow:'#22e0ff' };
+ }
+
+ function drawDisc(cx, cy, p, r, highlight){
+ const col = discColors(p);
+ ctx.save();
+ ctx.shadowColor = col.glow;
+ ctx.shadowBlur = highlight ? r*1.1 : r*0.55;
+ const g = ctx.createRadialGradient(cx - r*0.35, cy - r*0.4, r*0.1, cx, cy, r);
+ g.addColorStop(0, col.b);
+ g.addColorStop(0.55, col.a);
+ g.addColorStop(1, p===P1 ? '#a3134b' : '#0f7f96');
+ ctx.fillStyle = g;
+ ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI*2); ctx.fill();
+ ctx.shadowBlur = 0;
+ // rim
+ ctx.lineWidth = Math.max(1, r*0.06);
+ ctx.strokeStyle = 'rgba(255,255,255,.22)';
+ ctx.beginPath(); ctx.arc(cx, cy, r*0.96, 0, Math.PI*2); ctx.stroke();
+ // gloss
+ ctx.fillStyle = 'rgba(255,255,255,.28)';
+ ctx.beginPath(); ctx.ellipse(cx - r*0.3, cy - r*0.35, r*0.34, r*0.2, -0.5, 0, Math.PI*2); ctx.fill();
+ ctx.restore();
+ }
+
+ function draw(){
+ if (!L.w) return;
+ ctx.clearRect(0,0,L.w,L.h);
+
+ // hover preview in the lane
+ if (!gameOver && !aiThinking && hoverCol >= 0 && dropRow(hoverCol) >= 0) {
+ const cx = cellCenterX(hoverCol);
+ ctx.save();
+ ctx.globalAlpha = 0.5;
+ drawDisc(cx, L.top/2, current, L.radius, false);
+ ctx.restore();
+ // column guide
+ ctx.save();
+ ctx.globalAlpha = 0.10;
+ ctx.fillStyle = discColors(current).a;
+ ctx.fillRect(L.cell*hoverCol, L.boardY, L.cell, L.boardH);
+ ctx.restore();
+ }
+
+ // board panel
+ ctx.save();
+ ctx.shadowColor = '#1b2a6b';
+ ctx.shadowBlur = 30;
+ const bg = ctx.createLinearGradient(0, L.boardY, 0, L.boardY+L.boardH);
+ bg.addColorStop(0, '#131a44');
+ bg.addColorStop(1, '#0a1030');
+ ctx.fillStyle = bg;
+ roundRect(L.boardX+2, L.boardY, L.boardW-4, L.boardH, L.cell*0.28);
+ ctx.fill();
+ ctx.restore();
+ // board edge stroke
+ ctx.lineWidth = 2;
+ ctx.strokeStyle = 'rgba(120,150,255,.25)';
+ roundRect(L.boardX+2, L.boardY, L.boardW-4, L.boardH, L.cell*0.28);
+ ctx.stroke();
+
+ // holes + settled discs
+ for (let r=0;r<ROWS;r++){
+ for (let c=0;c<COLS;c++){
+ const cx = cellCenterX(c), cy = cellCenterY(r);
+ // hole
+ ctx.save();
+ ctx.fillStyle = '#050814';
+ ctx.beginPath(); ctx.arc(cx, cy, L.radius, 0, Math.PI*2); ctx.fill();
+ ctx.lineWidth = Math.max(1, L.radius*0.05);
+ ctx.strokeStyle = 'rgba(0,0,0,.6)';
+ ctx.beginPath(); ctx.arc(cx, cy, L.radius, 0, Math.PI*2); ctx.stroke();
+ ctx.restore();
+ }
+ }
+
+ // discs (settled + falling)
+ for (const d of discs) {
+ const cx = cellCenterX(d.c);
+ const hi = winLine && winLine.some(p => p[0]===d.r && p[1]===d.c);
+ drawDisc(cx, d.y, d.player, L.radius, hi);
+ }
+
+ // win line
+ if (winLine && winLine.length) {
+ const a = winLine[0], b = winLine[winLine.length-1];
+ ctx.save();
+ ctx.strokeStyle = 'rgba(255,225,77,.9)';
+ ctx.lineWidth = L.radius*0.22;
+ ctx.lineCap = 'round';
+ ctx.shadowColor = '#ffe14d';
+ ctx.shadowBlur = 24;
+ ctx.beginPath();
+ ctx.moveTo(cellCenterX(a[1]), cellCenterY(a[0]));
+ ctx.lineTo(cellCenterX(b[1]), cellCenterY(b[0]));
+ ctx.stroke();
+ ctx.restore();
+ }
+ }
+
+ // ---------- Game logic ----------
+ function dropRow(c){
+ for (let r=ROWS-1;r>=0;r--) if (board[r][c]===EMPTY) return r;
+ return -1;
+ }
+
+ function validCols(bd){
+ const out=[];
+ for (let c=0;c<COLS;c++) if (bd[0][c]===EMPTY) out.push(c);
+ return out;
+ }
+
+ function boardFull(bd){
+ for (let c=0;c<COLS;c++) if (bd[0][c]===EMPTY) return false;
+ return true;
+ }
+
+ // returns winning line (array of [r,c]) for player p, or null
+ function findWin(bd, p){
+ const dirs = [[0,1],[1,0],[1,1],[1,-1]];
+ for (let r=0;r<ROWS;r++){
+ for (let c=0;c<COLS;c++){
+ if (bd[r][c]!==p) continue;
+ for (const [dr,dc] of dirs){
+ const line=[[r,c]];
+ let rr=r+dr, cc=c+dc;
+ while (rr>=0 && rr<ROWS && cc>=0 && cc<COLS && bd[rr][cc]===p){
+ line.push([rr,cc]);
+ if (line.length===NEED) return line;
+ rr+=dr; cc+=dc;
+ }
+ }
+ }
+ }
+ return null;
+ }
+
+ // ---------- Human move ----------
+ function attemptDrop(c){
+ if (gameOver || animating || aiThinking) return;
+ if (modeSel.value==='ai' && current===P2) return; // not human's turn
+ const r = dropRow(c);
+ if (r<0) return;
+ doDrop(c, current);
+ }
+
+ function doDrop(c, player){
+ const r = dropRow(c);
+ if (r<0) return;
+ board[r][c] = player;
+ animating = true;
+ sfx.drop();
+ const targetY = cellCenterY(r);
+ const disc = { r, c, player, y: L.top/2, vy: 0, settled:false };
+ discs.push(disc);
+ startFall(disc, targetY);
+ }
+
+ function startFall(disc, targetY){
+ const grav = L.cell * 0.06; // px per frame^2 (scaled by cell)
+ let last = performance.now();
+ let bounced = false;
+ function step(now){
+ const dt = Math.min(2, (now - last)/16.6667); last = now;
+ disc.vy += grav * dt;
+ disc.y += disc.vy * dt;
+ if (disc.y >= targetY){
+ disc.y = targetY;
+ if (!bounced && disc.vy > L.cell*0.25){
+ disc.vy = -disc.vy * 0.28; // small bounce
+ bounced = true;
+ sfx.land();
+ draw();
+ requestAnimationFrame(step);
+ return;
+ }
+ disc.settled = true;
+ animating = false;
+ if (!bounced) sfx.land();
+ draw();
+ afterMove(disc.r, disc.c, disc.player);
+ return;
+ }
+ draw();
+ requestAnimationFrame(step);
+ }
+ requestAnimationFrame(step);
+ }
+
+ function afterMove(r, c, player){
+ const win = findWin(board, player);
+ if (win){
+ winLine = win; winPlayer = player; gameOver = true;
+ scores[player]++;
+ updateScores();
+ sfx.win();
+ setStatus(playerName(player, true) + ' wins the round! 🎉');
+ flashWin();
+ draw();
+ return;
+ }
+ if (boardFull(board)){
+ gameOver = true;
+ sfx.draw();
+ setStatus("It's a draw — board full.");
+ draw();
+ return;
+ }
+ current = player===P1 ? P2 : P1;
+ updateTurnUI();
+ if (modeSel.value==='ai' && current===P2 && !gameOver){
+ queueAI();
+ }
+ }
+
+ let winFlashTimer = null;
+ function flashWin(){
+ let n=0;
+ clearInterval(winFlashTimer);
+ winFlashTimer = setInterval(()=>{
+ n++;
+ // toggle handled implicitly by redraw jitter of glow via time — simple pulse:
+ draw();
+ if (n>8) clearInterval(winFlashTimer);
+ }, 140);
+ }
+
+ // ---------- AI: minimax + alpha-beta ----------
+ function queueAI(){
+ aiThinking = true;
+ setStatus('<span class="thinking">Computer is thinking…</span>');
+ draw();
+ // yield to let UI paint, keep responsive
+ setTimeout(runAI, 60);
+ }
+
+ function runAI(){
+ const depth = parseInt(diffSel.value, 10) || 5;
+ const move = bestMove(board, depth);
+ aiThinking = false;
+ if (move == null){ // no move (shouldn't happen)
+ updateTurnUI();
+ return;
+ }
+ doDrop(move, P2);
+ }
+
+ // Order columns center-first for better pruning
+ const COL_ORDER = (() => {
+ const mid=(COLS-1)/2;
+ return [...Array(COLS).keys()].sort((a,b)=>Math.abs(a-mid)-Math.abs(b-mid));
+ })();
+
+ function bestMove(bd, depth){
+ // immediate win / block shortcuts for snappiness at low depth
+ const valid = validColsOrdered(bd);
+ if (!valid.length) return null;
+ // win now?
+ for (const c of valid){ const b2=cloneDrop(bd,c,P2); if (findWin(b2,P2)) return c; }
+ // block opponent win?
+ for (const c of valid){ const b2=cloneDrop(bd,c,P1); if (findWin(b2,P1)) return c; }
+
+ let bestScore = -Infinity, bestCols = [valid[0]];
+ for (const c of valid){
+ const b2 = cloneDrop(bd, c, P2);
+ const s = minimax(b2, depth-1, -Infinity, Infinity, false);
+ if (s > bestScore){ bestScore = s; bestCols = [c]; }
+ else if (s === bestScore){ bestCols.push(c); }
+ }
+ // prefer central among ties
+ bestCols.sort((a,b)=>Math.abs(a-(COLS-1)/2)-Math.abs(b-(COLS-1)/2));
+ return bestCols[0];
+ }
+
+ function validColsOrdered(bd){
+ return COL_ORDER.filter(c => bd[0][c]===EMPTY);
+ }
+
+ function cloneDrop(bd, c, p){
+ const b2 = bd.map(row => row.slice());
+ for (let r=ROWS-1;r>=0;r--){ if (b2[r][c]===EMPTY){ b2[r][c]=p; break; } }
+ return b2;
+ }
+
+ function minimax(bd, depth, alpha, beta, maximizing){
+ if (findWin(bd, P2)) return 100000 + depth; // AI win, sooner better
+ if (findWin(bd, P1)) return -100000 - depth; // human win
+ if (depth===0 || boardFull(bd)) return evaluate(bd);
+
+ const cols = validColsOrdered(bd);
+ if (maximizing){
+ let value = -Infinity;
+ for (const c of cols){
+ const b2 = cloneDrop(bd, c, P2);
+ value = Math.max(value, minimax(b2, depth-1, alpha, beta, false));
+ alpha = Math.max(alpha, value);
+ if (alpha >= beta) break;
+ }
+ return value;
+ } else {
+ let value = Infinity;
+ for (const c of cols){
+ const b2 = cloneDrop(bd, c, P1);
+ value = Math.min(value, minimax(b2, depth-1, alpha, beta, true));
+ beta = Math.min(beta, value);
+ if (beta <= alpha) break;
+ }
+ return value;
+ }
+ }
+
+ // Heuristic evaluation from AI (P2) perspective
+ function evaluate(bd){
+ let score = 0;
+ // center column preference
+ const centerC = Math.floor(COLS/2);
+ let centerCount = 0;
+ for (let r=0;r<ROWS;r++) if (bd[r][centerC]===P2) centerCount++;
+ score += centerCount * 6;
+
+ const windows = [];
+ // horizontal
+ for (let r=0;r<ROWS;r++)
+ for (let c=0;c<=COLS-4;c++)
+ windows.push([bd[r][c],bd[r][c+1],bd[r][c+2],bd[r][c+3]]);
+ // vertical
+ for (let c=0;c<COLS;c++)
+ for (let r=0;r<=ROWS-4;r++)
+ windows.push([bd[r][c],bd[r+1][c],bd[r+2][c],bd[r+3][c]]);
+ // diag down-right
+ for (let r=0;r<=ROWS-4;r++)
+ for (let c=0;c<=COLS-4;c++)
+ windows.push([bd[r][c],bd[r+1][c+1],bd[r+2][c+2],bd[r+3][c+3]]);
+ // diag up-right
+ for (let r=3;r<ROWS;r++)
+ for (let c=0;c<=COLS-4;c++)
+ windows.push([bd[r][c],bd[r-1][c+1],bd[r-2][c+2],bd[r-3][c+3]]);
+
+ for (const w of windows) score += scoreWindow(w);
+ return score;
+ }
+
+ function scoreWindow(w){
+ let ai=0, hu=0, e=0;
+ for (const v of w){ if (v===P2) ai++; else if (v===P1) hu++; else e++; }
+ if (ai>0 && hu>0) return 0; // mixed, dead
+ let s=0;
+ if (ai===4) s+=1000;
+ else if (ai===3 && e===1) s+=12;
+ else if (ai===2 && e===2) s+=4;
+ if (hu===4) s-=1000;
+ else if (hu===3 && e===1) s-=16; // block threats a bit harder
+ else if (hu===2 && e===2) s-=4;
+ return s;
+ }
+
+ // ---------- UI helpers ----------
+ function playerName(p, cap){
+ if (modeSel.value==='ai') return p===P1 ? 'You' : 'Computer';
+ return p===P1 ? 'Player 1' : 'Player 2';
+ }
+ function setStatus(html){ statusEl.innerHTML = html; }
+ function updateScores(){ val1.textContent = scores[P1]; val2.textContent = scores[P2]; }
+ function updateNames(){
+ if (modeSel.value==='ai'){ name1.textContent='You'; name2.textContent='Computer'; }
+ else { name1.textContent='Player 1'; name2.textContent='Player 2'; }
+ }
+ function updateTurnUI(){
+ score1El.classList.toggle('active', current===P1);
+ score2El.classList.toggle('active', current===P2);
+ if (gameOver) return;
+ const cls = current===P1 ? 'p1' : 'p2';
+ const who = playerName(current);
+ if (modeSel.value==='ai' && current===P2){
+ setStatus('<span class="thinking">Computer is thinking…</span>');
+ } else {
+ setStatus(`<b class="${cls}">${who}</b> — drop a disc`);
+ }
+ }
+
+ // ---------- New game ----------
+ function newGame(){
+ clearInterval(winFlashTimer);
+ newBoard();
+ discs.length = 0;
+ current = P1;
+ gameOver = false; winLine = null; winPlayer = 0; aiThinking = false; animating = false;
+ hoverCol = -1;
+ updateNames();
+ updateScores();
+ updateTurnUI();
+ resize();
+ // if AI is P1? No — human/P1 always starts here.
+ }
+
+ // ---------- Input ----------
+ function colFromEvent(e){
+ const rect = canvas.getBoundingClientRect();
+ const x = (e.clientX ?? (e.touches && e.touches[0].clientX)) - rect.left;
+ const c = Math.floor(x / (rect.width / COLS));
+ return Math.max(0, Math.min(COLS-1, c));
+ }
+
+ canvas.addEventListener('mousemove', e => {
+ if (gameOver || aiThinking) { if (hoverCol!==-1){hoverCol=-1;draw();} return; }
+ const c = colFromEvent(e);
+ if (c !== hoverCol){ hoverCol = c; if(!muted && dropRow(c)>=0) sfx.hover(); draw(); }
+ });
+ canvas.addEventListener('mouseleave', () => { if (hoverCol!==-1){ hoverCol=-1; draw(); } });
+ canvas.addEventListener('click', e => { ac(); attemptDrop(colFromEvent(e)); });
+
+ // Touch
+ let touchCol = -1;
+ canvas.addEventListener('touchstart', e => {
+ ac();
+ e.preventDefault();
+ touchCol = colFromEvent(e);
+ hoverCol = touchCol;
+ draw();
+ }, {passive:false});
+ canvas.addEventListener('touchmove', e => {
+ e.preventDefault();
+ const c = colFromEvent(e);
+ if (c!==hoverCol){ hoverCol=c; draw(); }
+ }, {passive:false});
+ canvas.addEventListener('touchend', e => {
+ e.preventDefault();
+ if (hoverCol>=0){ const c=hoverCol; hoverCol=-1; attemptDrop(c); draw(); }
+ }, {passive:false});
+
+ // Keyboard: 1-7 to drop, N new game
+ window.addEventListener('keydown', e => {
+ if (e.key>='1' && e.key<='7'){ ac(); attemptDrop(parseInt(e.key,10)-1); }
+ else if (e.key==='n' || e.key==='N'){ newGame(); }
+ });
+
+ // Controls
+ $('newgame').addEventListener('click', () => { ac(); sfx.click(); newGame(); });
+ modeSel.addEventListener('change', () => { sfx.click(); scores={1:0,2:0}; newGame(); });
+ diffSel.addEventListener('change', () => { sfx.click(); });
+ muteBtn.addEventListener('click', () => {
+ muted = !muted;
+ muteBtn.textContent = muted ? '🔇' : '🔊';
+ if (!muted) { ac(); sfx.click(); }
+ });
+
+ window.addEventListener('resize', resize);
+ window.addEventListener('orientationchange', () => setTimeout(resize, 120));
+
+ // ---------- Boot ----------
+ newGame();
+})();
+</script>
+</body>
+</html>
diff --git a/games/lights-out/index.html b/games/lights-out/index.html
new file mode 100644
index 0000000..badcefd
--- /dev/null
+++ b/games/lights-out/index.html
@@ -0,0 +1,471 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
+<title>Abrams Lights Out</title>
+<style>
+ :root{
+ --bg:#070a12;
+ --bg2:#0d1220;
+ --panel:rgba(255,255,255,.045);
+ --stroke:rgba(140,180,255,.14);
+ --txt:#e8eefc;
+ --dim:#8a96b4;
+ --neon:#38f0d6;
+ --neon2:#12b8d8;
+ --lit-a:#3bffe0;
+ --lit-b:#1aa9ff;
+ --gap:clamp(6px,1.4vmin,14px);
+ }
+ *{box-sizing:border-box;-webkit-tap-highlight-color:transparent}
+ html,body{height:100%;margin:0}
+ body{
+ font-family:'Trebuchet MS',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
+ color:var(--txt);
+ background:
+ radial-gradient(1100px 700px at 15% -10%,rgba(30,200,220,.14),transparent 60%),
+ radial-gradient(900px 600px at 110% 120%,rgba(60,120,255,.13),transparent 55%),
+ linear-gradient(160deg,var(--bg),var(--bg2));
+ min-height:100%;
+ display:flex;
+ align-items:center;
+ justify-content:center;
+ padding:clamp(8px,2vmin,20px);
+ overflow:hidden;
+ user-select:none;
+ }
+ .wrap{
+ width:100%;
+ max-width:min(560px,94vmin);
+ display:flex;
+ flex-direction:column;
+ gap:clamp(10px,2vmin,16px);
+ }
+ header{text-align:center;line-height:1.05}
+ h1{
+ margin:0;
+ font-size:clamp(22px,5.4vmin,40px);
+ font-weight:800;
+ letter-spacing:.5px;
+ background:linear-gradient(90deg,var(--lit-a),var(--lit-b));
+ -webkit-background-clip:text;background-clip:text;
+ -webkit-text-fill-color:transparent;
+ filter:drop-shadow(0 0 14px rgba(56,240,214,.35));
+ }
+ .sub{margin:3px 0 0;color:var(--dim);font-size:clamp(10px,2.4vmin,13px);letter-spacing:2px;text-transform:uppercase}
+
+ .stats{
+ display:flex;justify-content:center;gap:clamp(8px,2vmin,14px);flex-wrap:wrap;
+ }
+ .stat{
+ background:var(--panel);border:1px solid var(--stroke);border-radius:12px;
+ padding:clamp(6px,1.4vmin,10px) clamp(10px,2.4vmin,18px);
+ min-width:74px;text-align:center;backdrop-filter:blur(6px);
+ }
+ .stat .k{font-size:clamp(9px,2vmin,10px);color:var(--dim);letter-spacing:1.5px;text-transform:uppercase}
+ .stat .v{font-size:clamp(16px,4vmin,24px);font-weight:800;font-variant-numeric:tabular-nums;margin-top:2px}
+ .stat.best .v{color:var(--neon)}
+
+ .boardwrap{position:relative;width:100%;aspect-ratio:1/1;margin:0 auto}
+ #board{
+ position:absolute;inset:0;
+ display:grid;gap:var(--gap);
+ padding:var(--gap);
+ background:rgba(4,8,16,.5);
+ border:1px solid var(--stroke);
+ border-radius:clamp(14px,3vmin,22px);
+ box-shadow:inset 0 0 40px rgba(0,0,0,.5), 0 20px 60px rgba(0,0,0,.5);
+ }
+ .cell{
+ border:none;padding:0;margin:0;cursor:pointer;outline:none;
+ border-radius:clamp(8px,2.2vmin,16px);
+ position:relative;
+ background:linear-gradient(160deg,#12182a,#0a0f1c);
+ box-shadow:inset 0 1px 0 rgba(255,255,255,.05), inset 0 0 0 1px rgba(120,150,220,.08),
+ 0 6px 14px rgba(0,0,0,.35);
+ transition:transform .16s cubic-bezier(.3,1.4,.5,1),
+ box-shadow .3s ease, background .3s ease, filter .3s ease;
+ color:transparent;
+ -webkit-appearance:none;appearance:none;
+ }
+ .cell::after{
+ content:"";position:absolute;inset:22%;border-radius:50%;
+ background:radial-gradient(circle at 35% 30%,rgba(255,255,255,.15),transparent 60%);
+ opacity:.25;transition:opacity .3s ease;
+ }
+ .cell.on{
+ background:linear-gradient(160deg,var(--lit-a),var(--lit-b));
+ box-shadow:inset 0 0 18px rgba(255,255,255,.5),
+ 0 0 22px rgba(56,240,214,.65),
+ 0 0 46px rgba(26,169,255,.45),
+ 0 6px 16px rgba(0,0,0,.3);
+ filter:saturate(1.15);
+ }
+ .cell.on::after{opacity:.85}
+ .cell:active{transform:scale(.9)}
+ .cell.pulse{animation:pulse .4s ease}
+ @keyframes pulse{0%{transform:scale(1)}45%{transform:scale(1.12)}100%{transform:scale(1)}}
+ @media (hover:hover){
+ .cell:hover{transform:translateY(-2px)}
+ .cell.on:hover{filter:saturate(1.3) brightness(1.06)}
+ }
+
+ .controls{display:flex;flex-wrap:wrap;gap:clamp(6px,1.6vmin,10px);justify-content:center;align-items:center}
+ button.btn,select.btn{
+ font-family:inherit;font-size:clamp(11px,2.6vmin,14px);font-weight:700;
+ color:var(--txt);background:var(--panel);border:1px solid var(--stroke);
+ border-radius:11px;padding:clamp(8px,1.8vmin,11px) clamp(12px,3vmin,18px);
+ cursor:pointer;letter-spacing:.5px;transition:.2s;backdrop-filter:blur(6px);
+ -webkit-appearance:none;appearance:none;
+ }
+ button.btn:hover,select.btn:hover{border-color:var(--neon);color:#fff;box-shadow:0 0 14px rgba(56,240,214,.25)}
+ button.btn:active{transform:translateY(1px)}
+ button.primary{
+ background:linear-gradient(160deg,rgba(56,240,214,.22),rgba(26,169,255,.18));
+ border-color:rgba(56,240,214,.4);color:#eafffb;
+ }
+ select.btn{padding-right:30px;background-image:linear-gradient(45deg,transparent 50%,var(--neon) 50%),linear-gradient(135deg,var(--neon) 50%,transparent 50%);background-position:calc(100% - 15px) center,calc(100% - 10px) center;background-size:5px 5px,5px 5px;background-repeat:no-repeat}
+ select.btn option{background:#0d1220;color:var(--txt)}
+
+ .win-overlay{
+ position:absolute;inset:0;display:flex;align-items:center;justify-content:center;
+ border-radius:clamp(14px,3vmin,22px);
+ background:radial-gradient(circle at 50% 40%,rgba(56,240,214,.14),rgba(4,8,16,.86));
+ backdrop-filter:blur(3px);opacity:0;pointer-events:none;transition:opacity .45s ease;
+ z-index:5;text-align:center;
+ }
+ .win-overlay.show{opacity:1;pointer-events:auto}
+ .win-card h2{
+ margin:0;font-size:clamp(28px,8vmin,54px);font-weight:800;
+ background:linear-gradient(90deg,var(--lit-a),#fff,var(--lit-b));
+ -webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent;
+ filter:drop-shadow(0 0 20px rgba(56,240,214,.6));
+ animation:pop .5s cubic-bezier(.2,1.6,.4,1);
+ }
+ @keyframes pop{0%{transform:scale(.4);opacity:0}100%{transform:scale(1);opacity:1}}
+ .win-card p{margin:10px 0 16px;color:var(--txt);font-size:clamp(13px,3.2vmin,16px)}
+ .win-card .big{color:var(--neon);font-weight:800}
+ canvas.confetti{position:absolute;inset:0;width:100%;height:100%;pointer-events:none;z-index:4}
+ footer{text-align:center;color:var(--dim);font-size:clamp(9px,2.2vmin,11px);letter-spacing:1px}
+</style>
+</head>
+<body>
+<div class="wrap">
+ <header>
+ <h1>ABRAMS LIGHTS OUT</h1>
+ <p class="sub">Turn every light off</p>
+ </header>
+
+ <div class="stats">
+ <div class="stat"><div class="k">Moves</div><div class="v" id="moves">0</div></div>
+ <div class="stat"><div class="k">Time</div><div class="v" id="time">0:00</div></div>
+ <div class="stat best"><div class="k">Best</div><div class="v" id="best">—</div></div>
+ </div>
+
+ <div class="boardwrap">
+ <div id="board"></div>
+ <canvas class="confetti" id="confetti"></canvas>
+ <div class="win-overlay" id="overlay">
+ <div class="win-card">
+ <h2>SOLVED!</h2>
+ <p>Cleared in <span class="big" id="winMoves">0</span> moves · <span class="big" id="winTime">0:00</span></p>
+ <button class="btn primary" id="againBtn">New Puzzle</button>
+ </div>
+ </div>
+ </div>
+
+ <div class="controls">
+ <button class="btn primary" id="newBtn">New Puzzle</button>
+ <select class="btn" id="sizeSel" title="Grid size">
+ <option value="4">4 × 4</option>
+ <option value="5" selected>5 × 5</option>
+ <option value="6">6 × 6</option>
+ </select>
+ <button class="btn" id="muteBtn" title="Sound">🔊 Sound</button>
+ </div>
+
+ <footer>Click a light to toggle it and its neighbors · Pure vanilla JS, zero dependencies</footer>
+</div>
+
+<script>
+(function(){
+ "use strict";
+ var boardEl = document.getElementById('board');
+ var movesEl = document.getElementById('moves');
+ var timeEl = document.getElementById('time');
+ var bestEl = document.getElementById('best');
+ var overlay = document.getElementById('overlay');
+ var winMovesEl = document.getElementById('winMoves');
+ var winTimeEl = document.getElementById('winTime');
+ var sizeSel = document.getElementById('sizeSel');
+ var muteBtn = document.getElementById('muteBtn');
+ var confettiCanvas = document.getElementById('confetti');
+
+ var N = parseInt(localStorage.getItem('lo_size') || '5', 10);
+ if ([4,5,6].indexOf(N) < 0) N = 5;
+ sizeSel.value = String(N);
+
+ var grid = []; // boolean[N*N]
+ var cells = []; // button elements
+ var moves = 0;
+ var solved = false;
+ var startTime = 0;
+ var elapsed = 0;
+ var timerId = null;
+ var muted = localStorage.getItem('lo_muted') === '1';
+
+ updateMuteBtn();
+
+ /* ---------- Audio (procedural, zero assets) ---------- */
+ var actx = null;
+ function audio(){
+ if (muted) return null;
+ if (!actx){
+ try { actx = new (window.AudioContext || window.webkitAudioContext)(); }
+ catch(e){ return null; }
+ }
+ if (actx.state === 'suspended') actx.resume();
+ return actx;
+ }
+ function beep(freq, dur, type, gainVal){
+ var ac = audio(); if (!ac) return;
+ var o = ac.createOscillator();
+ var g = ac.createGain();
+ o.type = type || 'sine';
+ o.frequency.value = freq;
+ g.gain.value = 0;
+ o.connect(g); g.connect(ac.destination);
+ var t = ac.currentTime;
+ var peak = (gainVal == null ? 0.12 : gainVal);
+ g.gain.setValueAtTime(0, t);
+ g.gain.linearRampToValueAtTime(peak, t + 0.008);
+ g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
+ o.start(t);
+ o.stop(t + dur + 0.02);
+ }
+ function toggleSound(litCount){
+ // pitch nudges up as fewer lights remain
+ var base = 300 + (N*N - litCount) * 12;
+ beep(base, 0.12, 'triangle', 0.09);
+ }
+ function winSound(){
+ var notes = [523.25, 659.25, 783.99, 1046.5];
+ for (var i=0;i<notes.length;i++){
+ (function(f,i){ setTimeout(function(){ beep(f, 0.35, 'sine', 0.13); }, i*110); })(notes[i], i);
+ }
+ }
+
+ /* ---------- Board build ---------- */
+ function build(){
+ boardEl.style.gridTemplateColumns = 'repeat(' + N + ',1fr)';
+ boardEl.style.gridTemplateRows = 'repeat(' + N + ',1fr)';
+ boardEl.innerHTML = '';
+ cells = [];
+ for (var i=0;i<N*N;i++){
+ var b = document.createElement('button');
+ b.className = 'cell';
+ b.setAttribute('data-i', i);
+ b.setAttribute('aria-label', 'light ' + (i+1));
+ (function(idx){
+ b.addEventListener('click', function(){ press(idx, true); });
+ })(i);
+ boardEl.appendChild(b);
+ cells.push(b);
+ }
+ }
+
+ function render(){
+ for (var i=0;i<N*N;i++){
+ if (grid[i]) cells[i].classList.add('on');
+ else cells[i].classList.remove('on');
+ }
+ }
+
+ function neighbors(idx){
+ var r = Math.floor(idx / N), c = idx % N;
+ var out = [idx];
+ if (r > 0) out.push(idx - N);
+ if (r < N-1) out.push(idx + N);
+ if (c > 0) out.push(idx - 1);
+ if (c < N-1) out.push(idx + 1);
+ return out;
+ }
+
+ function applyPress(idx){
+ var ns = neighbors(idx);
+ for (var k=0;k<ns.length;k++) grid[ns[k]] = !grid[ns[k]];
+ }
+
+ function litCount(){
+ var n = 0;
+ for (var i=0;i<N*N;i++) if (grid[i]) n++;
+ return n;
+ }
+
+ function press(idx, human){
+ if (solved) return;
+ if (human && moves === 0 && !timerId) startTimer();
+ applyPress(idx);
+ // visual pulse on affected cells
+ var ns = neighbors(idx);
+ for (var k=0;k<ns.length;k++){
+ (function(el){
+ el.classList.remove('pulse');
+ void el.offsetWidth;
+ el.classList.add('pulse');
+ })(cells[ns[k]]);
+ }
+ render();
+ if (human){
+ moves++;
+ movesEl.textContent = moves;
+ var lc = litCount();
+ toggleSound(lc);
+ if (lc === 0) onWin();
+ }
+ }
+
+ /* ---------- Solvable board generation ---------- */
+ function newPuzzle(){
+ solved = false;
+ overlay.classList.remove('show');
+ stopConfetti();
+ moves = 0;
+ movesEl.textContent = '0';
+ resetTimer();
+
+ // start all-off, apply random valid presses -> guaranteed solvable
+ grid = new Array(N*N).fill(false);
+ var total = N*N;
+ var pressCount = Math.max(3, Math.floor(total * (0.35 + Math.random()*0.35)));
+ var used = {};
+ var applied = 0, guard = 0;
+ while (applied < pressCount && guard < 1000){
+ guard++;
+ var idx = Math.floor(Math.random()*total);
+ applyPress(idx);
+ applied++;
+ }
+ // ensure not already solved
+ if (litCount() === 0){ applyPress(Math.floor(Math.random()*total)); }
+ render();
+ loadBest();
+ }
+
+ /* ---------- Win ---------- */
+ function onWin(){
+ solved = true;
+ stopTimer();
+ winSound();
+ winMovesEl.textContent = moves;
+ winTimeEl.textContent = fmt(elapsed);
+ // best
+ var key = bestKey();
+ var prev = parseInt(localStorage.getItem(key) || '0', 10);
+ if (!prev || moves < prev){
+ localStorage.setItem(key, String(moves));
+ }
+ loadBest();
+ setTimeout(function(){ overlay.classList.add('show'); startConfetti(); }, 250);
+ }
+
+ function bestKey(){ return 'lo_best_' + N; }
+ function loadBest(){
+ var v = localStorage.getItem(bestKey());
+ bestEl.textContent = v ? v : '—';
+ }
+
+ /* ---------- Timer ---------- */
+ function fmt(sec){
+ var m = Math.floor(sec/60), s = sec%60;
+ return m + ':' + (s<10?'0':'') + s;
+ }
+ function startTimer(){
+ startTime = Date.now() - elapsed*1000;
+ if (timerId) clearInterval(timerId);
+ timerId = setInterval(function(){
+ elapsed = Math.floor((Date.now()-startTime)/1000);
+ timeEl.textContent = fmt(elapsed);
+ }, 250);
+ }
+ function stopTimer(){ if (timerId){ clearInterval(timerId); timerId = null; } }
+ function resetTimer(){
+ stopTimer(); elapsed = 0; timeEl.textContent = '0:00';
+ }
+
+ /* ---------- Confetti ---------- */
+ var cctx, particles = [], cfRAF = null;
+ function startConfetti(){
+ var wrap = confettiCanvas.parentElement;
+ confettiCanvas.width = wrap.clientWidth;
+ confettiCanvas.height = wrap.clientHeight;
+ cctx = confettiCanvas.getContext('2d');
+ particles = [];
+ var colors = ['#3bffe0','#1aa9ff','#ffffff','#12b8d8','#7cf9ff'];
+ for (var i=0;i<90;i++){
+ particles.push({
+ x: Math.random()*confettiCanvas.width,
+ y: Math.random()*-confettiCanvas.height,
+ r: 3 + Math.random()*5,
+ c: colors[Math.floor(Math.random()*colors.length)],
+ vx: (Math.random()-0.5)*2,
+ vy: 2 + Math.random()*3.5,
+ rot: Math.random()*Math.PI,
+ vr: (Math.random()-0.5)*0.3
+ });
+ }
+ if (cfRAF) cancelAnimationFrame(cfRAF);
+ var frames = 0;
+ (function loop(){
+ frames++;
+ cctx.clearRect(0,0,confettiCanvas.width,confettiCanvas.height);
+ for (var i=0;i<particles.length;i++){
+ var p = particles[i];
+ p.x += p.vx; p.y += p.vy; p.rot += p.vr;
+ cctx.save();
+ cctx.translate(p.x,p.y); cctx.rotate(p.rot);
+ cctx.fillStyle = p.c;
+ cctx.globalAlpha = 0.9;
+ cctx.fillRect(-p.r/2,-p.r/2,p.r,p.r*1.6);
+ cctx.restore();
+ if (p.y > confettiCanvas.height + 20){ p.y = -20; p.x = Math.random()*confettiCanvas.width; }
+ }
+ if (frames < 240 && solved) cfRAF = requestAnimationFrame(loop);
+ else if (solved) { /* fade */ cctx.clearRect(0,0,confettiCanvas.width,confettiCanvas.height); }
+ })();
+ }
+ function stopConfetti(){
+ if (cfRAF) cancelAnimationFrame(cfRAF);
+ if (cctx) cctx.clearRect(0,0,confettiCanvas.width,confettiCanvas.height);
+ }
+
+ /* ---------- Controls ---------- */
+ function updateMuteBtn(){
+ muteBtn.textContent = muted ? '🔇 Muted' : '🔊 Sound';
+ }
+ muteBtn.addEventListener('click', function(){
+ muted = !muted;
+ localStorage.setItem('lo_muted', muted ? '1':'0');
+ updateMuteBtn();
+ if (!muted) beep(500,0.1,'sine',0.08);
+ });
+ document.getElementById('newBtn').addEventListener('click', newPuzzle);
+ document.getElementById('againBtn').addEventListener('click', newPuzzle);
+ sizeSel.addEventListener('change', function(){
+ N = parseInt(sizeSel.value,10);
+ localStorage.setItem('lo_size', String(N));
+ build(); newPuzzle();
+ });
+
+ window.addEventListener('resize', function(){
+ if (solved && overlay.classList.contains('show')) startConfetti();
+ });
+
+ /* ---------- Init ---------- */
+ build();
+ newPuzzle();
+})();
+</script>
+</body>
+</html>
diff --git a/games/maze/index.html b/games/maze/index.html
new file mode 100644
index 0000000..e1009f3
--- /dev/null
+++ b/games/maze/index.html
@@ -0,0 +1,692 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8" />
+<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
+<title>Abrams Maze</title>
+<style>
+ :root {
+ --bg: #05060c;
+ --panel: rgba(10, 14, 28, 0.72);
+ --wall: #1b2350;
+ --wall-glow: #4b63ff;
+ --floor: #090c1a;
+ --player: #38ffd4;
+ --player-glow: #16f0c8;
+ --goal: #ff5cc8;
+ --goal-glow: #ff2fb0;
+ --trail: rgba(56, 255, 212, 0.16);
+ --hint: #ffe45c;
+ --text: #cfe0ff;
+ --muted: #7488c2;
+ --accent: #6c8bff;
+ }
+ * { box-sizing: border-box; margin: 0; padding: 0; -webkit-tap-highlight-color: transparent; }
+ html, body {
+ width: 100%; height: 100%;
+ background: var(--bg);
+ color: var(--text);
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
+ overflow: hidden;
+ overscroll-behavior: none;
+ user-select: none;
+ -webkit-user-select: none;
+ touch-action: none;
+ }
+ #app {
+ position: fixed; inset: 0;
+ display: flex; flex-direction: column;
+ background:
+ radial-gradient(1200px 700px at 50% -10%, rgba(76, 99, 255, 0.16), transparent 60%),
+ radial-gradient(900px 600px at 100% 110%, rgba(255, 92, 200, 0.10), transparent 60%),
+ var(--bg);
+ }
+ header {
+ display: flex; align-items: center; justify-content: space-between;
+ gap: 8px; padding: 10px 14px;
+ flex-wrap: wrap;
+ }
+ .title {
+ font-weight: 800; letter-spacing: 2px; font-size: 17px;
+ text-transform: uppercase;
+ background: linear-gradient(90deg, var(--player), var(--accent), var(--goal));
+ -webkit-background-clip: text; background-clip: text; color: transparent;
+ text-shadow: 0 0 24px rgba(108, 139, 255, 0.35);
+ white-space: nowrap;
+ }
+ .stats { display: flex; gap: 8px; flex-wrap: wrap; }
+ .stat {
+ background: var(--panel);
+ border: 1px solid rgba(108, 139, 255, 0.22);
+ border-radius: 10px;
+ padding: 5px 10px;
+ font-size: 12px;
+ line-height: 1.15;
+ min-width: 62px;
+ text-align: center;
+ backdrop-filter: blur(6px);
+ }
+ .stat b { display: block; font-size: 15px; color: #fff; font-variant-numeric: tabular-nums; margin-top: 1px; }
+ .stat span { color: var(--muted); font-size: 10px; text-transform: uppercase; letter-spacing: 1px; }
+ #stage { flex: 1; position: relative; min-height: 0; }
+ canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
+ .toolbar {
+ display: flex; gap: 8px; justify-content: center; align-items: center;
+ padding: 8px 12px 4px; flex-wrap: wrap;
+ }
+ button.tool {
+ background: var(--panel);
+ border: 1px solid rgba(108, 139, 255, 0.28);
+ color: var(--text);
+ border-radius: 10px;
+ padding: 8px 12px;
+ font-size: 12px; font-weight: 600;
+ letter-spacing: 0.5px;
+ cursor: pointer;
+ transition: transform .08s ease, border-color .15s ease, background .15s ease;
+ }
+ button.tool:hover { border-color: var(--accent); }
+ button.tool:active { transform: scale(0.94); }
+ button.tool.on { border-color: var(--player); color: var(--player); box-shadow: 0 0 14px rgba(56, 255, 212, 0.25); }
+ /* D-pad */
+ #dpad {
+ display: grid;
+ grid-template-columns: repeat(3, 60px);
+ grid-template-rows: repeat(3, 60px);
+ gap: 8px;
+ justify-content: center;
+ padding: 6px 0 16px;
+ touch-action: none;
+ }
+ .dbtn {
+ background: var(--panel);
+ border: 1px solid rgba(108, 139, 255, 0.3);
+ border-radius: 14px;
+ color: var(--accent);
+ font-size: 26px; font-weight: 700;
+ display: flex; align-items: center; justify-content: center;
+ cursor: pointer;
+ transition: transform .06s ease, background .1s ease;
+ user-select: none;
+ }
+ .dbtn:active, .dbtn.press { background: rgba(108, 139, 255, 0.28); transform: scale(0.9); color: #fff; }
+ .dbtn.up { grid-column: 2; grid-row: 1; }
+ .dbtn.left { grid-column: 1; grid-row: 2; }
+ .dbtn.right { grid-column: 3; grid-row: 2; }
+ .dbtn.down { grid-column: 2; grid-row: 3; }
+ .dbtn.spacer { visibility: hidden; }
+ #banner {
+ position: absolute; left: 50%; top: 42%;
+ transform: translate(-50%, -50%) scale(0.8);
+ text-align: center; pointer-events: none;
+ opacity: 0; transition: opacity .35s ease, transform .35s ease;
+ z-index: 5;
+ }
+ #banner.show { opacity: 1; transform: translate(-50%, -50%) scale(1); }
+ #banner .big {
+ font-size: clamp(28px, 8vw, 60px); font-weight: 900; letter-spacing: 2px;
+ background: linear-gradient(90deg, var(--player), var(--goal));
+ -webkit-background-clip: text; background-clip: text; color: transparent;
+ text-shadow: 0 0 40px rgba(255, 92, 200, 0.4);
+ }
+ #banner .sub { color: var(--text); margin-top: 6px; font-size: clamp(13px, 3.5vw, 18px); }
+ .hint-desktop { color: var(--muted); font-size: 11px; text-align: center; padding: 0 12px 4px; }
+ @media (min-width: 760px) and (hover: hover) {
+ #dpad { display: none; }
+ }
+ @media (max-width: 380px) {
+ #dpad { grid-template-columns: repeat(3, 52px); grid-template-rows: repeat(3, 52px); }
+ }
+</style>
+</head>
+<body>
+<div id="app">
+ <header>
+ <div class="title">Abrams Maze</div>
+ <div class="stats">
+ <div class="stat"><span>Level</span><b id="s-level">1</b></div>
+ <div class="stat"><span>Time</span><b id="s-time">0.0</b></div>
+ <div class="stat"><span>Best Time</span><b id="s-best">—</b></div>
+ <div class="stat"><span>Top Level</span><b id="s-top">1</b></div>
+ </div>
+ </header>
+
+ <div id="stage">
+ <canvas id="c"></canvas>
+ <div id="banner"><div class="big" id="banner-big">LEVEL UP</div><div class="sub" id="banner-sub"></div></div>
+ </div>
+
+ <div class="toolbar">
+ <button class="tool" id="btn-hint">💡 Hint Path</button>
+ <button class="tool on" id="btn-trail">🍞 Trail</button>
+ <button class="tool" id="btn-sound">🔇 Sound</button>
+ <button class="tool" id="btn-new">🎲 New Maze</button>
+ </div>
+ <div class="hint-desktop">Arrow keys / WASD to move · hold to glide · reach the pink goal</div>
+
+ <div id="dpad">
+ <div class="dbtn spacer"></div>
+ <div class="dbtn up" data-dir="up">▲</div>
+ <div class="dbtn spacer"></div>
+ <div class="dbtn left" data-dir="left">◀</div>
+ <div class="dbtn spacer"></div>
+ <div class="dbtn right" data-dir="right">▶</div>
+ <div class="dbtn spacer"></div>
+ <div class="dbtn down" data-dir="down">▼</div>
+ <div class="dbtn spacer"></div>
+ </div>
+</div>
+
+<script>
+(function () {
+ "use strict";
+
+ // ---------- Persistent state ----------
+ var LS_KEY = "abrams-maze-v1";
+ var store = { bestTime: null, topLevel: 1, sound: false, trail: true };
+ try {
+ var raw = localStorage.getItem(LS_KEY);
+ if (raw) { var p = JSON.parse(raw); for (var k in p) if (p[k] !== undefined) store[k] = p[k]; }
+ } catch (e) {}
+ function save() { try { localStorage.setItem(LS_KEY, JSON.stringify(store)); } catch (e) {} }
+
+ // ---------- DOM ----------
+ var canvas = document.getElementById("c");
+ var ctx = canvas.getContext("2d");
+ var stage = document.getElementById("stage");
+ var elLevel = document.getElementById("s-level");
+ var elTime = document.getElementById("s-time");
+ var elBest = document.getElementById("s-best");
+ var elTop = document.getElementById("s-top");
+ var banner = document.getElementById("banner");
+ var bannerBig = document.getElementById("banner-big");
+ var bannerSub = document.getElementById("banner-sub");
+
+ // ---------- Audio (procedural, optional) ----------
+ var actx = null;
+ function ensureAudio() {
+ if (!store.sound) return;
+ if (!actx) {
+ try { actx = new (window.AudioContext || window.webkitAudioContext)(); } catch (e) { actx = null; }
+ }
+ if (actx && actx.state === "suspended") actx.resume();
+ }
+ function beep(freq, dur, type, vol) {
+ if (!store.sound || !actx) return;
+ try {
+ var o = actx.createOscillator(), g = actx.createGain();
+ o.type = type || "sine";
+ o.frequency.value = freq;
+ g.gain.value = 0;
+ o.connect(g); g.connect(actx.destination);
+ var t = actx.currentTime;
+ g.gain.setValueAtTime(0, t);
+ g.gain.linearRampToValueAtTime(vol || 0.08, t + 0.008);
+ g.gain.exponentialRampToValueAtTime(0.0001, t + (dur || 0.09));
+ o.start(t); o.stop(t + (dur || 0.09) + 0.02);
+ } catch (e) {}
+ }
+ function sfxStep() { beep(180 + Math.random() * 40, 0.05, "triangle", 0.04); }
+ function sfxBump() { beep(90, 0.08, "square", 0.05); }
+ function sfxWin() {
+ if (!store.sound || !actx) return;
+ var notes = [523, 659, 784, 1046];
+ notes.forEach(function (f, i) { setTimeout(function () { beep(f, 0.16, "sine", 0.09); }, i * 90); });
+ }
+
+ // ---------- Maze generation (recursive backtracker) ----------
+ // Each cell stores wall bits: 1=N,2=E,4=S,8=W (bit set means wall present)
+ var maze = null; // {cols, rows, cells}
+ var N = 1, E = 2, S = 4, W = 8;
+ var DX = { 1: 0, 2: 1, 4: 0, 8: -1 };
+ var DY = { 1: -1, 2: 0, 4: 1, 8: 0 };
+ var OPP = { 1: 4, 2: 8, 4: 1, 8: 2 };
+
+ function genMaze(cols, rows) {
+ var cells = new Uint8Array(cols * rows);
+ for (var i = 0; i < cells.length; i++) cells[i] = 15; // all walls
+ var visited = new Uint8Array(cols * rows);
+ var stack = [];
+ var startIdx = 0;
+ visited[startIdx] = 1;
+ stack.push(startIdx);
+ while (stack.length) {
+ var cur = stack[stack.length - 1];
+ var cx = cur % cols, cy = (cur / cols) | 0;
+ var dirs = [N, E, S, W];
+ // shuffle
+ for (var s = dirs.length - 1; s > 0; s--) {
+ var r = (Math.random() * (s + 1)) | 0;
+ var tmp = dirs[s]; dirs[s] = dirs[r]; dirs[r] = tmp;
+ }
+ var moved = false;
+ for (var d = 0; d < 4; d++) {
+ var dir = dirs[d];
+ var nx = cx + DX[dir], ny = cy + DY[dir];
+ if (nx < 0 || ny < 0 || nx >= cols || ny >= rows) continue;
+ var nIdx = ny * cols + nx;
+ if (visited[nIdx]) continue;
+ cells[cur] &= ~dir;
+ cells[nIdx] &= ~OPP[dir];
+ visited[nIdx] = 1;
+ stack.push(nIdx);
+ moved = true;
+ break;
+ }
+ if (!moved) stack.pop();
+ }
+ return { cols: cols, rows: rows, cells: cells };
+ }
+
+ function canMove(m, cx, cy, dir) {
+ var idx = cy * m.cols + cx;
+ return (m.cells[idx] & dir) === 0;
+ }
+
+ // BFS solution path from start to goal
+ function solvePath(m, sx, sy, gx, gy) {
+ var cols = m.cols, rows = m.rows;
+ var prev = new Int32Array(cols * rows).fill(-1);
+ var seen = new Uint8Array(cols * rows);
+ var q = [sy * cols + sx];
+ seen[q[0]] = 1;
+ var head = 0;
+ while (head < q.length) {
+ var cur = q[head++];
+ if (cur === gy * cols + gx) break;
+ var cx = cur % cols, cy = (cur / cols) | 0;
+ var dirs = [N, E, S, W];
+ for (var d = 0; d < 4; d++) {
+ var dir = dirs[d];
+ if (m.cells[cur] & dir) continue;
+ var nx = cx + DX[dir], ny = cy + DY[dir];
+ if (nx < 0 || ny < 0 || nx >= cols || ny >= rows) continue;
+ var nIdx = ny * cols + nx;
+ if (seen[nIdx]) continue;
+ seen[nIdx] = 1; prev[nIdx] = cur; q.push(nIdx);
+ }
+ }
+ var path = [];
+ var t = gy * cols + gx;
+ if (!seen[t]) return path;
+ while (t !== -1) { path.push(t); t = prev[t]; }
+ path.reverse();
+ return path;
+ }
+
+ // ---------- Game state ----------
+ var game = {
+ level: 1,
+ cols: 8, rows: 8,
+ player: { cx: 0, cy: 0, x: 0, y: 0 }, // cx/cy = cell, x/y = pixel (animated)
+ goal: { cx: 0, cy: 0 },
+ startTime: 0,
+ elapsed: 0,
+ running: false,
+ won: false,
+ trail: {}, // "cx,cy" -> visit count
+ hintPath: null, // array of idx
+ hintUntil: 0,
+ cell: 24, ox: 0, oy: 0
+ };
+
+ function sizeForLevel(lvl) {
+ var base = 7 + lvl; // grows each level
+ return Math.min(base, 45);
+ }
+
+ function newLevel(lvl, isNewGame) {
+ game.level = lvl;
+ var sz = sizeForLevel(lvl);
+ game.cols = sz; game.rows = sz;
+ maze = genMaze(sz, sz);
+ game.player.cx = 0; game.player.cy = 0;
+ game.goal.cx = sz - 1; game.goal.cy = sz - 1;
+ game.trail = {};
+ game.trail["0,0"] = 1;
+ game.hintPath = null;
+ game.hintUntil = 0;
+ game.won = false;
+ game.running = true;
+ game.elapsed = 0;
+ game.startTime = performance.now();
+ layout();
+ // snap pixel pos to start cell
+ game.player.x = game.ox + game.player.cx * game.cell + game.cell / 2;
+ game.player.y = game.oy + game.player.cy * game.cell + game.cell / 2;
+ elLevel.textContent = lvl;
+ updateBanner(isNewGame);
+ }
+
+ function updateBanner(isNewGame) {
+ if (isNewGame) return;
+ bannerBig.textContent = "LEVEL " + game.level;
+ bannerSub.textContent = game.cols + " × " + game.rows + " maze";
+ banner.classList.add("show");
+ setTimeout(function () { banner.classList.remove("show"); }, 1100);
+ }
+
+ // ---------- Layout / sizing ----------
+ var dpr = 1;
+ function layout() {
+ dpr = Math.max(1, Math.min(window.devicePixelRatio || 1, 2));
+ var w = stage.clientWidth, h = stage.clientHeight;
+ canvas.width = Math.floor(w * dpr);
+ canvas.height = Math.floor(h * dpr);
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
+ var pad = 14;
+ var avail = Math.min(w, h) - pad * 2;
+ game.cell = Math.max(6, Math.floor(avail / game.cols));
+ var gridW = game.cell * game.cols;
+ var gridH = game.cell * game.rows;
+ game.ox = Math.floor((w - gridW) / 2);
+ game.oy = Math.floor((h - gridH) / 2);
+ }
+
+ window.addEventListener("resize", function () {
+ var pcx = game.player.cx, pcy = game.player.cy;
+ layout();
+ // keep player pixel aligned to its cell after resize
+ game.player.x = game.ox + pcx * game.cell + game.cell / 2;
+ game.player.y = game.oy + pcy * game.cell + game.cell / 2;
+ });
+
+ // ---------- Movement ----------
+ var moveCooldown = 0;
+ function tryMove(dir) {
+ if (!game.running || game.won) return;
+ var p = game.player;
+ if (!canMove(maze, p.cx, p.cy, dir)) { sfxBump(); return; }
+ p.cx += DX[dir]; p.cy += DY[dir];
+ var key = p.cx + "," + p.cy;
+ game.trail[key] = (game.trail[key] || 0) + 1;
+ sfxStep();
+ if (p.cx === game.goal.cx && p.cy === game.goal.cy) win();
+ }
+
+ var dirMap = {
+ ArrowUp: N, KeyW: N, ArrowRight: E, KeyD: E,
+ ArrowDown: S, KeyS: S, ArrowLeft: W, KeyA: W
+ };
+ var held = {}; // dir -> true
+ var repeatTimer = 0;
+
+ window.addEventListener("keydown", function (e) {
+ var dir = dirMap[e.code];
+ if (dir) {
+ e.preventDefault();
+ ensureAudio();
+ if (!held[dir]) { held[dir] = true; tryMove(dir); repeatTimer = 0; }
+ } else if (e.code === "Space") {
+ e.preventDefault(); doHint();
+ }
+ });
+ window.addEventListener("keyup", function (e) {
+ var dir = dirMap[e.code];
+ if (dir) held[dir] = false;
+ });
+
+ // D-pad + held-to-glide
+ var dpad = document.getElementById("dpad");
+ var dpadHeld = {};
+ var dirWord = { up: N, down: S, left: W, right: E };
+ function dpadPress(word, el) {
+ ensureAudio();
+ var dir = dirWord[word];
+ if (!dpadHeld[dir]) { dpadHeld[dir] = true; tryMove(dir); }
+ if (el) el.classList.add("press");
+ }
+ function dpadRelease(word, el) {
+ dpadHeld[dirWord[word]] = false;
+ if (el) el.classList.remove("press");
+ }
+ Array.prototype.forEach.call(dpad.querySelectorAll(".dbtn[data-dir]"), function (btn) {
+ var word = btn.getAttribute("data-dir");
+ btn.addEventListener("touchstart", function (e) { e.preventDefault(); dpadPress(word, btn); }, { passive: false });
+ btn.addEventListener("touchend", function (e) { e.preventDefault(); dpadRelease(word, btn); }, { passive: false });
+ btn.addEventListener("touchcancel", function (e) { dpadRelease(word, btn); });
+ btn.addEventListener("mousedown", function (e) { e.preventDefault(); dpadPress(word, btn); });
+ btn.addEventListener("mouseup", function () { dpadRelease(word, btn); });
+ btn.addEventListener("mouseleave", function () { dpadRelease(word, btn); });
+ });
+
+ // Swipe on canvas as an alternate touch control
+ var swipe = null;
+ stage.addEventListener("touchstart", function (e) {
+ if (e.target !== canvas) return;
+ ensureAudio();
+ var t = e.touches[0];
+ swipe = { x: t.clientX, y: t.clientY };
+ }, { passive: true });
+ stage.addEventListener("touchmove", function (e) {
+ if (!swipe) return;
+ var t = e.touches[0];
+ var dx = t.clientX - swipe.x, dy = t.clientY - swipe.y;
+ var thresh = 26;
+ if (Math.abs(dx) > thresh || Math.abs(dy) > thresh) {
+ if (Math.abs(dx) > Math.abs(dy)) tryMove(dx > 0 ? E : W);
+ else tryMove(dy > 0 ? S : N);
+ swipe = { x: t.clientX, y: t.clientY };
+ }
+ }, { passive: true });
+ stage.addEventListener("touchend", function () { swipe = null; });
+
+ // ---------- Win / level up ----------
+ function win() {
+ game.won = true;
+ game.running = false;
+ game.elapsed = (performance.now() - game.startTime) / 1000;
+ sfxWin();
+ var t = game.elapsed;
+ var newBest = (store.bestTime == null || t < store.bestTime);
+ if (newBest) store.bestTime = t;
+ if (game.level + 1 > store.topLevel) store.topLevel = game.level + 1;
+ save();
+ refreshHUD();
+ bannerBig.textContent = "SOLVED!";
+ bannerSub.textContent = "Level " + game.level + " · " + t.toFixed(2) + "s" + (newBest ? " · NEW BEST!" : "");
+ banner.classList.add("show");
+ setTimeout(function () {
+ banner.classList.remove("show");
+ setTimeout(function () { newLevel(game.level + 1, false); }, 250);
+ }, 1400);
+ }
+
+ // ---------- Hint path ----------
+ function doHint() {
+ if (!game.running || game.won) return;
+ ensureAudio();
+ game.hintPath = solvePath(maze, game.player.cx, game.player.cy, game.goal.cx, game.goal.cy);
+ game.hintUntil = performance.now() + 1600;
+ beep(660, 0.12, "sine", 0.06);
+ }
+
+ // ---------- HUD ----------
+ function refreshHUD() {
+ elBest.textContent = store.bestTime == null ? "—" : store.bestTime.toFixed(2) + "s";
+ elTop.textContent = store.topLevel;
+ }
+
+ // ---------- Buttons ----------
+ var btnHint = document.getElementById("btn-hint");
+ var btnTrail = document.getElementById("btn-trail");
+ var btnSound = document.getElementById("btn-sound");
+ var btnNew = document.getElementById("btn-new");
+
+ btnHint.addEventListener("click", doHint);
+ btnTrail.addEventListener("click", function () {
+ store.trail = !store.trail; save();
+ btnTrail.classList.toggle("on", store.trail);
+ });
+ btnSound.addEventListener("click", function () {
+ store.sound = !store.sound; save();
+ btnSound.classList.toggle("on", store.sound);
+ btnSound.textContent = store.sound ? "🔊 Sound" : "🔇 Sound";
+ if (store.sound) { ensureAudio(); beep(520, 0.1, "sine", 0.07); }
+ });
+ btnNew.addEventListener("click", function () { ensureAudio(); newLevel(game.level, false); });
+
+ // init button visual states
+ btnTrail.classList.toggle("on", store.trail);
+ btnSound.classList.toggle("on", store.sound);
+ btnSound.textContent = store.sound ? "🔊 Sound" : "🔇 Sound";
+
+ // ---------- Render ----------
+ function draw(now) {
+ var w = stage.clientWidth, h = stage.clientHeight;
+ ctx.clearRect(0, 0, w, h);
+
+ var cell = game.cell, ox = game.ox, oy = game.oy, cols = game.cols, rows = game.rows;
+ var gridW = cell * cols, gridH = cell * rows;
+
+ // floor
+ ctx.fillStyle = getVar("--floor");
+ roundRect(ctx, ox - 4, oy - 4, gridW + 8, gridH + 8, 8);
+ ctx.fill();
+
+ // trail
+ if (store.trail) {
+ ctx.fillStyle = getVar("--trail");
+ for (var key in game.trail) {
+ var parts = key.split(","); var tx = +parts[0], ty = +parts[1];
+ ctx.fillRect(ox + tx * cell + 1, oy + ty * cell + 1, cell - 2, cell - 2);
+ }
+ }
+
+ // hint path
+ if (game.hintPath && now < game.hintUntil) {
+ var pulse = 0.35 + 0.35 * (0.5 + 0.5 * Math.sin(now / 120));
+ ctx.save();
+ ctx.strokeStyle = getVar("--hint");
+ ctx.globalAlpha = pulse;
+ ctx.lineWidth = Math.max(2, cell * 0.28);
+ ctx.lineJoin = "round"; ctx.lineCap = "round";
+ ctx.shadowColor = getVar("--hint");
+ ctx.shadowBlur = 12;
+ ctx.beginPath();
+ for (var hi = 0; hi < game.hintPath.length; hi++) {
+ var id = game.hintPath[hi];
+ var hx = id % cols, hy = (id / cols) | 0;
+ var px = ox + hx * cell + cell / 2, py = oy + hy * cell + cell / 2;
+ if (hi === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py);
+ }
+ ctx.stroke();
+ ctx.restore();
+ } else if (game.hintPath && now >= game.hintUntil) {
+ game.hintPath = null;
+ }
+
+ // walls
+ ctx.save();
+ ctx.strokeStyle = getVar("--wall-glow");
+ ctx.shadowColor = getVar("--wall-glow");
+ ctx.shadowBlur = Math.min(10, cell * 0.4);
+ ctx.lineWidth = Math.max(1.4, cell * 0.09);
+ ctx.lineCap = "round";
+ ctx.beginPath();
+ for (var y = 0; y < rows; y++) {
+ for (var x = 0; x < cols; x++) {
+ var c = maze.cells[y * cols + x];
+ var x0 = ox + x * cell, y0 = oy + y * cell, x1 = x0 + cell, y1 = y0 + cell;
+ if (c & N) { ctx.moveTo(x0, y0); ctx.lineTo(x1, y0); }
+ if (c & W) { ctx.moveTo(x0, y0); ctx.lineTo(x0, y1); }
+ if (c & E) { ctx.moveTo(x1, y0); ctx.lineTo(x1, y1); }
+ if (c & S) { ctx.moveTo(x0, y1); ctx.lineTo(x1, y1); }
+ }
+ }
+ ctx.stroke();
+ ctx.restore();
+
+ // goal
+ var gx = ox + game.goal.cx * cell + cell / 2;
+ var gy = oy + game.goal.cy * cell + cell / 2;
+ var gr = cell * 0.3;
+ var gpulse = 1 + 0.14 * Math.sin(now / 220);
+ ctx.save();
+ ctx.shadowColor = getVar("--goal-glow");
+ ctx.shadowBlur = 22;
+ ctx.fillStyle = getVar("--goal");
+ star(ctx, gx, gy, gr * gpulse, gr * 0.45 * gpulse, 5, now / 900);
+ ctx.fill();
+ ctx.restore();
+
+ // player (animate pixel toward its cell)
+ var targetX = ox + game.player.cx * cell + cell / 2;
+ var targetY = oy + game.player.cy * cell + cell / 2;
+ game.player.x += (targetX - game.player.x) * 0.35;
+ game.player.y += (targetY - game.player.y) * 0.35;
+ var pr = cell * 0.30;
+ ctx.save();
+ ctx.shadowColor = getVar("--player-glow");
+ ctx.shadowBlur = 20;
+ var grad = ctx.createRadialGradient(game.player.x, game.player.y, 1, game.player.x, game.player.y, pr);
+ grad.addColorStop(0, "#eafffb");
+ grad.addColorStop(0.5, getVar("--player"));
+ grad.addColorStop(1, getVar("--player-glow"));
+ ctx.fillStyle = grad;
+ ctx.beginPath();
+ ctx.arc(game.player.x, game.player.y, pr, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.restore();
+
+ // live timer
+ if (game.running && !game.won) {
+ game.elapsed = (now - game.startTime) / 1000;
+ }
+ elTime.textContent = game.elapsed.toFixed(1);
+ }
+
+ function star(c, cx, cy, outer, inner, points, rot) {
+ c.beginPath();
+ for (var i = 0; i < points * 2; i++) {
+ var rr = (i % 2 === 0) ? outer : inner;
+ var a = rot + (i * Math.PI) / points;
+ var px = cx + Math.cos(a) * rr, py = cy + Math.sin(a) * rr;
+ if (i === 0) c.moveTo(px, py); else c.lineTo(px, py);
+ }
+ c.closePath();
+ }
+ function roundRect(c, x, y, w, h, r) {
+ c.beginPath();
+ c.moveTo(x + r, y);
+ c.arcTo(x + w, y, x + w, y + h, r);
+ c.arcTo(x + w, y + h, x, y + h, r);
+ c.arcTo(x, y + h, x, y, r);
+ c.arcTo(x, y, x + w, y, r);
+ c.closePath();
+ }
+ var varCache = {};
+ function getVar(name) {
+ if (varCache[name]) return varCache[name];
+ var v = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
+ varCache[name] = v || "#fff";
+ return varCache[name];
+ }
+
+ // ---------- Main loop (held-to-glide repeat) ----------
+ var lastRepeat = 0;
+ function loop(now) {
+ // handle held movement (keyboard + dpad) with a steady cadence
+ var anyHeld = false, heldDir = 0;
+ for (var d in held) if (held[d]) { anyHeld = true; heldDir = +d; }
+ if (!anyHeld) for (var d2 in dpadHeld) if (dpadHeld[d2]) { anyHeld = true; heldDir = +d2; }
+ if (anyHeld && heldDir) {
+ if (now - lastRepeat > 110) { tryMove(heldDir); lastRepeat = now; }
+ }
+ draw(now);
+ requestAnimationFrame(loop);
+ }
+
+ // ---------- Boot ----------
+ function boot() {
+ refreshHUD();
+ // Resume best progress: start at level 1 fresh each session, but keep records.
+ newLevel(1, false);
+ requestAnimationFrame(loop);
+ }
+ // ensure stage has size before first layout
+ requestAnimationFrame(boot);
+})();
+</script>
+</body>
+</html>
diff --git a/games/minesweeper/index.html b/games/minesweeper/index.html
new file mode 100644
index 0000000..0165f34
--- /dev/null
+++ b/games/minesweeper/index.html
@@ -0,0 +1,820 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8" />
+<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
+<title>Abrams Minesweeper</title>
+<style>
+ :root{
+ --bg:#080b14;
+ --bg2:#0d1220;
+ --panel:#111a2e;
+ --panel2:#0b1120;
+ --grid:#0a0f1c;
+ --cell:#1a2540;
+ --cell-hi:#243258;
+ --cell-lo:#0f1830;
+ --revealed:#0c1424;
+ --line:#22304f;
+ --neon:#00f0ff;
+ --neon2:#ff2bd6;
+ --neon3:#7cff5c;
+ --amber:#ffb020;
+ --danger:#ff3b5c;
+ --text:#cfe3ff;
+ --muted:#6b83ad;
+ --n1:#4ea1ff;
+ --n2:#5cffb0;
+ --n3:#ff6b8a;
+ --n4:#b48bff;
+ --n5:#ffb020;
+ --n6:#38e8ff;
+ --n7:#ff8ad6;
+ --n8:#c8d4ee;
+ }
+ *{box-sizing:border-box;-webkit-tap-highlight-color:transparent;}
+ html,body{margin:0;padding:0;height:100%;}
+ body{
+ font-family:"Segoe UI",Roboto,-apple-system,system-ui,sans-serif;
+ background:
+ radial-gradient(1200px 600px at 20% -10%, rgba(0,240,255,.10), transparent 60%),
+ radial-gradient(900px 500px at 110% 10%, rgba(255,43,214,.10), transparent 55%),
+ linear-gradient(160deg,var(--bg),var(--bg2));
+ color:var(--text);
+ min-height:100%;
+ display:flex;
+ flex-direction:column;
+ align-items:center;
+ justify-content:flex-start;
+ overflow:hidden;
+ user-select:none;
+ -webkit-user-select:none;
+ }
+ #app{
+ width:100%;
+ max-width:920px;
+ min-height:100%;
+ display:flex;
+ flex-direction:column;
+ padding:10px clamp(8px,2vw,18px) 14px;
+ gap:10px;
+ }
+ header{
+ display:flex;
+ align-items:center;
+ justify-content:space-between;
+ gap:10px;
+ flex-wrap:wrap;
+ }
+ .title{
+ display:flex;
+ align-items:baseline;
+ gap:8px;
+ letter-spacing:.5px;
+ }
+ .title h1{
+ margin:0;
+ font-size:clamp(18px,3.4vw,26px);
+ font-weight:800;
+ background:linear-gradient(90deg,var(--neon),var(--neon2));
+ -webkit-background-clip:text;background-clip:text;
+ -webkit-text-fill-color:transparent;
+ text-shadow:0 0 18px rgba(0,240,255,.25);
+ }
+ .title .sub{
+ font-size:11px;
+ color:var(--muted);
+ text-transform:uppercase;
+ letter-spacing:2px;
+ }
+ .controls{
+ display:flex;
+ align-items:center;
+ gap:8px;
+ flex-wrap:wrap;
+ }
+ select,button{
+ font-family:inherit;
+ font-size:13px;
+ color:var(--text);
+ background:linear-gradient(180deg,var(--cell-hi),var(--cell));
+ border:1px solid var(--line);
+ border-radius:9px;
+ padding:8px 12px;
+ cursor:pointer;
+ transition:transform .06s ease,box-shadow .15s ease,border-color .15s ease,background .15s ease;
+ outline:none;
+ }
+ select{padding-right:26px;}
+ button:hover,select:hover{border-color:var(--neon);box-shadow:0 0 12px rgba(0,240,255,.25);}
+ button:active{transform:translateY(1px) scale(.98);}
+ button.primary{
+ background:linear-gradient(180deg,rgba(0,240,255,.22),rgba(0,240,255,.06));
+ border-color:rgba(0,240,255,.5);
+ color:#eafcff;
+ font-weight:700;
+ }
+ button.icon{padding:8px 11px;}
+ button.on{border-color:var(--neon3);box-shadow:0 0 12px rgba(124,255,92,.35);color:#eaffee;}
+ .status{
+ display:flex;
+ align-items:stretch;
+ justify-content:space-between;
+ gap:10px;
+ background:linear-gradient(180deg,var(--panel),var(--panel2));
+ border:1px solid var(--line);
+ border-radius:14px;
+ padding:10px 14px;
+ box-shadow:inset 0 0 24px rgba(0,240,255,.05), 0 6px 20px rgba(0,0,0,.35);
+ }
+ .stat{
+ display:flex;
+ flex-direction:column;
+ align-items:center;
+ gap:2px;
+ min-width:74px;
+ }
+ .stat .label{font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:1.5px;}
+ .stat .val{
+ font-family:"Courier New",monospace;
+ font-weight:800;
+ font-size:clamp(20px,4.4vw,28px);
+ letter-spacing:1px;
+ line-height:1;
+ }
+ .stat.mines .val{color:var(--danger);text-shadow:0 0 10px rgba(255,59,92,.45);}
+ .stat.time .val{color:var(--neon);text-shadow:0 0 10px rgba(0,240,255,.4);}
+ .face{
+ display:flex;align-items:center;justify-content:center;
+ font-size:clamp(24px,6vw,34px);
+ width:clamp(52px,11vw,64px);height:clamp(52px,11vw,64px);
+ border-radius:14px;
+ background:linear-gradient(180deg,var(--cell-hi),var(--cell-lo));
+ border:1px solid var(--line);
+ cursor:pointer;
+ box-shadow:0 4px 14px rgba(0,0,0,.4);
+ transition:transform .06s ease,box-shadow .15s ease;
+ }
+ .face:hover{box-shadow:0 0 16px rgba(0,240,255,.3);}
+ .face:active{transform:translateY(1px) scale(.96);}
+ .boardwrap{
+ flex:1;
+ display:flex;
+ align-items:center;
+ justify-content:center;
+ min-height:0;
+ position:relative;
+ }
+ .board{
+ display:grid;
+ gap:3px;
+ padding:10px;
+ background:linear-gradient(180deg,var(--grid),#060a14);
+ border:1px solid var(--line);
+ border-radius:16px;
+ box-shadow:inset 0 0 40px rgba(0,240,255,.06), 0 10px 34px rgba(0,0,0,.5);
+ touch-action:manipulation;
+ }
+ .cell{
+ display:flex;align-items:center;justify-content:center;
+ font-weight:800;
+ line-height:1;
+ border-radius:6px;
+ background:linear-gradient(180deg,var(--cell-hi),var(--cell));
+ border:1px solid rgba(255,255,255,.04);
+ box-shadow:inset 0 1px 0 rgba(255,255,255,.06), 0 2px 4px rgba(0,0,0,.35);
+ cursor:pointer;
+ transition:background .08s ease,transform .05s ease,box-shadow .12s ease;
+ color:var(--text);
+ overflow:hidden;
+ }
+ .cell:hover:not(.open):not(.flag){background:linear-gradient(180deg,#2c3d68,#1e2c50);}
+ .cell.open{
+ background:var(--revealed);
+ box-shadow:inset 0 0 0 1px rgba(0,240,255,.05);
+ cursor:default;
+ border-color:rgba(0,240,255,.06);
+ }
+ .cell.open.empty{background:#0a1120;}
+ .cell.flag{background:linear-gradient(180deg,#2a1636,#1c0f26);}
+ .cell.mine{background:radial-gradient(circle at 50% 45%,rgba(255,59,92,.5),#200712);}
+ .cell.exploded{background:radial-gradient(circle at 50% 45%,#ff3b5c,#7a0018);box-shadow:0 0 18px rgba(255,59,92,.7);}
+ .cell.wrong{background:linear-gradient(180deg,#2a2030,#221018);}
+ .cell.hint{animation:pulse 1s ease infinite;}
+ @keyframes pulse{0%,100%{box-shadow:inset 0 0 0 2px var(--neon);}50%{box-shadow:inset 0 0 12px 2px var(--neon);}}
+ .n1{color:var(--n1);}.n2{color:var(--n2);}.n3{color:var(--n3);}.n4{color:var(--n4);}
+ .n5{color:var(--n5);}.n6{color:var(--n6);}.n7{color:var(--n7);}.n8{color:var(--n8);}
+ .flagbar{
+ display:flex;align-items:center;justify-content:center;gap:10px;
+ flex-wrap:wrap;
+ }
+ .flagtoggle{
+ display:flex;align-items:center;gap:10px;
+ padding:10px 18px;
+ font-size:15px;font-weight:700;
+ border-radius:12px;
+ background:linear-gradient(180deg,var(--cell-hi),var(--cell));
+ border:1px solid var(--line);
+ }
+ .flagtoggle.armed{
+ border-color:var(--neon2);
+ box-shadow:0 0 18px rgba(255,43,214,.4);
+ background:linear-gradient(180deg,rgba(255,43,214,.25),rgba(255,43,214,.08));
+ color:#ffeafb;
+ }
+ .flagtoggle .mode{font-size:11px;letter-spacing:1px;text-transform:uppercase;opacity:.85;}
+ .hintbtn{font-size:13px;}
+ .overlay{
+ position:absolute;inset:0;
+ display:none;
+ align-items:center;justify-content:center;
+ background:rgba(4,8,16,.72);
+ backdrop-filter:blur(3px);
+ border-radius:16px;
+ z-index:5;
+ padding:16px;
+ }
+ .overlay.show{display:flex;animation:fade .25s ease;}
+ @keyframes fade{from{opacity:0}to{opacity:1}}
+ .card{
+ text-align:center;
+ background:linear-gradient(180deg,var(--panel),var(--panel2));
+ border:1px solid var(--line);
+ border-radius:18px;
+ padding:26px 30px;
+ box-shadow:0 16px 50px rgba(0,0,0,.6);
+ max-width:340px;
+ }
+ .card h2{
+ margin:0 0 6px;
+ font-size:30px;
+ letter-spacing:1px;
+ }
+ .card.win h2{color:var(--neon3);text-shadow:0 0 20px rgba(124,255,92,.5);}
+ .card.lose h2{color:var(--danger);text-shadow:0 0 20px rgba(255,59,92,.5);}
+ .card p{margin:6px 0;color:var(--text);font-size:14px;}
+ .card .best{color:var(--amber);font-weight:700;}
+ .card .newbest{color:var(--neon3);font-weight:800;animation:pulse 1s infinite;}
+ .card button{margin-top:16px;font-size:15px;padding:12px 26px;}
+ footer{
+ text-align:center;font-size:11px;color:var(--muted);letter-spacing:.5px;
+ }
+ footer .best-line{color:var(--amber);}
+ @media (max-width:520px){
+ .stat{min-width:58px;}
+ .controls{width:100%;justify-content:center;}
+ }
+</style>
+</head>
+<body>
+<div id="app">
+ <header>
+ <div class="title">
+ <h1>ABRAMS MINESWEEPER</h1>
+ <span class="sub">neon sweep</span>
+ </div>
+ <div class="controls">
+ <select id="difficulty" aria-label="Difficulty">
+ <option value="beginner">Beginner · 9×9 · 10</option>
+ <option value="intermediate">Intermediate · 16×16 · 40</option>
+ <option value="expert">Expert · 16×30 · 99</option>
+ </select>
+ <button id="newgame" class="primary">↻ New Game</button>
+ <button id="mute" class="icon on" title="Toggle sound">🔊</button>
+ </div>
+ </header>
+
+ <div class="status">
+ <div class="stat mines">
+ <span class="label">Mines</span>
+ <span class="val" id="mineCount">010</span>
+ </div>
+ <div class="face" id="face" title="New game">🙂</div>
+ <div class="stat time">
+ <span class="label">Time</span>
+ <span class="val" id="timer">000</span>
+ </div>
+ </div>
+
+ <div class="flagbar">
+ <div class="flagtoggle" id="flagToggle" role="button" tabindex="0" aria-pressed="false">
+ <span id="flagIcon">⛏️</span>
+ <span class="mode" id="flagMode">Dig mode</span>
+ </div>
+ <button class="hintbtn" id="hint">💡 Hint</button>
+ </div>
+
+ <div class="boardwrap">
+ <div class="board" id="board"></div>
+ <div class="overlay" id="overlay">
+ <div class="card" id="card">
+ <h2 id="ovTitle">You Win!</h2>
+ <p id="ovMsg"></p>
+ <p id="ovBest"></p>
+ <button class="primary" id="ovBtn">Play Again</button>
+ </div>
+ </div>
+ </div>
+
+ <footer>
+ <div class="best-line" id="footerBest"></div>
+ <div>Left-click / tap to dig · Right-click or Flag toggle to mark · First click is always safe</div>
+ </footer>
+</div>
+
+<script>
+(function(){
+ "use strict";
+
+ const DIFFS = {
+ beginner: { rows:9, cols:9, mines:10, label:"Beginner" },
+ intermediate: { rows:16, cols:16, mines:40, label:"Intermediate" },
+ expert: { rows:16, cols:30, mines:99, label:"Expert" }
+ };
+
+ const LS_KEY = "abrams_minesweeper_best_v1";
+ const LS_MUTE = "abrams_minesweeper_mute_v1";
+
+ // --- DOM refs
+ const boardEl = document.getElementById("board");
+ const mineCountEl = document.getElementById("mineCount");
+ const timerEl = document.getElementById("timer");
+ const faceEl = document.getElementById("face");
+ const diffSel = document.getElementById("difficulty");
+ const newBtn = document.getElementById("newgame");
+ const muteBtn = document.getElementById("mute");
+ const flagToggle= document.getElementById("flagToggle");
+ const flagIcon = document.getElementById("flagIcon");
+ const flagMode = document.getElementById("flagMode");
+ const hintBtn = document.getElementById("hint");
+ const overlay = document.getElementById("overlay");
+ const card = document.getElementById("card");
+ const ovTitle = document.getElementById("ovTitle");
+ const ovMsg = document.getElementById("ovMsg");
+ const ovBest = document.getElementById("ovBest");
+ const ovBtn = document.getElementById("ovBtn");
+ const footerBest= document.getElementById("footerBest");
+
+ // --- state
+ let cfg, grid, cellEls, flagMode_on=false, firstClickDone, gameOver, won;
+ let flagsPlaced=0, revealedCount=0, totalSafe=0;
+ let timer=0, timerId=null, muted=false;
+
+ // ---------- Web Audio (procedural, no assets) ----------
+ let audioCtx=null;
+ function ac(){
+ if(muted) return null;
+ if(!audioCtx){
+ try{ audioCtx = new (window.AudioContext||window.webkitAudioContext)(); }
+ catch(e){ return null; }
+ }
+ if(audioCtx.state==="suspended") audioCtx.resume();
+ return audioCtx;
+ }
+ function blip(freq, dur, type, vol){
+ const ctx=ac(); if(!ctx) return;
+ const o=ctx.createOscillator(), g=ctx.createGain();
+ o.type=type||"square"; o.frequency.value=freq;
+ g.gain.value=0.0001;
+ o.connect(g); g.connect(ctx.destination);
+ const t=ctx.currentTime;
+ g.gain.setValueAtTime(0.0001,t);
+ g.gain.exponentialRampToValueAtTime(vol||0.12, t+0.008);
+ g.gain.exponentialRampToValueAtTime(0.0001, t+dur);
+ o.start(t); o.stop(t+dur+0.02);
+ }
+ const S = {
+ reveal(){ blip(520,0.05,"triangle",0.08); },
+ flood(){ blip(680,0.07,"sine",0.06); },
+ flag(){ blip(880,0.06,"square",0.10); },
+ unflag(){ blip(300,0.06,"square",0.08); },
+ win(){ [523,659,784,1047].forEach((f,i)=>setTimeout(()=>blip(f,0.18,"triangle",0.14),i*110)); },
+ lose(){ blip(160,0.4,"sawtooth",0.16); setTimeout(()=>blip(90,0.5,"sawtooth",0.14),140); },
+ click(){ blip(440,0.03,"square",0.05); }
+ };
+
+ // ---------- persistence ----------
+ function loadBest(){
+ try{ return JSON.parse(localStorage.getItem(LS_KEY)) || {}; }
+ catch(e){ return {}; }
+ }
+ function saveBest(o){
+ try{ localStorage.setItem(LS_KEY, JSON.stringify(o)); }catch(e){}
+ }
+ function getBest(key){ const b=loadBest(); return (typeof b[key]==="number")?b[key]:null; }
+ function setBest(key,val){ const b=loadBest(); b[key]=val; saveBest(b); }
+
+ function loadMute(){
+ try{ return localStorage.getItem(LS_MUTE)==="1"; }catch(e){ return false; }
+ }
+ function saveMute(v){ try{ localStorage.setItem(LS_MUTE, v?"1":"0"); }catch(e){} }
+
+ // ---------- helpers ----------
+ function pad3(n){ n=Math.max(0,Math.min(999,n|0)); return String(n).padStart(3,"0"); }
+ function idx(r,c){ return r*cfg.cols+c; }
+ function inBounds(r,c){ return r>=0 && r<cfg.rows && c>=0 && c<cfg.cols; }
+ function neighbors(r,c){
+ const out=[];
+ for(let dr=-1;dr<=1;dr++)for(let dc=-1;dc<=1;dc++){
+ if(dr===0&&dc===0) continue;
+ if(inBounds(r+dr,c+dc)) out.push([r+dr,c+dc]);
+ }
+ return out;
+ }
+
+ // ---------- board sizing ----------
+ function sizeBoard(){
+ // Compute cell size that fits the available viewport.
+ const app = document.getElementById("app");
+ const appW = app.clientWidth - (parseFloat(getComputedStyle(app).paddingLeft)+parseFloat(getComputedStyle(app).paddingRight));
+ // available height: viewport minus other chrome (approx). Use boardwrap.
+ const wrap = boardEl.parentElement;
+ const wrapH = wrap.clientHeight || (window.innerHeight*0.55);
+ const gap=3, pad=10;
+ const availW = Math.max(120, appW) - pad*2 - gap*(cfg.cols-1);
+ const availH = Math.max(120, wrapH) - pad*2 - gap*(cfg.rows-1) - 4;
+ let size = Math.floor(Math.min(availW/cfg.cols, availH/cfg.rows));
+ size = Math.max(16, Math.min(46, size));
+ boardEl.style.gridTemplateColumns = `repeat(${cfg.cols}, ${size}px)`;
+ boardEl.style.gridTemplateRows = `repeat(${cfg.rows}, ${size}px)`;
+ const fs = Math.max(10, Math.round(size*0.56));
+ for(const el of cellEls){ el.style.fontSize = fs+"px"; }
+ }
+
+ // ---------- new game ----------
+ function newGame(){
+ const key = diffSel.value;
+ cfg = DIFFS[key];
+ grid = [];
+ cellEls = [];
+ firstClickDone=false; gameOver=false; won=false;
+ flagsPlaced=0; revealedCount=0;
+ totalSafe = cfg.rows*cfg.cols - cfg.mines;
+ stopTimer(); timer=0; timerEl.textContent="000";
+ faceEl.textContent="🙂";
+ overlay.classList.remove("show");
+ setFlagMode(false);
+
+ for(let i=0;i<cfg.rows*cfg.cols;i++){
+ grid.push({ mine:false, count:0, open:false, flag:false, r:Math.floor(i/cfg.cols), c:i%cfg.cols });
+ }
+
+ boardEl.innerHTML="";
+ const frag=document.createDocumentFragment();
+ for(let r=0;r<cfg.rows;r++){
+ for(let c=0;c<cfg.cols;c++){
+ const el=document.createElement("div");
+ el.className="cell";
+ el.dataset.r=r; el.dataset.c=c;
+ frag.appendChild(el);
+ cellEls.push(el);
+ }
+ }
+ boardEl.appendChild(frag);
+ mineCountEl.textContent = pad3(cfg.mines);
+ sizeBoard();
+ updateFooterBest();
+ }
+
+ // ---------- mine placement (first click safe) ----------
+ function placeMines(safeR, safeC){
+ // build forbidden set: the clicked cell + its neighbors (guarantees an opening)
+ const forbidden = new Set();
+ forbidden.add(idx(safeR,safeC));
+ for(const [nr,nc] of neighbors(safeR,safeC)) forbidden.add(idx(nr,nc));
+
+ // if board too small to keep all neighbors safe, at least keep the clicked cell safe
+ let openable = cfg.rows*cfg.cols - forbidden.size;
+ let localForbidden = forbidden;
+ if(openable < cfg.mines){
+ localForbidden = new Set([idx(safeR,safeC)]);
+ }
+
+ const candidates=[];
+ for(let i=0;i<cfg.rows*cfg.cols;i++){ if(!localForbidden.has(i)) candidates.push(i); }
+ // Fisher-Yates partial shuffle
+ for(let i=candidates.length-1;i>0;i--){
+ const j=Math.floor(Math.random()*(i+1));
+ [candidates[i],candidates[j]]=[candidates[j],candidates[i]];
+ }
+ for(let i=0;i<cfg.mines;i++){ grid[candidates[i]].mine=true; }
+
+ // counts
+ for(let r=0;r<cfg.rows;r++)for(let c=0;c<cfg.cols;c++){
+ const cell=grid[idx(r,c)];
+ if(cell.mine){ cell.count=-1; continue; }
+ let n=0;
+ for(const [nr,nc] of neighbors(r,c)){ if(grid[idx(nr,nc)].mine) n++; }
+ cell.count=n;
+ }
+ }
+
+ // ---------- rendering a cell ----------
+ function renderCell(i){
+ const cell=grid[i], el=cellEls[i];
+ el.className="cell";
+ el.textContent="";
+ if(cell.flag && !cell.open){
+ el.classList.add("flag");
+ el.textContent="🚩";
+ return;
+ }
+ if(cell.open){
+ el.classList.add("open");
+ if(cell.mine){
+ el.classList.add("mine");
+ el.textContent="💣";
+ } else if(cell.count>0){
+ el.classList.add("n"+cell.count);
+ el.textContent=String(cell.count);
+ } else {
+ el.classList.add("empty");
+ }
+ }
+ }
+
+ // ---------- reveal / flood ----------
+ function reveal(r,c){
+ if(gameOver) return;
+ const start=grid[idx(r,c)];
+ if(start.open || start.flag) return;
+
+ if(!firstClickDone){
+ placeMines(r,c);
+ firstClickDone=true;
+ startTimer();
+ }
+
+ if(start.mine){
+ explode(idx(r,c));
+ return;
+ }
+
+ // iterative flood fill
+ const stack=[[r,c]];
+ let flooded=0;
+ while(stack.length){
+ const [cr,cc]=stack.pop();
+ const ci=idx(cr,cc);
+ const cell=grid[ci];
+ if(cell.open || cell.flag || cell.mine) continue;
+ cell.open=true; revealedCount++; flooded++;
+ renderCell(ci);
+ if(cell.count===0){
+ for(const [nr,nc] of neighbors(cr,cc)){
+ const ni=idx(nr,nc);
+ if(!grid[ni].open && !grid[ni].flag && !grid[ni].mine){
+ stack.push([nr,nc]);
+ }
+ }
+ }
+ }
+ if(flooded>1) S.flood(); else S.reveal();
+ checkWin();
+ }
+
+ // chord: if an open numbered cell has exactly `count` flags around it, reveal the rest
+ function chord(r,c){
+ if(gameOver) return;
+ const cell=grid[idx(r,c)];
+ if(!cell.open || cell.count<=0) return;
+ let flags=0; const toOpen=[];
+ for(const [nr,nc] of neighbors(r,c)){
+ const nb=grid[idx(nr,nc)];
+ if(nb.flag) flags++;
+ else if(!nb.open) toOpen.push([nr,nc]);
+ }
+ if(flags===cell.count){
+ for(const [nr,nc] of toOpen){ reveal(nr,nc); if(gameOver) return; }
+ }
+ }
+
+ function explode(i){
+ grid[i].open=true;
+ gameOver=true; won=false;
+ stopTimer();
+ // reveal all mines + mark wrong flags
+ for(let k=0;k<grid.length;k++){
+ const cell=grid[k];
+ if(cell.mine){
+ cell.open=true; renderCell(k);
+ if(k===i) cellEls[k].classList.add("exploded");
+ } else if(cell.flag){
+ renderCell(k);
+ cellEls[k].classList.add("wrong");
+ cellEls[k].textContent="❌";
+ }
+ }
+ cellEls[i].classList.add("exploded");
+ faceEl.textContent="😵";
+ S.lose();
+ setTimeout(()=>showOverlay(false), 380);
+ }
+
+ function checkWin(){
+ if(gameOver) return;
+ if(revealedCount>=totalSafe){
+ gameOver=true; won=true;
+ stopTimer();
+ // auto-flag remaining mines
+ for(let k=0;k<grid.length;k++){
+ if(grid[k].mine && !grid[k].flag){ grid[k].flag=true; renderCell(k); }
+ }
+ flagsPlaced=cfg.mines;
+ mineCountEl.textContent="000";
+ faceEl.textContent="😎";
+ S.win();
+ handleWinRecord();
+ setTimeout(()=>showOverlay(true), 260);
+ }
+ }
+
+ // ---------- flag ----------
+ function toggleFlag(r,c){
+ if(gameOver) return;
+ const cell=grid[idx(r,c)];
+ if(cell.open) return;
+ if(!firstClickDone){ startTimer(); firstClickDone=false; } // don't place mines on flag
+ cell.flag=!cell.flag;
+ flagsPlaced += cell.flag?1:-1;
+ renderCell(idx(r,c));
+ mineCountEl.textContent = pad3(cfg.mines - flagsPlaced);
+ if(cell.flag) S.flag(); else S.unflag();
+ }
+
+ // ---------- timer ----------
+ function startTimer(){
+ if(timerId) return;
+ timerId=setInterval(()=>{
+ timer++; if(timer>999) timer=999;
+ timerEl.textContent=pad3(timer);
+ },1000);
+ }
+ function stopTimer(){ if(timerId){ clearInterval(timerId); timerId=null; } }
+
+ // ---------- win record ----------
+ function handleWinRecord(){
+ const key=diffSel.value;
+ const prev=getBest(key);
+ if(prev===null || timer<prev){
+ setBest(key, timer);
+ won_newBest=true;
+ } else {
+ won_newBest=false;
+ }
+ updateFooterBest();
+ }
+ let won_newBest=false;
+
+ // ---------- overlay ----------
+ function showOverlay(isWin){
+ card.classList.remove("win","lose");
+ if(isWin){
+ card.classList.add("win");
+ ovTitle.textContent="⚡ CLEARED ⚡";
+ ovMsg.textContent = `${DIFFS[diffSel.value].label} swept in ${timer}s`;
+ const best=getBest(diffSel.value);
+ if(won_newBest){
+ ovBest.innerHTML = `<span class="newbest">★ NEW BEST TIME ★</span>`;
+ } else {
+ ovBest.innerHTML = best!==null ? `<span class="best">Best: ${best}s</span>` : "";
+ }
+ } else {
+ card.classList.add("lose");
+ ovTitle.textContent="💥 BOOM 💥";
+ ovMsg.textContent="You hit a mine.";
+ const best=getBest(diffSel.value);
+ ovBest.innerHTML = best!==null ? `<span class="best">Best: ${best}s</span>` : "";
+ }
+ overlay.classList.add("show");
+ }
+
+ function updateFooterBest(){
+ const best=getBest(diffSel.value);
+ footerBest.textContent = best!==null
+ ? `Best ${DIFFS[diffSel.value].label}: ${best}s`
+ : `No best time yet for ${DIFFS[diffSel.value].label}`;
+ }
+
+ // ---------- flag mode toggle ----------
+ function setFlagMode(on){
+ flagMode_on=on;
+ flagToggle.classList.toggle("armed",on);
+ flagToggle.setAttribute("aria-pressed", on?"true":"false");
+ flagIcon.textContent = on?"🚩":"⛏️";
+ flagMode.textContent = on?"Flag mode":"Dig mode";
+ }
+
+ // ---------- hint ----------
+ function giveHint(){
+ if(gameOver) return;
+ if(!firstClickDone){
+ // hint the center-ish safe first click
+ const r=Math.floor(cfg.rows/2), c=Math.floor(cfg.cols/2);
+ pulseHint(idx(r,c));
+ return;
+ }
+ // find a safe unopened, unflagged cell
+ const safe=[];
+ for(let k=0;k<grid.length;k++){
+ if(!grid[k].open && !grid[k].flag && !grid[k].mine) safe.push(k);
+ }
+ if(safe.length){
+ pulseHint(safe[Math.floor(Math.random()*safe.length)]);
+ S.click();
+ }
+ }
+ function pulseHint(i){
+ const el=cellEls[i];
+ el.classList.add("hint");
+ setTimeout(()=>el.classList.remove("hint"),1600);
+ }
+
+ // ---------- event handling ----------
+ let touchTimer=null, touchMoved=false, longPressed=false;
+
+ function cellFromEvent(e){
+ const t = e.target.closest ? e.target.closest(".cell") : null;
+ if(!t) return null;
+ return { r:+t.dataset.r, c:+t.dataset.c };
+ }
+
+ boardEl.addEventListener("click", (e)=>{
+ if(longPressed){ longPressed=false; return; }
+ const pos=cellFromEvent(e); if(!pos) return;
+ if(gameOver) return;
+ ac(); // unlock audio on gesture
+ const cell=grid[idx(pos.r,pos.c)];
+ if(flagMode_on){
+ toggleFlag(pos.r,pos.c);
+ } else if(cell.open && cell.count>0){
+ chord(pos.r,pos.c);
+ } else {
+ reveal(pos.r,pos.c);
+ }
+ });
+
+ boardEl.addEventListener("contextmenu",(e)=>{
+ e.preventDefault();
+ const pos=cellFromEvent(e); if(!pos) return;
+ if(gameOver) return;
+ ac();
+ const cell=grid[idx(pos.r,pos.c)];
+ if(cell.open && cell.count>0) chord(pos.r,pos.c);
+ else toggleFlag(pos.r,pos.c);
+ });
+
+ // touch: long-press = flag
+ boardEl.addEventListener("touchstart",(e)=>{
+ if(gameOver) return;
+ const pos=cellFromEvent(e); if(!pos) return;
+ touchMoved=false; longPressed=false;
+ touchTimer=setTimeout(()=>{
+ longPressed=true;
+ ac();
+ const cell=grid[idx(pos.r,pos.c)];
+ if(cell.open && cell.count>0) chord(pos.r,pos.c);
+ else toggleFlag(pos.r,pos.c);
+ },420);
+ },{passive:true});
+ boardEl.addEventListener("touchmove",()=>{ touchMoved=true; if(touchTimer){clearTimeout(touchTimer);touchTimer=null;} },{passive:true});
+ boardEl.addEventListener("touchend",()=>{ if(touchTimer){clearTimeout(touchTimer);touchTimer=null;} });
+
+ // ---------- control wiring ----------
+ newBtn.addEventListener("click", ()=>{ S.click(); newGame(); });
+ faceEl.addEventListener("click", ()=>{ S.click(); newGame(); });
+ ovBtn.addEventListener("click", ()=>{ newGame(); });
+ diffSel.addEventListener("change", ()=>{ newGame(); });
+
+ flagToggle.addEventListener("click", ()=>{ setFlagMode(!flagMode_on); S.click(); });
+ flagToggle.addEventListener("keydown",(e)=>{ if(e.key===" "||e.key==="Enter"){ e.preventDefault(); setFlagMode(!flagMode_on); }});
+ hintBtn.addEventListener("click", ()=>{ giveHint(); });
+
+ muteBtn.addEventListener("click", ()=>{
+ muted=!muted; saveMute(muted);
+ muteBtn.textContent = muted?"🔇":"🔊";
+ muteBtn.classList.toggle("on",!muted);
+ if(!muted) S.click();
+ });
+
+ // keyboard: F toggles flag mode, N new game, H hint
+ document.addEventListener("keydown",(e)=>{
+ if(e.target && (e.target.tagName==="SELECT")) return;
+ const k=e.key.toLowerCase();
+ if(k==="f"){ setFlagMode(!flagMode_on); }
+ else if(k==="n"){ newGame(); }
+ else if(k==="h"){ giveHint(); }
+ });
+
+ window.addEventListener("resize", ()=>{ if(cfg) sizeBoard(); });
+ window.addEventListener("orientationchange", ()=>{ setTimeout(()=>{ if(cfg) sizeBoard(); },200); });
+
+ // ---------- init ----------
+ muted=loadMute();
+ muteBtn.textContent = muted?"🔇":"🔊";
+ muteBtn.classList.toggle("on",!muted);
+ newGame();
+})();
+</script>
+</body>
+</html>
diff --git a/games/simon/index.html b/games/simon/index.html
new file mode 100644
index 0000000..469dd39
--- /dev/null
+++ b/games/simon/index.html
@@ -0,0 +1,632 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8" />
+<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
+<title>Abrams Simon</title>
+<style>
+ :root {
+ --bg0: #05060a;
+ --bg1: #0b0f1c;
+ --ink: #e8f0ff;
+ --muted: #7b88a8;
+ --accent: #45e6ff;
+ --green: #1fd67a;
+ --red: #ff3b5c;
+ --yellow: #ffd23b;
+ --blue: #3b7bff;
+ }
+
+ * { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
+
+ html, body {
+ margin: 0;
+ padding: 0;
+ width: 100%;
+ height: 100%;
+ overflow: hidden;
+ background:
+ radial-gradient(circle at 50% 8%, #12203f 0%, var(--bg1) 45%, var(--bg0) 100%);
+ color: var(--ink);
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
+ user-select: none;
+ -webkit-user-select: none;
+ touch-action: manipulation;
+ }
+
+ #app {
+ position: absolute;
+ inset: 0;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ padding: clamp(8px, 2.4vmin, 24px);
+ gap: clamp(8px, 2vmin, 20px);
+ }
+
+ header {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 4px;
+ text-align: center;
+ }
+
+ h1 {
+ margin: 0;
+ font-size: clamp(20px, 5vmin, 40px);
+ font-weight: 800;
+ letter-spacing: 0.18em;
+ text-transform: uppercase;
+ background: linear-gradient(90deg, var(--green), var(--yellow), var(--red), var(--blue), var(--accent));
+ -webkit-background-clip: text;
+ background-clip: text;
+ color: transparent;
+ filter: drop-shadow(0 0 12px rgba(69, 230, 255, 0.35));
+ }
+
+ .subtitle {
+ font-size: clamp(10px, 1.8vmin, 13px);
+ color: var(--muted);
+ letter-spacing: 0.24em;
+ text-transform: uppercase;
+ }
+
+ .stats {
+ display: flex;
+ gap: clamp(10px, 3vmin, 28px);
+ align-items: stretch;
+ }
+
+ .stat {
+ min-width: clamp(70px, 18vmin, 110px);
+ padding: clamp(6px, 1.4vmin, 12px) clamp(10px, 2.4vmin, 18px);
+ border-radius: 14px;
+ background: rgba(20, 30, 56, 0.55);
+ border: 1px solid rgba(90, 120, 190, 0.18);
+ box-shadow: inset 0 0 20px rgba(0, 0, 0, 0.35);
+ text-align: center;
+ }
+ .stat .label {
+ font-size: clamp(9px, 1.5vmin, 11px);
+ letter-spacing: 0.16em;
+ text-transform: uppercase;
+ color: var(--muted);
+ }
+ .stat .value {
+ font-size: clamp(20px, 5vmin, 34px);
+ font-weight: 800;
+ line-height: 1.05;
+ color: var(--accent);
+ text-shadow: 0 0 14px rgba(69, 230, 255, 0.4);
+ }
+ .stat.best .value { color: var(--yellow); text-shadow: 0 0 14px rgba(255, 210, 59, 0.4); }
+
+ /* Board */
+ .board-wrap {
+ position: relative;
+ width: min(78vmin, 560px);
+ height: min(78vmin, 560px);
+ max-width: 90vw;
+ max-height: 60vh;
+ }
+
+ .board {
+ position: absolute;
+ inset: 0;
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ grid-template-rows: 1fr 1fr;
+ gap: clamp(8px, 2.2vmin, 20px);
+ border-radius: 50%;
+ padding: clamp(8px, 2.2vmin, 20px);
+ background:
+ radial-gradient(circle at center, rgba(15, 22, 44, 0.9), rgba(5, 7, 14, 0.9));
+ box-shadow:
+ 0 0 0 2px rgba(90, 120, 190, 0.15),
+ 0 0 60px rgba(0, 0, 0, 0.6),
+ inset 0 0 40px rgba(0, 0, 0, 0.7);
+ }
+
+ .pad {
+ position: relative;
+ border: none;
+ cursor: pointer;
+ background: var(--c);
+ filter: brightness(0.42) saturate(0.9);
+ transition: filter 0.09s ease, box-shadow 0.09s ease, transform 0.06s ease;
+ outline: none;
+ box-shadow: inset 0 0 30px rgba(0, 0, 0, 0.55);
+ }
+ .pad::after {
+ content: "";
+ position: absolute;
+ inset: 0;
+ border-radius: inherit;
+ box-shadow: inset 0 0 22px rgba(255, 255, 255, 0.08);
+ pointer-events: none;
+ }
+
+ .pad.tl { border-top-left-radius: 100%; --c: var(--green); }
+ .pad.tr { border-top-right-radius: 100%; --c: var(--red); }
+ .pad.bl { border-bottom-left-radius: 100%; --c: var(--yellow); }
+ .pad.br { border-bottom-right-radius: 100%; --c: var(--blue); }
+
+ .pad.lit {
+ filter: brightness(1.5) saturate(1.25);
+ box-shadow:
+ 0 0 46px 8px var(--c),
+ inset 0 0 40px rgba(255, 255, 255, 0.5);
+ transform: scale(1.015);
+ }
+
+ .board.playable .pad:not(:disabled):active {
+ filter: brightness(1.35) saturate(1.2);
+ }
+
+ /* Center hub */
+ .hub {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ width: 34%;
+ height: 34%;
+ border-radius: 50%;
+ background: radial-gradient(circle at 35% 30%, #16223f, #080b16 72%);
+ border: 2px solid rgba(90, 120, 190, 0.25);
+ box-shadow: 0 0 30px rgba(0, 0, 0, 0.7), inset 0 0 26px rgba(0, 0, 0, 0.7);
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 2px;
+ z-index: 3;
+ text-align: center;
+ padding: 4px;
+ }
+ .hub .hub-title {
+ font-size: clamp(10px, 2.1vmin, 15px);
+ font-weight: 800;
+ letter-spacing: 0.16em;
+ color: var(--accent);
+ text-shadow: 0 0 12px rgba(69, 230, 255, 0.5);
+ }
+ .hub .hub-msg {
+ font-size: clamp(9px, 1.7vmin, 12px);
+ color: var(--muted);
+ letter-spacing: 0.06em;
+ min-height: 1.1em;
+ }
+
+ /* Controls */
+ .controls {
+ display: flex;
+ flex-wrap: wrap;
+ gap: clamp(8px, 2vmin, 16px);
+ align-items: center;
+ justify-content: center;
+ }
+
+ button.ctrl {
+ font-family: inherit;
+ font-weight: 700;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+ font-size: clamp(11px, 2vmin, 14px);
+ color: var(--ink);
+ background: linear-gradient(180deg, rgba(40, 58, 100, 0.75), rgba(20, 30, 56, 0.75));
+ border: 1px solid rgba(90, 130, 210, 0.35);
+ border-radius: 12px;
+ padding: clamp(8px, 1.8vmin, 12px) clamp(16px, 4vmin, 26px);
+ cursor: pointer;
+ transition: transform 0.08s ease, box-shadow 0.15s ease, border-color 0.15s ease;
+ box-shadow: 0 0 0 rgba(69, 230, 255, 0);
+ }
+ button.ctrl:hover { border-color: var(--accent); box-shadow: 0 0 18px rgba(69, 230, 255, 0.25); }
+ button.ctrl:active { transform: translateY(1px) scale(0.98); }
+
+ button.start {
+ background: linear-gradient(180deg, var(--accent), #1b9fbf);
+ color: #041018;
+ border-color: rgba(69, 230, 255, 0.6);
+ box-shadow: 0 0 22px rgba(69, 230, 255, 0.35);
+ }
+ button.start:hover { box-shadow: 0 0 30px rgba(69, 230, 255, 0.55); }
+
+ .toggle {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ font-size: clamp(10px, 1.8vmin, 13px);
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: var(--muted);
+ cursor: pointer;
+ padding: clamp(7px, 1.6vmin, 11px) clamp(12px, 3vmin, 18px);
+ border-radius: 12px;
+ border: 1px solid rgba(90, 130, 210, 0.22);
+ background: rgba(20, 30, 56, 0.4);
+ transition: border-color 0.15s ease, color 0.15s ease;
+ }
+ .toggle.on { color: var(--ink); border-color: rgba(69, 230, 255, 0.5); }
+ .toggle .dot {
+ width: 12px; height: 12px; border-radius: 50%;
+ background: rgba(120, 140, 180, 0.4);
+ box-shadow: inset 0 0 4px rgba(0,0,0,0.6);
+ transition: background 0.15s ease, box-shadow 0.15s ease;
+ }
+ .toggle.on .dot {
+ background: var(--green);
+ box-shadow: 0 0 10px var(--green);
+ }
+ .toggle.strict.on .dot { background: var(--red); box-shadow: 0 0 10px var(--red); }
+
+ .board.flash-fail { animation: shake 0.4s ease; }
+ @keyframes shake {
+ 0%,100% { transform: translateX(0); }
+ 20% { transform: translateX(-8px); }
+ 40% { transform: translateX(8px); }
+ 60% { transform: translateX(-5px); }
+ 80% { transform: translateX(5px); }
+ }
+
+ @media (max-height: 560px) {
+ header .subtitle { display: none; }
+ .board-wrap { max-height: 52vh; }
+ }
+</style>
+</head>
+<body>
+<div id="app">
+ <header>
+ <h1>Abrams Simon</h1>
+ <div class="subtitle">Watch · Remember · Repeat</div>
+ </header>
+
+ <div class="stats">
+ <div class="stat"><div class="label">Round</div><div class="value" id="roundVal">0</div></div>
+ <div class="stat best"><div class="label">Best</div><div class="value" id="bestVal">0</div></div>
+ </div>
+
+ <div class="board-wrap">
+ <div class="board" id="board">
+ <button class="pad tl" data-i="0" aria-label="green" disabled></button>
+ <button class="pad tr" data-i="1" aria-label="red" disabled></button>
+ <button class="pad bl" data-i="2" aria-label="yellow" disabled></button>
+ <button class="pad br" data-i="3" aria-label="blue" disabled></button>
+ </div>
+ <div class="hub">
+ <div class="hub-title" id="hubTitle">READY?</div>
+ <div class="hub-msg" id="hubMsg">Press Start</div>
+ </div>
+ </div>
+
+ <div class="controls">
+ <button class="ctrl start" id="startBtn">Start</button>
+ <div class="toggle strict" id="strictToggle" role="switch" aria-checked="false" tabindex="0">
+ <span class="dot"></span><span>Strict</span>
+ </div>
+ <div class="toggle on" id="soundToggle" role="switch" aria-checked="true" tabindex="0">
+ <span class="dot"></span><span>Sound</span>
+ </div>
+ </div>
+</div>
+
+<script>
+(function () {
+ "use strict";
+
+ // ---- Elements ----
+ const pads = Array.from(document.querySelectorAll(".pad"));
+ const board = document.getElementById("board");
+ const roundVal = document.getElementById("roundVal");
+ const bestVal = document.getElementById("bestVal");
+ const hubTitle = document.getElementById("hubTitle");
+ const hubMsg = document.getElementById("hubMsg");
+ const startBtn = document.getElementById("startBtn");
+ const strictToggle = document.getElementById("strictToggle");
+ const soundToggle = document.getElementById("soundToggle");
+
+ // ---- State ----
+ const BEST_KEY = "abrams-simon-best";
+ const SOUND_KEY = "abrams-simon-sound";
+ const STRICT_KEY = "abrams-simon-strict";
+
+ let sequence = [];
+ let userStep = 0;
+ let acceptingInput = false;
+ let playing = false;
+ let busy = false; // during playback / transitions
+ let best = 0;
+
+ // pad tones (green, red, yellow, blue) — pleasant-ish intervals
+ const TONES = [329.63, 261.63, 220.0, 164.81]; // E4, C4, A3, E3
+ const FAIL_TONE = 110.0;
+
+ // ---- Audio ----
+ let audioCtx = null;
+ let soundOn = true;
+
+ function ensureAudio() {
+ if (!audioCtx) {
+ try {
+ const AC = window.AudioContext || window.webkitAudioContext;
+ audioCtx = new AC();
+ } catch (e) {
+ audioCtx = null;
+ }
+ }
+ if (audioCtx && audioCtx.state === "suspended") {
+ audioCtx.resume().catch(function () {});
+ }
+ return audioCtx;
+ }
+
+ function playTone(freq, durationMs, type) {
+ if (!soundOn) return;
+ const ctx = ensureAudio();
+ if (!ctx) return;
+ const now = ctx.currentTime;
+ const dur = durationMs / 1000;
+
+ const osc = ctx.createOscillator();
+ const gain = ctx.createGain();
+ osc.type = type || "sine";
+ osc.frequency.setValueAtTime(freq, now);
+
+ gain.gain.setValueAtTime(0.0001, now);
+ gain.gain.exponentialRampToValueAtTime(0.28, now + 0.012);
+ gain.gain.setValueAtTime(0.28, now + Math.max(0.012, dur - 0.06));
+ gain.gain.exponentialRampToValueAtTime(0.0001, now + dur);
+
+ osc.connect(gain).connect(ctx.destination);
+ osc.start(now);
+ osc.stop(now + dur + 0.02);
+ }
+
+ function playFail() {
+ if (!soundOn) return;
+ const ctx = ensureAudio();
+ if (!ctx) return;
+ const now = ctx.currentTime;
+ const osc = ctx.createOscillator();
+ const gain = ctx.createGain();
+ osc.type = "sawtooth";
+ osc.frequency.setValueAtTime(FAIL_TONE, now);
+ osc.frequency.exponentialRampToValueAtTime(55, now + 0.7);
+ gain.gain.setValueAtTime(0.0001, now);
+ gain.gain.exponentialRampToValueAtTime(0.3, now + 0.02);
+ gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.75);
+ osc.connect(gain).connect(ctx.destination);
+ osc.start(now);
+ osc.stop(now + 0.8);
+ }
+
+ function winChime() {
+ if (!soundOn) return;
+ const ctx = ensureAudio();
+ if (!ctx) return;
+ [523.25, 659.25, 783.99].forEach(function (f, k) {
+ setTimeout(function () { playTone(f, 160, "triangle"); }, k * 90);
+ });
+ }
+
+ // ---- Timing (speeds up with level) ----
+ function litDuration() {
+ // starts ~560ms, floors ~200ms
+ return Math.max(200, 560 - sequence.length * 22);
+ }
+ function gapDuration() {
+ return Math.max(80, 200 - sequence.length * 8);
+ }
+
+ // ---- Visual pad flash ----
+ function litPad(i, duration) {
+ return new Promise(function (resolve) {
+ const pad = pads[i];
+ pad.classList.add("lit");
+ playTone(TONES[i], duration, "sine");
+ setTimeout(function () {
+ pad.classList.remove("lit");
+ resolve();
+ }, duration);
+ });
+ }
+
+ function sleep(ms) {
+ return new Promise(function (r) { setTimeout(r, ms); });
+ }
+
+ // ---- Sequence playback ----
+ async function playSequence() {
+ busy = true;
+ acceptingInput = false;
+ setBoardPlayable(false);
+ hubTitle.textContent = "WATCH";
+ hubMsg.textContent = "Round " + sequence.length;
+ await sleep(500);
+ const dur = litDuration();
+ const gap = gapDuration();
+ for (let k = 0; k < sequence.length; k++) {
+ await litPad(sequence[k], dur);
+ await sleep(gap);
+ }
+ busy = false;
+ acceptingInput = true;
+ userStep = 0;
+ setBoardPlayable(true);
+ hubTitle.textContent = "YOUR TURN";
+ hubMsg.textContent = "0 / " + sequence.length;
+ }
+
+ function setBoardPlayable(on) {
+ pads.forEach(function (p) { p.disabled = !on; });
+ board.classList.toggle("playable", on);
+ }
+
+ // ---- Round flow ----
+ function nextRound() {
+ sequence.push(Math.floor(Math.random() * 4));
+ roundVal.textContent = sequence.length;
+ playSequence();
+ }
+
+ function startGame() {
+ ensureAudio();
+ sequence = [];
+ userStep = 0;
+ playing = true;
+ acceptingInput = false;
+ roundVal.textContent = "0";
+ startBtn.textContent = "Restart";
+ board.classList.remove("flash-fail");
+ hubTitle.textContent = "GO!";
+ hubMsg.textContent = "";
+ setTimeout(nextRound, 350);
+ }
+
+ async function handlePress(i) {
+ if (!acceptingInput || busy || !playing) return;
+
+ // flash the pressed pad
+ const dur = 220;
+ acceptingInput = false;
+ await litPad(i, dur);
+
+ if (i !== sequence[userStep]) {
+ gameOver();
+ return;
+ }
+
+ userStep++;
+ hubMsg.textContent = userStep + " / " + sequence.length;
+
+ if (userStep >= sequence.length) {
+ // round cleared
+ updateBest(sequence.length);
+ winChime();
+ hubTitle.textContent = "NICE";
+ hubMsg.textContent = "Round " + sequence.length + " cleared";
+ acceptingInput = false;
+ setBoardPlayable(false);
+ await sleep(650);
+ nextRound();
+ } else {
+ acceptingInput = true;
+ }
+ }
+
+ async function gameOver() {
+ playing = false;
+ acceptingInput = false;
+ setBoardPlayable(false);
+ playFail();
+ board.classList.add("flash-fail");
+
+ // flash all pads red-ish
+ pads.forEach(function (p) { p.classList.add("lit"); });
+ await sleep(320);
+ pads.forEach(function (p) { p.classList.remove("lit"); });
+ board.classList.remove("flash-fail");
+
+ const reached = Math.max(0, sequence.length - 1);
+ hubTitle.textContent = "GAME OVER";
+ hubMsg.textContent = "Reached round " + reached;
+ startBtn.textContent = "Start";
+
+ if (strictOn) {
+ // strict: sequence resets fully (already will on Start)
+ sequence = [];
+ roundVal.textContent = "0";
+ }
+ }
+
+ function updateBest(level) {
+ if (level > best) {
+ best = level;
+ bestVal.textContent = best;
+ try { localStorage.setItem(BEST_KEY, String(best)); } catch (e) {}
+ }
+ }
+
+ // ---- Toggles ----
+ let strictOn = false;
+
+ function setSound(on) {
+ soundOn = on;
+ soundToggle.classList.toggle("on", on);
+ soundToggle.setAttribute("aria-checked", on ? "true" : "false");
+ try { localStorage.setItem(SOUND_KEY, on ? "1" : "0"); } catch (e) {}
+ if (on) ensureAudio();
+ }
+
+ function setStrict(on) {
+ strictOn = on;
+ strictToggle.classList.toggle("on", on);
+ strictToggle.setAttribute("aria-checked", on ? "true" : "false");
+ try { localStorage.setItem(STRICT_KEY, on ? "1" : "0"); } catch (e) {}
+ }
+
+ // ---- Wire events ----
+ pads.forEach(function (pad) {
+ const i = parseInt(pad.getAttribute("data-i"), 10);
+ // Pointer events cover mouse + touch; prevent double-fire.
+ pad.addEventListener("pointerdown", function (ev) {
+ ev.preventDefault();
+ handlePress(i);
+ });
+ // keyboard access
+ pad.addEventListener("keydown", function (ev) {
+ if (ev.key === "Enter" || ev.key === " ") {
+ ev.preventDefault();
+ handlePress(i);
+ }
+ });
+ });
+
+ startBtn.addEventListener("click", function () {
+ ensureAudio();
+ startGame();
+ });
+
+ soundToggle.addEventListener("click", function () { setSound(!soundOn); });
+ soundToggle.addEventListener("keydown", function (ev) {
+ if (ev.key === "Enter" || ev.key === " ") { ev.preventDefault(); setSound(!soundOn); }
+ });
+
+ strictToggle.addEventListener("click", function () { setStrict(!strictOn); });
+ strictToggle.addEventListener("keydown", function (ev) {
+ if (ev.key === "Enter" || ev.key === " ") { ev.preventDefault(); setStrict(!strictOn); }
+ });
+
+ // number-key shortcuts 1-4
+ window.addEventListener("keydown", function (ev) {
+ if (ev.repeat) return;
+ const map = { "1": 0, "2": 1, "3": 2, "4": 3 };
+ if (ev.key in map) { handlePress(map[ev.key]); }
+ else if (ev.key === "Enter" && !playing) { ensureAudio(); startGame(); }
+ });
+
+ // ---- Init from storage ----
+ (function init() {
+ try {
+ const b = parseInt(localStorage.getItem(BEST_KEY) || "0", 10);
+ if (!isNaN(b) && b > 0) { best = b; bestVal.textContent = b; }
+ } catch (e) {}
+
+ let s = true, st = false;
+ try {
+ const sv = localStorage.getItem(SOUND_KEY);
+ if (sv !== null) s = sv === "1";
+ const stv = localStorage.getItem(STRICT_KEY);
+ if (stv !== null) st = stv === "1";
+ } catch (e) {}
+ setSound(s);
+ setStrict(st);
+ })();
+})();
+</script>
+</body>
+</html>
diff --git a/games/tower-stack/index.html b/games/tower-stack/index.html
new file mode 100644
index 0000000..d25f198
--- /dev/null
+++ b/games/tower-stack/index.html
@@ -0,0 +1,513 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8" />
+<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
+<title>Abrams Stack</title>
+<style>
+ * { margin: 0; padding: 0; box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
+ html, body { width: 100%; height: 100%; overflow: hidden; background: #0a0a18; }
+ #wrap {
+ position: fixed; inset: 0;
+ display: flex; align-items: center; justify-content: center;
+ background: radial-gradient(ellipse at 50% 0%, #1a1240 0%, #0a0a18 60%, #050510 100%);
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
+ user-select: none;
+ }
+ canvas { display: block; touch-action: none; }
+
+ /* HUD */
+ #hud {
+ position: absolute; top: 0; left: 0; right: 0;
+ display: flex; justify-content: space-between; align-items: flex-start;
+ padding: 18px 20px; pointer-events: none; z-index: 5;
+ }
+ .stat { color: #fff; text-shadow: 0 2px 12px rgba(0,0,0,.6); }
+ .stat .label { font-size: 11px; letter-spacing: 2px; text-transform: uppercase; opacity: .55; }
+ .stat .val { font-size: 34px; font-weight: 800; line-height: 1; margin-top: 2px;
+ background: linear-gradient(180deg,#fff,#b9c4ff); -webkit-background-clip: text; background-clip: text; color: transparent; }
+ .stat.right { text-align: right; }
+ #combo {
+ position:absolute; top: 78px; left: 50%; transform: translateX(-50%);
+ font-size: 15px; font-weight: 700; letter-spacing: 1px; color: #ffe27a;
+ text-shadow: 0 0 18px rgba(255,210,80,.7); opacity: 0; transition: opacity .2s; pointer-events:none; z-index:5;
+ }
+
+ /* Mute */
+ #mute {
+ position: absolute; top: 16px; right: 50%; transform: translateX(50%);
+ width: 42px; height: 42px; border-radius: 12px;
+ background: rgba(255,255,255,.07); border: 1px solid rgba(255,255,255,.14);
+ color: #fff; font-size: 19px; cursor: pointer; z-index: 6;
+ display:flex; align-items:center; justify-content:center; backdrop-filter: blur(6px);
+ transition: background .15s, transform .1s;
+ }
+ #mute:hover { background: rgba(255,255,255,.14); }
+ #mute:active { transform: translateX(50%) scale(.9); }
+
+ /* Overlay */
+ #overlay {
+ position: absolute; inset: 0; z-index: 10;
+ display: flex; flex-direction: column; align-items: center; justify-content: center;
+ text-align: center; padding: 24px;
+ background: radial-gradient(ellipse at 50% 40%, rgba(30,20,70,.55), rgba(5,5,16,.9));
+ backdrop-filter: blur(3px);
+ transition: opacity .35s;
+ }
+ #overlay.hidden { opacity: 0; pointer-events: none; }
+ #title {
+ font-size: clamp(42px, 12vw, 92px); font-weight: 900; letter-spacing: -1px; line-height: .95;
+ background: linear-gradient(120deg,#7af0ff,#a97bff 45%,#ff7ac6 90%);
+ -webkit-background-clip: text; background-clip: text; color: transparent;
+ filter: drop-shadow(0 4px 26px rgba(160,110,255,.45));
+ }
+ #subtitle { margin-top: 14px; font-size: 15px; letter-spacing: 3px; text-transform: uppercase; color: rgba(255,255,255,.6); }
+ #big {
+ margin: 26px 0 6px; font-size: clamp(34px,9vw,60px); font-weight: 900; color:#fff;
+ text-shadow: 0 0 30px rgba(140,110,255,.6);
+ }
+ #ovBest { font-size: 14px; letter-spacing: 2px; color:#8fe0ff; text-transform: uppercase; }
+ #hint {
+ margin-top: 34px; font-size: 15px; color: rgba(255,255,255,.82);
+ padding: 14px 26px; border: 1px solid rgba(255,255,255,.16); border-radius: 50px;
+ background: rgba(255,255,255,.05); animation: pulse 1.8s ease-in-out infinite;
+ }
+ @keyframes pulse { 0%,100%{ transform: scale(1); opacity:.85 } 50%{ transform: scale(1.05); opacity:1 } }
+ #credit { position:absolute; bottom: 16px; font-size: 11px; letter-spacing: 2px; color: rgba(255,255,255,.28); text-transform: uppercase; }
+</style>
+</head>
+<body>
+<div id="wrap">
+ <canvas id="c"></canvas>
+
+ <div id="hud">
+ <div class="stat">
+ <div class="label">Height</div>
+ <div class="val" id="score">0</div>
+ </div>
+ <div class="stat right">
+ <div class="label">Best</div>
+ <div class="val" id="best">0</div>
+ </div>
+ </div>
+ <div id="combo"></div>
+ <button id="mute" aria-label="Toggle sound">🔊</button>
+
+ <div id="overlay">
+ <div id="title">ABRAMS<br>STACK</div>
+ <div id="subtitle">stack it high</div>
+ <div id="big" style="display:none"></div>
+ <div id="ovBest"></div>
+ <div id="hint">Tap / Click / Space to drop</div>
+ </div>
+ <div id="credit">Pure Canvas 2D · Zero Dependencies</div>
+</div>
+
+<script>
+(function () {
+ "use strict";
+
+ var canvas = document.getElementById("c");
+ var ctx = canvas.getContext("2d");
+ var scoreEl = document.getElementById("score");
+ var bestEl = document.getElementById("best");
+ var comboEl = document.getElementById("combo");
+ var overlay = document.getElementById("overlay");
+ var titleEl = document.getElementById("title");
+ var subtitleEl = document.getElementById("subtitle");
+ var bigEl = document.getElementById("big");
+ var ovBestEl = document.getElementById("ovBest");
+ var hintEl = document.getElementById("hint");
+ var muteBtn = document.getElementById("mute");
+
+ // ---------- Sizing (DPR aware, iframe responsive) ----------
+ var W = 0, H = 0, DPR = 1;
+ function resize() {
+ DPR = Math.min(window.devicePixelRatio || 1, 2);
+ W = window.innerWidth;
+ H = window.innerHeight;
+ canvas.width = Math.floor(W * DPR);
+ canvas.height = Math.floor(H * DPR);
+ canvas.style.width = W + "px";
+ canvas.style.height = H + "px";
+ ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
+ }
+ window.addEventListener("resize", resize);
+ resize();
+
+ // ---------- Persistent best ----------
+ var best = 0;
+ try { best = parseInt(localStorage.getItem("abramsStackBest") || "0", 10) || 0; } catch (e) {}
+ bestEl.textContent = best;
+
+ // ---------- Audio (procedural, optional) ----------
+ var muted = false;
+ try { muted = localStorage.getItem("abramsStackMuted") === "1"; } catch (e) {}
+ muteBtn.textContent = muted ? "🔇" : "🔊";
+ var actx = null;
+ function ensureAudio() {
+ if (muted) return;
+ if (!actx) {
+ try { actx = new (window.AudioContext || window.webkitAudioContext)(); } catch (e) { actx = null; }
+ }
+ if (actx && actx.state === "suspended") actx.resume();
+ }
+ function tone(freq, dur, type, vol, glideTo) {
+ if (muted || !actx) return;
+ var t = actx.currentTime;
+ var o = actx.createOscillator();
+ var g = actx.createGain();
+ o.type = type || "sine";
+ o.frequency.setValueAtTime(freq, t);
+ if (glideTo) o.frequency.exponentialRampToValueAtTime(glideTo, t + dur);
+ g.gain.setValueAtTime(0.0001, t);
+ g.gain.exponentialRampToValueAtTime(vol || 0.2, t + 0.008);
+ g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
+ o.connect(g); g.connect(actx.destination);
+ o.start(t); o.stop(t + dur + 0.02);
+ }
+ function sndDrop() { tone(220, 0.14, "sine", 0.22, 150); }
+ function sndSlice() { tone(140, 0.12, "sawtooth", 0.14, 90); }
+ function sndPerfect(n) {
+ if (muted || !actx) return;
+ var base = 520 + Math.min(n, 8) * 40;
+ tone(base, 0.14, "triangle", 0.22);
+ setTimeout(function () { tone(base * 1.5, 0.16, "triangle", 0.18); }, 70);
+ }
+ function sndOver() {
+ tone(300, 0.5, "sawtooth", 0.2, 70);
+ setTimeout(function () { tone(160, 0.5, "sine", 0.15, 60); }, 90);
+ }
+ muteBtn.addEventListener("click", function (e) {
+ e.stopPropagation();
+ muted = !muted;
+ muteBtn.textContent = muted ? "🔇" : "🔊";
+ try { localStorage.setItem("abramsStackMuted", muted ? "1" : "0"); } catch (er) {}
+ if (!muted) ensureAudio();
+ });
+
+ // ---------- Game state ----------
+ var STATE_START = 0, STATE_PLAY = 1, STATE_OVER = 2;
+ var state = STATE_START;
+
+ var BLOCK_H = 46; // logical pixel height of each block
+ var baseWidth = 0; // starting block width
+ var blocks = []; // placed blocks {x, w, hue}
+ var current = null; // moving block {x, w, dir, hue}
+ var speed = 0;
+ var camY = 0; // camera vertical offset (target)
+ var camYCur = 0; // smoothed
+ var score = 0;
+ var combo = 0;
+ var particles = [];
+ var slices = []; // falling sliced-off pieces
+ var flash = 0; // perfect flash intensity
+
+ function hueForLevel(n) { return (200 + n * 9) % 360; }
+
+ function groundY() { return H - 90; } // y of top of base row in world space (before camera)
+
+ function reset() {
+ blocks = [];
+ slices = [];
+ particles = [];
+ score = 0;
+ combo = 0;
+ camY = 0; camYCur = 0;
+ flash = 0;
+ baseWidth = Math.min(W * 0.5, 300);
+ speed = Math.max(2.6, W / 300);
+ var bx = (W - baseWidth) / 2;
+ blocks.push({ x: bx, w: baseWidth, hue: hueForLevel(0) });
+ spawnCurrent();
+ }
+
+ function spawnCurrent() {
+ var prev = blocks[blocks.length - 1];
+ var fromLeft = blocks.length % 2 === 0;
+ var startX = fromLeft ? -prev.w : W;
+ current = {
+ x: fromLeft ? -prev.w * 0.6 : W - prev.w * 0.4,
+ w: prev.w,
+ dir: fromLeft ? 1 : -1,
+ hue: hueForLevel(blocks.length)
+ };
+ // speed scales with height
+ speed = Math.max(2.6, W / 300) * (1 + blocks.length * 0.035);
+ }
+
+ // world Y (bottom-up) for the top of a block at index i (i=0 is base)
+ function blockWorldY(i) { return groundY() - i * BLOCK_H; }
+
+ function showCombo(txt) {
+ comboEl.textContent = txt;
+ comboEl.style.opacity = "1";
+ clearTimeout(showCombo._t);
+ showCombo._t = setTimeout(function () { comboEl.style.opacity = "0"; }, 800);
+ }
+
+ function drop() {
+ if (state !== STATE_PLAY) return;
+ var prev = blocks[blocks.length - 1];
+ var cx = current.x, cw = current.w;
+ var overlapL = Math.max(cx, prev.x);
+ var overlapR = Math.min(cx + cw, prev.x + prev.w);
+ var overlap = overlapR - overlapL;
+
+ if (overlap <= 0) {
+ // total miss -> game over. add the whole falling block as a slice
+ slices.push({ x: cx, w: cw, y: blockWorldY(blocks.length - 1) - BLOCK_H, hue: current.hue, vy: 0, vx: current.dir * 1.5, rot: 0, vr: current.dir * 0.05 });
+ gameOver();
+ return;
+ }
+
+ // perfect detection
+ var diff = Math.abs(cx - prev.x);
+ var perfect = diff < 4;
+
+ if (perfect) {
+ combo++;
+ flash = 1;
+ sndPerfect(combo);
+ // snap to perfect alignment (no shrink), small bonus width back on combo
+ cx = prev.x;
+ showCombo(combo > 1 ? "PERFECT ×" + combo + " +" + (combo) : "PERFECT!");
+ spawnParticles(cx + cw / 2, blockWorldY(blocks.length - 1) - BLOCK_H / 2, current.hue, 22, true);
+ } else {
+ combo = 0;
+ sndDrop();
+ // slice off the overhang
+ if (cx < prev.x) {
+ // overhang on the left
+ var offW = prev.x - cx;
+ slices.push({ x: cx, w: offW, y: blockWorldY(blocks.length - 1) - BLOCK_H, hue: current.hue, vy: 0, vx: -1.4, rot: 0, vr: -0.06 });
+ } else {
+ var offW2 = (cx + cw) - (prev.x + prev.w);
+ if (offW2 > 0) slices.push({ x: prev.x + prev.w, w: offW2, y: blockWorldY(blocks.length - 1) - BLOCK_H, hue: current.hue, vy: 0, vx: 1.4, rot: 0, vr: 0.06 });
+ }
+ sndSlice();
+ cx = overlapL;
+ cw = overlap;
+ spawnParticles(overlapL + overlap / 2, blockWorldY(blocks.length - 1) - BLOCK_H / 2, current.hue, 8, false);
+ }
+
+ blocks.push({ x: cx, w: cw, hue: current.hue });
+ score++;
+ scoreEl.textContent = score;
+
+ // camera pans up as tower grows (keep top few visible)
+ var visibleRows = Math.max(4, Math.floor((H - 200) / BLOCK_H));
+ if (blocks.length > visibleRows) {
+ camY = (blocks.length - visibleRows) * BLOCK_H;
+ }
+
+ spawnCurrent();
+ }
+
+ function spawnParticles(x, worldY, hue, count, sparkle) {
+ for (var i = 0; i < count; i++) {
+ var a = Math.random() * Math.PI * 2;
+ var sp = (sparkle ? 2 + Math.random() * 4 : 1 + Math.random() * 2.5);
+ particles.push({
+ x: x, wy: worldY,
+ vx: Math.cos(a) * sp,
+ vy: Math.sin(a) * sp - (sparkle ? 1.5 : 0.5),
+ life: 1, hue: hue, size: 2 + Math.random() * (sparkle ? 4 : 2)
+ });
+ }
+ }
+
+ function gameOver() {
+ state = STATE_OVER;
+ sndOver();
+ if (score > best) {
+ best = score;
+ try { localStorage.setItem("abramsStackBest", String(best)); } catch (e) {}
+ bestEl.textContent = best;
+ }
+ titleEl.style.display = "none";
+ subtitleEl.style.display = "none";
+ bigEl.style.display = "block";
+ bigEl.textContent = "Height " + score;
+ ovBestEl.textContent = (score >= best && score > 0 ? "★ NEW BEST ★" : "Best " + best);
+ hintEl.textContent = "Tap / Click / Space to retry";
+ overlay.classList.remove("hidden");
+ }
+
+ function startGame() {
+ ensureAudio();
+ overlay.classList.add("hidden");
+ titleEl.style.display = "";
+ subtitleEl.style.display = "";
+ bigEl.style.display = "none";
+ reset();
+ state = STATE_PLAY;
+ }
+
+ // ---------- Input ----------
+ function onAction(e) {
+ if (e && e.target === muteBtn) return;
+ if (e) e.preventDefault();
+ ensureAudio();
+ if (state === STATE_PLAY) {
+ drop();
+ } else {
+ startGame();
+ }
+ }
+ window.addEventListener("pointerdown", onAction, { passive: false });
+ window.addEventListener("keydown", function (e) {
+ if (e.code === "Space" || e.key === " " || e.code === "Enter") {
+ e.preventDefault();
+ onAction();
+ }
+ });
+
+ // ---------- Render helpers ----------
+ function roundRect(x, y, w, h, r) {
+ r = Math.min(r, w / 2, h / 2);
+ ctx.beginPath();
+ ctx.moveTo(x + r, y);
+ ctx.arcTo(x + w, y, x + w, y + h, r);
+ ctx.arcTo(x + w, y + h, x, y + h, r);
+ ctx.arcTo(x, y + h, x, y, r);
+ ctx.arcTo(x, y, x + w, y, r);
+ ctx.closePath();
+ }
+
+ function drawBlock(x, screenY, w, hue, glow) {
+ var top = "hsl(" + hue + ",78%,64%)";
+ var bot = "hsl(" + hue + ",70%,44%)";
+ var grad = ctx.createLinearGradient(0, screenY, 0, screenY + BLOCK_H);
+ grad.addColorStop(0, top);
+ grad.addColorStop(1, bot);
+ if (glow) {
+ ctx.save();
+ ctx.shadowColor = "hsla(" + hue + ",90%,65%,0.9)";
+ ctx.shadowBlur = 26;
+ }
+ ctx.fillStyle = grad;
+ roundRect(x, screenY, w, BLOCK_H - 3, 7);
+ ctx.fill();
+ if (glow) ctx.restore();
+ // top highlight
+ ctx.fillStyle = "hsla(" + hue + ",90%,80%,0.5)";
+ roundRect(x + 3, screenY + 2, w - 6, 5, 3);
+ ctx.fill();
+ }
+
+ // ---------- Loop ----------
+ var lastT = performance.now();
+ function frame(now) {
+ var dt = Math.min((now - lastT) / 16.6667, 3); // normalized to 60fps
+ lastT = now;
+
+ // smooth camera
+ camYCur += (camY - camYCur) * Math.min(1, 0.12 * dt);
+ if (flash > 0) flash = Math.max(0, flash - 0.05 * dt);
+
+ // update moving block
+ if (state === STATE_PLAY && current) {
+ current.x += current.dir * speed * dt;
+ var prev = blocks[blocks.length - 1];
+ // bounce within a range slightly beyond the tower so it fully crosses
+ var leftLimit = -current.w * 0.9;
+ var rightLimit = W - current.w * 0.1;
+ if (current.x < leftLimit) { current.x = leftLimit; current.dir = 1; }
+ if (current.x > rightLimit) { current.x = rightLimit; current.dir = -1; }
+ }
+
+ // update particles
+ for (var i = particles.length - 1; i >= 0; i--) {
+ var p = particles[i];
+ p.x += p.vx * dt;
+ p.wy += p.vy * dt;
+ p.vy += 0.18 * dt;
+ p.life -= 0.02 * dt;
+ if (p.life <= 0) particles.splice(i, 1);
+ }
+ // update slices (falling debris)
+ for (var s = slices.length - 1; s >= 0; s--) {
+ var sl = slices[s];
+ sl.vy += 0.55 * dt;
+ sl.y += sl.vy * dt;
+ sl.x += sl.vx * dt;
+ sl.rot += sl.vr * dt;
+ if (sl.y - camYCur > H + 200) slices.splice(s, 1);
+ }
+
+ draw();
+ requestAnimationFrame(frame);
+ }
+
+ function draw() {
+ ctx.clearRect(0, 0, W, H);
+
+ // subtle drifting bg glow tied to height
+ var topHue = hueForLevel(blocks.length);
+ var g = ctx.createRadialGradient(W / 2, H * 0.2, 40, W / 2, H * 0.2, Math.max(W, H));
+ g.addColorStop(0, "hsla(" + topHue + ",55%,22%,0.55)");
+ g.addColorStop(1, "rgba(5,5,16,0)");
+ ctx.fillStyle = g;
+ ctx.fillRect(0, 0, W, H);
+
+ function worldToScreen(wy) { return wy - camYCur; }
+
+ // placed blocks
+ for (var i = 0; i < blocks.length; i++) {
+ var b = blocks[i];
+ var wy = blockWorldY(i); // world y of top of this block
+ var sy = worldToScreen(wy) - BLOCK_H;
+ if (sy > H + BLOCK_H || sy < -BLOCK_H) continue;
+ drawBlock(b.x, sy, b.w, b.hue, i === blocks.length - 1 && state === STATE_PLAY ? false : false);
+ }
+
+ // moving block
+ if (state === STATE_PLAY && current) {
+ var wyc = blockWorldY(blocks.length);
+ var syc = worldToScreen(wyc) - BLOCK_H;
+ drawBlock(current.x, syc, current.w, current.hue, true);
+ }
+
+ // slices (debris)
+ for (var s = 0; s < slices.length; s++) {
+ var sl = slices[s];
+ var sy2 = worldToScreen(sl.y);
+ ctx.save();
+ ctx.translate(sl.x + sl.w / 2, sy2 + BLOCK_H / 2);
+ ctx.rotate(sl.rot);
+ ctx.globalAlpha = 0.9;
+ var gr = ctx.createLinearGradient(0, -BLOCK_H / 2, 0, BLOCK_H / 2);
+ gr.addColorStop(0, "hsl(" + sl.hue + ",78%,60%)");
+ gr.addColorStop(1, "hsl(" + sl.hue + ",70%,42%)");
+ ctx.fillStyle = gr;
+ roundRect(-sl.w / 2, -BLOCK_H / 2, sl.w, BLOCK_H - 3, 6);
+ ctx.fill();
+ ctx.restore();
+ }
+
+ // particles
+ ctx.globalAlpha = 1;
+ for (var p = 0; p < particles.length; p++) {
+ var pa = particles[p];
+ ctx.globalAlpha = Math.max(0, pa.life);
+ ctx.fillStyle = "hsl(" + pa.hue + ",90%,68%)";
+ var py = pa.wy - camYCur;
+ ctx.beginPath();
+ ctx.arc(pa.x, py, pa.size, 0, Math.PI * 2);
+ ctx.fill();
+ }
+ ctx.globalAlpha = 1;
+
+ // perfect flash overlay
+ if (flash > 0) {
+ ctx.fillStyle = "rgba(255,255,255," + (flash * 0.18) + ")";
+ ctx.fillRect(0, 0, W, H);
+ }
+ }
+
+ requestAnimationFrame(frame);
+})();
+</script>
+</body>
+</html>
diff --git a/games/whack-a-mole/index.html b/games/whack-a-mole/index.html
new file mode 100644
index 0000000..3d0e27f
--- /dev/null
+++ b/games/whack-a-mole/index.html
@@ -0,0 +1,728 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8" />
+<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
+<title>Abrams Whack</title>
+<style>
+ :root{
+ --bg0:#080012;
+ --bg1:#12002a;
+ --neon:#00f0ff;
+ --neon2:#ff2bd6;
+ --neon3:#b14bff;
+ --good:#33ff9e;
+ --bad:#ff3b5c;
+ --gold:#ffd23f;
+ --ink:#eafcff;
+ }
+ *{box-sizing:border-box;margin:0;padding:0;-webkit-tap-highlight-color:transparent;}
+ html,body{height:100%;width:100%;overflow:hidden;}
+ body{
+ font-family:"Trebuchet MS","Segoe UI",system-ui,sans-serif;
+ background:
+ radial-gradient(circle at 20% 10%, rgba(177,75,255,.28), transparent 45%),
+ radial-gradient(circle at 85% 90%, rgba(0,240,255,.24), transparent 45%),
+ linear-gradient(160deg,var(--bg1),var(--bg0));
+ color:var(--ink);
+ user-select:none;
+ display:flex;
+ align-items:center;
+ justify-content:center;
+ }
+ #app{
+ position:relative;
+ width:100%;
+ height:100%;
+ max-width:900px;
+ max-height:900px;
+ margin:auto;
+ display:flex;
+ flex-direction:column;
+ padding:clamp(8px,2vmin,20px);
+ }
+
+ /* HUD */
+ #hud{
+ display:flex;
+ align-items:center;
+ justify-content:space-between;
+ gap:clamp(6px,1.5vmin,14px);
+ flex-wrap:wrap;
+ padding:clamp(6px,1.4vmin,12px) clamp(8px,2vmin,16px);
+ border-radius:16px;
+ background:rgba(10,0,25,.45);
+ border:1px solid rgba(0,240,255,.25);
+ box-shadow:0 0 24px rgba(0,240,255,.12) inset;
+ backdrop-filter:blur(4px);
+ }
+ .stat{
+ display:flex;
+ flex-direction:column;
+ align-items:center;
+ line-height:1.05;
+ min-width:0;
+ }
+ .stat .label{
+ font-size:clamp(8px,1.6vmin,11px);
+ letter-spacing:.14em;
+ text-transform:uppercase;
+ opacity:.7;
+ }
+ .stat .val{
+ font-size:clamp(16px,4vmin,30px);
+ font-weight:800;
+ font-variant-numeric:tabular-nums;
+ text-shadow:0 0 10px currentColor;
+ }
+ #score .val{color:var(--neon);}
+ #best .val{color:var(--gold);}
+ #time .val{color:var(--good);}
+ #combo .val{color:var(--neon2);}
+ #time.low .val{color:var(--bad); animation:pulse .6s infinite;}
+ @keyframes pulse{50%{opacity:.35;}}
+
+ #lives{display:flex;gap:4px;font-size:clamp(14px,3vmin,22px);}
+
+ #muteBtn{
+ cursor:pointer;
+ border:1px solid rgba(255,255,255,.3);
+ background:rgba(255,255,255,.06);
+ color:var(--ink);
+ border-radius:10px;
+ padding:clamp(4px,1vmin,8px) clamp(6px,1.4vmin,10px);
+ font-size:clamp(12px,2.6vmin,18px);
+ line-height:1;
+ transition:.15s;
+ }
+ #muteBtn:hover{background:rgba(0,240,255,.15);border-color:var(--neon);}
+
+ #title{
+ text-align:center;
+ margin:clamp(4px,1.2vmin,10px) 0;
+ font-size:clamp(18px,5vmin,40px);
+ font-weight:900;
+ letter-spacing:.06em;
+ background:linear-gradient(90deg,var(--neon),var(--neon2),var(--neon3),var(--neon));
+ background-size:300% 100%;
+ -webkit-background-clip:text;
+ background-clip:text;
+ color:transparent;
+ animation:shift 6s linear infinite;
+ filter:drop-shadow(0 0 12px rgba(255,43,214,.4));
+ }
+ @keyframes shift{to{background-position:300% 0;}}
+
+ /* Board */
+ #boardWrap{
+ flex:1;
+ display:flex;
+ align-items:center;
+ justify-content:center;
+ min-height:0;
+ }
+ #board{
+ display:grid;
+ grid-template-columns:repeat(3,1fr);
+ grid-template-rows:repeat(3,1fr);
+ gap:clamp(6px,2vmin,18px);
+ width:min(100%,min(72vh,560px));
+ aspect-ratio:1/1;
+ }
+ .hole{
+ position:relative;
+ border-radius:50%;
+ background:
+ radial-gradient(circle at 50% 38%, #1a0033 0%, #0c0018 60%, #05000d 100%);
+ box-shadow:
+ 0 0 0 2px rgba(0,240,255,.18),
+ inset 0 10px 26px rgba(0,0,0,.9),
+ inset 0 -6px 14px rgba(177,75,255,.18);
+ overflow:hidden;
+ cursor:pointer;
+ display:flex;
+ align-items:center;
+ justify-content:center;
+ transition:box-shadow .12s;
+ }
+ .hole::after{ /* rim glow */
+ content:"";
+ position:absolute;
+ inset:0;
+ border-radius:50%;
+ box-shadow:inset 0 6px 18px rgba(0,240,255,.10);
+ pointer-events:none;
+ }
+ .hole.hit{
+ box-shadow:
+ 0 0 24px 6px rgba(51,255,158,.6),
+ inset 0 10px 26px rgba(0,0,0,.9);
+ }
+ .hole.badhit{
+ box-shadow:
+ 0 0 24px 6px rgba(255,59,92,.7),
+ inset 0 10px 26px rgba(0,0,0,.9);
+ }
+
+ /* the pop target */
+ .target{
+ position:absolute;
+ left:50%;
+ bottom:-72%;
+ transform:translateX(-50%);
+ width:78%;
+ height:78%;
+ border-radius:50% 50% 46% 46%;
+ display:flex;
+ align-items:center;
+ justify-content:center;
+ font-size:clamp(22px,9vmin,64px);
+ transition:bottom .13s cubic-bezier(.2,.9,.3,1.4), transform .13s;
+ pointer-events:none;
+ will-change:bottom;
+ }
+ .target.up{ bottom:8%; }
+ .target.mole{
+ background:radial-gradient(circle at 50% 35%, var(--neon3), #4a007a 70%, #22003a);
+ box-shadow:0 0 18px rgba(177,75,255,.7), inset 0 -8px 12px rgba(0,0,0,.5), inset 0 6px 10px rgba(255,255,255,.15);
+ border:2px solid rgba(0,240,255,.5);
+ }
+ .target.bomb{
+ background:radial-gradient(circle at 50% 35%, #ff5a78, #b3001d 72%, #4a000c);
+ box-shadow:0 0 20px rgba(255,59,92,.85), inset 0 -8px 12px rgba(0,0,0,.5);
+ border:2px solid rgba(255,210,63,.7);
+ }
+ .target.gold{
+ background:radial-gradient(circle at 50% 35%, #fff2a8, var(--gold) 68%, #b98900);
+ box-shadow:0 0 26px rgba(255,210,63,.9), inset 0 -8px 12px rgba(0,0,0,.35);
+ border:2px solid #fff;
+ }
+ .target.whacked{ transform:translateX(-50%) scale(.55) rotate(8deg); opacity:0; transition:transform .18s, opacity .18s; }
+
+ /* floating score popup */
+ .pop{
+ position:absolute;
+ left:50%;
+ top:30%;
+ transform:translate(-50%,-50%);
+ font-weight:900;
+ font-size:clamp(14px,4.5vmin,30px);
+ pointer-events:none;
+ text-shadow:0 0 8px currentColor;
+ animation:floatUp .7s ease-out forwards;
+ z-index:5;
+ }
+ @keyframes floatUp{
+ 0%{opacity:0; transform:translate(-50%,-30%) scale(.6);}
+ 20%{opacity:1;}
+ 100%{opacity:0; transform:translate(-50%,-160%) scale(1.1);}
+ }
+
+ /* ripple on whack */
+ .ripple{
+ position:absolute;
+ inset:0;
+ border-radius:50%;
+ pointer-events:none;
+ animation:rip .45s ease-out forwards;
+ }
+ @keyframes rip{
+ from{box-shadow:0 0 0 0 rgba(255,255,255,.6);}
+ to{box-shadow:0 0 0 40px rgba(255,255,255,0);}
+ }
+
+ /* Overlay */
+ #overlay{
+ position:absolute;
+ inset:0;
+ display:flex;
+ align-items:center;
+ justify-content:center;
+ background:rgba(4,0,14,.82);
+ backdrop-filter:blur(6px);
+ z-index:20;
+ text-align:center;
+ padding:20px;
+ }
+ #overlay.hidden{display:none;}
+ .panel{
+ max-width:440px;
+ width:100%;
+ background:linear-gradient(160deg, rgba(28,0,54,.9), rgba(8,0,20,.9));
+ border:1px solid rgba(0,240,255,.4);
+ border-radius:22px;
+ padding:clamp(20px,4vmin,36px);
+ box-shadow:0 0 40px rgba(177,75,255,.35), inset 0 0 30px rgba(0,240,255,.08);
+ }
+ .panel h1{
+ font-size:clamp(26px,7vmin,46px);
+ font-weight:900;
+ letter-spacing:.04em;
+ background:linear-gradient(90deg,var(--neon),var(--neon2));
+ -webkit-background-clip:text;background-clip:text;color:transparent;
+ filter:drop-shadow(0 0 10px rgba(0,240,255,.5));
+ margin-bottom:8px;
+ }
+ .panel p{opacity:.85; font-size:clamp(12px,2.6vmin,15px); line-height:1.5; margin-bottom:16px;}
+ .legend{
+ display:flex;justify-content:center;gap:16px;flex-wrap:wrap;
+ margin-bottom:18px;font-size:clamp(11px,2.4vmin,14px);
+ }
+ .legend span{display:inline-flex;align-items:center;gap:6px;}
+ .dot{width:16px;height:16px;border-radius:50%;display:inline-block;box-shadow:0 0 8px currentColor;}
+ .dot.m{background:var(--neon3);color:var(--neon3);}
+ .dot.b{background:var(--bad);color:var(--bad);}
+ .dot.g{background:var(--gold);color:var(--gold);}
+ #finalScore{
+ font-size:clamp(28px,8vmin,52px);
+ font-weight:900;
+ color:var(--neon);
+ text-shadow:0 0 16px var(--neon);
+ margin:6px 0;
+ font-variant-numeric:tabular-nums;
+ }
+ #finalBest{color:var(--gold);font-weight:700;margin-bottom:16px;}
+ #newBest{
+ color:var(--gold);font-weight:900;letter-spacing:.1em;
+ animation:pulse .8s infinite; margin-bottom:8px; font-size:clamp(13px,3vmin,18px);
+ }
+ #newBest.hidden{display:none;}
+ .btn{
+ cursor:pointer;
+ border:none;
+ border-radius:14px;
+ padding:clamp(12px,2.6vmin,16px) clamp(24px,5vmin,40px);
+ font-size:clamp(15px,3.4vmin,22px);
+ font-weight:800;
+ letter-spacing:.05em;
+ color:#08001a;
+ background:linear-gradient(90deg,var(--neon),var(--neon2));
+ box-shadow:0 6px 24px rgba(255,43,214,.4);
+ transition:transform .1s, box-shadow .15s;
+ }
+ .btn:hover{transform:translateY(-2px); box-shadow:0 10px 30px rgba(0,240,255,.5);}
+ .btn:active{transform:translateY(1px) scale(.98);}
+
+ /* screen shake */
+ @keyframes shake{
+ 0%,100%{transform:translate(0,0);}
+ 25%{transform:translate(-4px,2px);}
+ 50%{transform:translate(4px,-2px);}
+ 75%{transform:translate(-3px,-1px);}
+ }
+ #app.shake{animation:shake .28s;}
+</style>
+</head>
+<body>
+<div id="app">
+ <div id="hud">
+ <div id="score" class="stat"><span class="label">Score</span><span class="val">0</span></div>
+ <div id="combo" class="stat"><span class="label">Combo</span><span class="val">x1</span></div>
+ <div id="title" style="flex:1;">ABRAMS WHACK</div>
+ <div id="time" class="stat"><span class="label">Time</span><span class="val">45</span></div>
+ <div id="best" class="stat"><span class="label">Best</span><span class="val">0</span></div>
+ <div id="livesWrap" class="stat"><span class="label">Lives</span><span id="lives" class="val"></span></div>
+ <button id="muteBtn" title="Mute / unmute">🔊</button>
+ </div>
+
+ <div id="boardWrap">
+ <div id="board"></div>
+ </div>
+
+ <div id="overlay">
+ <div class="panel">
+ <h1 id="ovTitle">ABRAMS WHACK</h1>
+ <div id="newBest" class="hidden">★ NEW HIGH SCORE ★</div>
+ <div id="finalScore" style="display:none;">0</div>
+ <div id="finalBest" style="display:none;"></div>
+ <p id="ovDesc">Whack the neon moles before they vanish. Hit fast for combo multipliers.
+ Dodge the red bombs — they cost a life. Grab the gold ones for big points.</p>
+ <div class="legend">
+ <span><i class="dot m"></i>Mole +pts</span>
+ <span><i class="dot g"></i>Gold jackpot</span>
+ <span><i class="dot b"></i>Bomb −life</span>
+ </div>
+ <button id="startBtn" class="btn">START</button>
+ </div>
+ </div>
+</div>
+
+<script>
+(function(){
+ "use strict";
+
+ // ---------- Config ----------
+ const ROUND_TIME = 45; // seconds
+ const HOLES = 9; // 3x3
+ const START_LIVES = 3;
+ const BASE_POINTS = 10;
+ const GOLD_POINTS = 50;
+ const MISS_PENALTY = 5; // missed mole
+ const BOMB_PENALTY = 15; // whacking a bomb
+ const COMBO_WINDOW = 1400; // ms to keep combo alive between hits
+ const MAX_COMBO = 8;
+
+ // ---------- State ----------
+ let board, holes = [];
+ let score = 0, best = 0, timeLeft = ROUND_TIME, lives = START_LIVES;
+ let combo = 0, lastHitAt = 0;
+ let running = false;
+ let spawnTimer = null, tickTimer = null;
+ let elapsed = 0; // seconds into round (for difficulty ramp)
+ let muted = false;
+
+ // per-hole occupancy: {kind, el, timeout, whacked}
+ const occ = new Array(HOLES).fill(null);
+
+ // ---------- DOM ----------
+ const $ = s => document.querySelector(s);
+ const app = $("#app");
+ const scoreVal = $("#score .val");
+ const comboVal = $("#combo .val");
+ const timeStat = $("#time");
+ const timeVal = $("#time .val");
+ const bestVal = $("#best .val");
+ const livesEl = $("#lives");
+ const overlay = $("#overlay");
+ const ovTitle = $("#ovTitle");
+ const ovDesc = $("#ovDesc");
+ const finalScore = $("#finalScore");
+ const finalBest = $("#finalBest");
+ const newBest = $("#newBest");
+ const startBtn = $("#startBtn");
+ const muteBtn = $("#muteBtn");
+
+ // ---------- Persistence ----------
+ const LS_KEY = "abramsWhack.best";
+ try { best = parseInt(localStorage.getItem(LS_KEY) || "0", 10) || 0; } catch(e){ best = 0; }
+ try { muted = localStorage.getItem("abramsWhack.muted") === "1"; } catch(e){}
+ bestVal.textContent = best;
+ muteBtn.textContent = muted ? "🔇" : "🔊";
+
+ // ---------- Audio (procedural Web Audio) ----------
+ let actx = null;
+ function audio(){
+ if (muted) return null;
+ if (!actx){
+ try { actx = new (window.AudioContext || window.webkitAudioContext)(); }
+ catch(e){ return null; }
+ }
+ if (actx.state === "suspended") actx.resume();
+ return actx;
+ }
+ function tone(freq, dur, type, gain, slideTo){
+ const ac = audio(); if(!ac) return;
+ const o = ac.createOscillator();
+ const g = ac.createGain();
+ o.type = type || "sine";
+ o.frequency.setValueAtTime(freq, ac.currentTime);
+ if (slideTo) o.frequency.exponentialRampToValueAtTime(slideTo, ac.currentTime + dur);
+ g.gain.setValueAtTime(0.0001, ac.currentTime);
+ g.gain.exponentialRampToValueAtTime(gain || 0.2, ac.currentTime + 0.01);
+ g.gain.exponentialRampToValueAtTime(0.0001, ac.currentTime + dur);
+ o.connect(g); g.connect(ac.destination);
+ o.start(); o.stop(ac.currentTime + dur + 0.02);
+ }
+ function noiseBurst(dur, gain){
+ const ac = audio(); if(!ac) return;
+ const n = Math.floor(ac.sampleRate * dur);
+ const buf = ac.createBuffer(1, n, ac.sampleRate);
+ const data = buf.getChannelData(0);
+ for (let i=0;i<n;i++) data[i] = (Math.random()*2-1) * (1 - i/n);
+ const src = ac.createBufferSource(); src.buffer = buf;
+ const g = ac.createGain(); g.gain.value = gain || 0.25;
+ const f = ac.createBiquadFilter(); f.type="highpass"; f.frequency.value=800;
+ src.connect(f); f.connect(g); g.connect(ac.destination);
+ src.start();
+ }
+ const sfx = {
+ whack(c){ tone(420 + Math.min(c,MAX_COMBO)*55, 0.12, "square", 0.22, 180); },
+ gold(){ tone(660,0.1,"triangle",0.25,1320); setTimeout(()=>tone(990,0.14,"triangle",0.22,1760),70); },
+ bomb(){ noiseBurst(0.28,0.35); tone(120,0.3,"sawtooth",0.3,40); },
+ miss(){ tone(200,0.14,"sine",0.15,120); },
+ pop(){ tone(300,0.06,"sine",0.08,500); },
+ over(){ tone(440,0.2,"triangle",0.2,110); setTimeout(()=>tone(220,0.4,"triangle",0.2,80),160); },
+ start(){ tone(330,0.1,"triangle",0.2,660); setTimeout(()=>tone(660,0.14,"triangle",0.2,990),100); }
+ };
+
+ // ---------- Build board ----------
+ function buildBoard(){
+ board = $("#board");
+ board.innerHTML = "";
+ holes = [];
+ for (let i=0;i<HOLES;i++){
+ const h = document.createElement("div");
+ h.className = "hole";
+ h.dataset.i = i;
+ h.addEventListener("pointerdown", onHolePress, {passive:false});
+ board.appendChild(h);
+ holes.push(h);
+ }
+ }
+
+ // ---------- Difficulty ramp ----------
+ // returns {upTime, gap} in ms. As elapsed grows, moles stay up shorter & spawn faster.
+ function difficulty(){
+ const t = elapsed / ROUND_TIME; // 0..1
+ const upTime = Math.max(520, 1150 - t*760); // 1150 -> 520 ms visible
+ const gap = Math.max(340, 900 - t*620); // spawn interval
+ return { upTime, gap };
+ }
+
+ function chooseKind(){
+ // gold rare, bomb scales up a bit with difficulty
+ const t = elapsed / ROUND_TIME;
+ const r = Math.random();
+ if (r < 0.06) return "gold";
+ if (r < 0.06 + 0.14 + t*0.12) return "bomb"; // 14% -> ~26%
+ return "mole";
+ }
+
+ // ---------- Spawn ----------
+ function spawn(){
+ if (!running) return;
+ // find a free hole
+ const free = [];
+ for (let i=0;i<HOLES;i++) if (!occ[i]) free.push(i);
+ if (free.length){
+ // sometimes spawn 2 at once late-game
+ const t = elapsed / ROUND_TIME;
+ const count = (t > 0.5 && Math.random() < 0.3 && free.length > 1) ? 2 : 1;
+ for (let k=0;k<count && free.length;k++){
+ const idx = free.splice(Math.floor(Math.random()*free.length),1)[0];
+ placeTarget(idx);
+ }
+ }
+ const { gap } = difficulty();
+ spawnTimer = setTimeout(spawn, gap + Math.random()*120);
+ }
+
+ function placeTarget(i){
+ const kind = chooseKind();
+ const { upTime } = difficulty();
+ const el = document.createElement("div");
+ el.className = "target " + kind;
+ el.textContent = kind === "bomb" ? "💣" : (kind === "gold" ? "⭐" : "🐹");
+ holes[i].appendChild(el);
+ sfx.pop();
+ // force reflow then raise
+ // eslint-disable-next-line no-unused-expressions
+ el.offsetHeight;
+ requestAnimationFrame(()=> el.classList.add("up"));
+
+ const record = { kind, el, whacked:false, timeout:null };
+ occ[i] = record;
+
+ record.timeout = setTimeout(()=>{
+ if (record.whacked) return;
+ // duck back down
+ el.classList.remove("up");
+ // a mole that escaped un-whacked = miss penalty (bombs escaping are fine)
+ if (kind === "mole" || kind === "gold"){
+ onMissed();
+ }
+ setTimeout(()=>{ if (el.parentNode) el.parentNode.removeChild(el); }, 180);
+ if (occ[i] === record) occ[i] = null;
+ }, upTime);
+ }
+
+ // ---------- Interaction ----------
+ function onHolePress(e){
+ e.preventDefault();
+ if (!running) return;
+ const i = +this.dataset.i;
+ const rec = occ[i];
+ if (!rec || rec.whacked){
+ return; // whacked empty hole -> nothing (no penalty for empties)
+ }
+ whack(i, rec, this);
+ }
+
+ function whack(i, rec, holeEl){
+ rec.whacked = true;
+ clearTimeout(rec.timeout);
+ rec.el.classList.add("whacked");
+ ripple(holeEl);
+ setTimeout(()=>{ if (rec.el.parentNode) rec.el.parentNode.removeChild(rec.el); }, 200);
+ if (occ[i] === rec) occ[i] = null;
+
+ const now = performance.now();
+ if (rec.kind === "bomb"){
+ // penalty + life loss
+ combo = 0; lastHitAt = 0;
+ score = Math.max(0, score - BOMB_PENALTY);
+ loseLife();
+ holeEl.classList.add("badhit");
+ setTimeout(()=>holeEl.classList.remove("badhit"),200);
+ floatPop(holeEl, "−"+BOMB_PENALTY, "var(--bad)");
+ sfx.bomb();
+ shake();
+ updateHUD();
+ return;
+ }
+
+ // good hit -> combo
+ if (now - lastHitAt < COMBO_WINDOW) combo = Math.min(MAX_COMBO, combo + 1);
+ else combo = 1;
+ lastHitAt = now;
+
+ const mult = combo;
+ let gained;
+ if (rec.kind === "gold"){
+ gained = GOLD_POINTS * mult;
+ sfx.gold();
+ floatPop(holeEl, "+"+gained, "var(--gold)");
+ } else {
+ gained = BASE_POINTS * mult;
+ sfx.whack(combo);
+ floatPop(holeEl, "+"+gained, "var(--good)");
+ }
+ score += gained;
+ holeEl.classList.add("hit");
+ setTimeout(()=>holeEl.classList.remove("hit"),160);
+ updateHUD();
+ }
+
+ function onMissed(){
+ combo = 0; lastHitAt = 0;
+ score = Math.max(0, score - MISS_PENALTY);
+ sfx.miss();
+ updateHUD();
+ }
+
+ function loseLife(){
+ lives = Math.max(0, lives - 1);
+ renderLives();
+ if (lives <= 0) endRound();
+ }
+
+ // ---------- FX ----------
+ function ripple(holeEl){
+ const r = document.createElement("div");
+ r.className = "ripple";
+ holeEl.appendChild(r);
+ setTimeout(()=>{ if (r.parentNode) r.parentNode.removeChild(r); }, 460);
+ }
+ function floatPop(holeEl, text, color){
+ const p = document.createElement("div");
+ p.className = "pop";
+ p.textContent = text;
+ p.style.color = color;
+ holeEl.appendChild(p);
+ setTimeout(()=>{ if (p.parentNode) p.parentNode.removeChild(p); }, 720);
+ }
+ function shake(){
+ app.classList.remove("shake");
+ // reflow
+ void app.offsetWidth;
+ app.classList.add("shake");
+ }
+
+ // ---------- HUD ----------
+ function updateHUD(){
+ scoreVal.textContent = score;
+ comboVal.textContent = "x" + Math.max(1, combo);
+ comboVal.style.transform = combo > 1 ? "scale(1.15)" : "scale(1)";
+ }
+ function renderLives(){
+ livesEl.textContent = "❤".repeat(lives) + "♡".repeat(Math.max(0,START_LIVES-lives));
+ }
+ function renderTime(){
+ timeVal.textContent = timeLeft;
+ if (timeLeft <= 10) timeStat.classList.add("low");
+ else timeStat.classList.remove("low");
+ }
+
+ // ---------- Round control ----------
+ function startRound(){
+ // reset
+ score = 0; combo = 0; lastHitAt = 0; lives = START_LIVES;
+ timeLeft = ROUND_TIME; elapsed = 0; running = true;
+ clearAll();
+ updateHUD(); renderLives(); renderTime();
+ bestVal.textContent = best;
+ overlay.classList.add("hidden");
+ sfx.start();
+
+ // ticking clock
+ tickTimer = setInterval(()=>{
+ timeLeft--; elapsed++;
+ renderTime();
+ if (timeLeft <= 0) endRound();
+ }, 1000);
+
+ // start spawning
+ spawn();
+ }
+
+ function endRound(){
+ if (!running) return;
+ running = false;
+ clearTimeout(spawnTimer);
+ clearInterval(tickTimer);
+ clearAll();
+ sfx.over();
+
+ // high score
+ const isNew = score > best;
+ if (isNew){
+ best = score;
+ try { localStorage.setItem(LS_KEY, String(best)); } catch(e){}
+ }
+ bestVal.textContent = best;
+
+ // show overlay in game-over mode
+ ovTitle.textContent = "GAME OVER";
+ ovDesc.style.display = "none";
+ finalScore.style.display = "block";
+ finalScore.textContent = score;
+ finalBest.style.display = "block";
+ finalBest.textContent = "Best: " + best;
+ newBest.classList.toggle("hidden", !isNew);
+ startBtn.textContent = "PLAY AGAIN";
+ overlay.classList.remove("hidden");
+ }
+
+ function clearAll(){
+ for (let i=0;i<HOLES;i++){
+ const rec = occ[i];
+ if (rec){ clearTimeout(rec.timeout); if (rec.el && rec.el.parentNode) rec.el.parentNode.removeChild(rec.el); }
+ occ[i] = null;
+ }
+ holes.forEach(h=>{
+ h.classList.remove("hit","badhit");
+ // strip leftover pops/ripples
+ Array.from(h.querySelectorAll(".pop,.ripple,.target")).forEach(n=>n.remove());
+ });
+ }
+
+ // ---------- Mute ----------
+ muteBtn.addEventListener("click", (e)=>{
+ e.stopPropagation();
+ muted = !muted;
+ muteBtn.textContent = muted ? "🔇" : "🔊";
+ try { localStorage.setItem("abramsWhack.muted", muted ? "1":"0"); } catch(e){}
+ if (!muted) audio(); // wake context
+ });
+
+ // ---------- Start button ----------
+ startBtn.addEventListener("click", ()=>{ audio(); startRound(); });
+
+ // keyboard: space/enter to start when not running
+ window.addEventListener("keydown", (e)=>{
+ if (!running && (e.code === "Space" || e.code === "Enter")){
+ e.preventDefault(); audio(); startRound();
+ }
+ });
+
+ // prevent iOS double-tap zoom on board taps handled via pointerdown already
+ document.addEventListener("gesturestart", e=>e.preventDefault());
+
+ // ---------- Init ----------
+ buildBoard();
+ updateHUD(); renderLives(); renderTime();
+
+})();
+</script>
+</body>
+</html>
← 9a1b82f Add 3 newest Model Arena game winners: DVD Bounce, Verlet Ro
·
back to Games Agentabrams
·
Fix stale e2e: decouple cube checks from list order (deep-li f4e6a87 →