← back to Games Agentabrams
auto-save: 2026-07-24T20:29:36 (10 files) — games/chomp/ games/gems/ games/hopper/ games/invaders/ games/missile/
423a750b39db7b62f5155f85737daea443297b0f · 2026-07-24 20:29:43 -0700 · Steve Abrams
Files touched
A games/chomp/index.htmlA games/gems/index.htmlA games/hopper/index.htmlA games/invaders/index.htmlA games/missile/index.htmlA games/racer/index.htmlA games/sokoban/index.htmlA games/sudoku/index.htmlA games/tron/index.htmlA games/wordle/index.html
Diff
commit 423a750b39db7b62f5155f85737daea443297b0f
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Fri Jul 24 20:29:43 2026 -0700
auto-save: 2026-07-24T20:29:36 (10 files) — games/chomp/ games/gems/ games/hopper/ games/invaders/ games/missile/
---
games/chomp/index.html | 855 ++++++++++++++++++++++++++++++++++++++++++++
games/gems/index.html | 755 +++++++++++++++++++++++++++++++++++++++
games/hopper/index.html | 790 ++++++++++++++++++++++++++++++++++++++++
games/invaders/index.html | 891 ++++++++++++++++++++++++++++++++++++++++++++++
games/missile/index.html | 886 +++++++++++++++++++++++++++++++++++++++++++++
games/racer/index.html | 882 +++++++++++++++++++++++++++++++++++++++++++++
games/sokoban/index.html | 712 ++++++++++++++++++++++++++++++++++++
games/sudoku/index.html | 799 +++++++++++++++++++++++++++++++++++++++++
games/tron/index.html | 454 +++++++++++++++++++++++
games/wordle/index.html | 477 +++++++++++++++++++++++++
10 files changed, 7501 insertions(+)
diff --git a/games/chomp/index.html b/games/chomp/index.html
new file mode 100644
index 0000000..e2d9abc
--- /dev/null
+++ b/games/chomp/index.html
@@ -0,0 +1,855 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
+<title>Abrams Chomp</title>
+<style>
+ :root {
+ --bg:#0a0e14; --grid:#070a10; --wall:#2563eb; --wall-glow:#60a5fa;
+ --player:#fde047; --pellet:#f5d7a1; --power:#fbbf24; --ink:#e5e7eb;
+ }
+ * { box-sizing:border-box; margin:0; padding:0; }
+ html,body { height:100%; }
+ body {
+ background:radial-gradient(1200px 600px at 50% -10%, #16202e, var(--bg));
+ color:var(--ink); font:15px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;
+ display:flex; flex-direction:column; align-items:center; justify-content:center;
+ min-height:100%; gap:10px; padding:12px; overscroll-behavior:none; touch-action:none;
+ }
+ h1 { font-size:14px; letter-spacing:.28em; text-transform:uppercase; color:#93c5fd; font-weight:600; }
+ .hud { display:flex; gap:18px; font-variant-numeric:tabular-nums; font-size:13px; color:#9aa4b2; }
+ .hud b { color:var(--ink); font-weight:600; }
+ #wrap { position:relative; }
+ canvas { background:var(--grid); border-radius:12px; box-shadow:0 10px 40px rgba(0,0,0,.5); display:block; }
+ .overlay {
+ position:absolute; inset:0; display:flex; flex-direction:column; align-items:center; justify-content:center;
+ gap:10px; text-align:center; background:rgba(8,12,18,.78); border-radius:12px; backdrop-filter:blur(2px);
+ }
+ .overlay.hide { display:none; }
+ .big { font-size:22px; font-weight:700; letter-spacing:.02em; }
+ .sub { font-size:13px; color:#9aa4b2; max-width:300px; }
+ .tag { font-size:11px; color:#93c5fd; letter-spacing:.2em; text-transform:uppercase; }
+ button.play {
+ margin-top:4px; padding:10px 22px; border:0; border-radius:999px; cursor:pointer;
+ background:linear-gradient(180deg,#facc15,#eab308); color:#1a1500; font-weight:700; font-size:14px;
+ }
+ .btns { position:absolute; top:8px; right:10px; display:flex; gap:6px; z-index:5; }
+ .btns button { width:30px; height:30px; border-radius:8px; border:1px solid #2a3441; background:#141b25; color:#cbd5e1; cursor:pointer; font-size:14px; }
+ .hint { font-size:11px; color:#5b6673; }
+ /* D-pad */
+ #dpad {
+ display:grid; grid-template-columns:repeat(3,52px); grid-template-rows:repeat(3,52px);
+ gap:6px; margin-top:4px; touch-action:none;
+ }
+ #dpad button {
+ border:1px solid #223; border-radius:10px; background:#111926; color:#93c5fd;
+ font-size:20px; cursor:pointer; -webkit-user-select:none; user-select:none; touch-action:none;
+ }
+ #dpad button:active { background:#1b2740; }
+ #dpad .sp { visibility:hidden; }
+ @media (hover:hover) and (pointer:fine) { #dpad { display:none; } }
+</style>
+</head>
+<body>
+ <h1>Abrams Chomp</h1>
+ <div class="hud">
+ <span>Score <b id="score">0</b></span>
+ <span>Lives <b id="lives">3</b></span>
+ <span>Best <b id="best">0</b></span>
+ <span>Lvl <b id="level">1</b></span>
+ </div>
+ <div id="wrap">
+ <canvas id="c" width="448" height="496"></canvas>
+ <div class="btns"><button id="mute" title="Sound">🔊</button></div>
+ <div id="overlay" class="overlay">
+ <div class="tag">Neon Maze Chomper</div>
+ <div class="big" id="ovTitle">ABRAMS CHOMP</div>
+ <div class="sub" id="ovSub">Eat every pellet. Grab a power pellet to hunt the ghosts. Arrow keys / WASD or the D-pad.</div>
+ <button class="play" id="playBtn">PLAY</button>
+ </div>
+ </div>
+ <div id="dpad">
+ <button class="sp"></button><button data-dir="up">▲</button><button class="sp"></button>
+ <button data-dir="left">◀</button><button class="sp"></button><button data-dir="right">▶</button>
+ <button class="sp"></button><button data-dir="down">▼</button><button class="sp"></button>
+ </div>
+ <div class="hint">Arrow keys / WASD to steer · P to pause</div>
+
+<script>
+(function () {
+ "use strict";
+
+ // ---- Maze layout (hand-authored ASCII) ----
+ // # wall, . pellet, o power pellet, space empty, - ghost house door, T tunnel row cell
+ // Grid is 28 wide x 31 tall (classic proportions).
+ var MAZE = [
+ "############################",
+ "#............##............#",
+ "#.####.#####.##.#####.####.#",
+ "#o####.#####.##.#####.####o#",
+ "#.####.#####.##.#####.####.#",
+ "#..........................#",
+ "#.####.##.########.##.####.#",
+ "#.####.##.########.##.####.#",
+ "#......##....##....##......#",
+ "######.##### ## #####.######",
+ "######.##### ## #####.######",
+ "######.## ##.######",
+ "######.## ###--### ##.######",
+ "######.## # # ##.######",
+ "T . # # . T",
+ "######.## # # ##.######",
+ "######.## ######## ##.######",
+ "######.## ##.######",
+ "######.## ######## ##.######",
+ "######.## ######## ##.######",
+ "#............##............#",
+ "#.####.#####.##.#####.####.#",
+ "#.####.#####.##.#####.####.#",
+ "#o..##....... .......##..o#",
+ "###.##.##.########.##.##.###",
+ "###.##.##.########.##.##.###",
+ "#......##....##....##......#",
+ "#.##########.##.##########.#",
+ "#.##########.##.##########.#",
+ "#..........................#",
+ "############################"
+ ];
+
+ var ROWS = MAZE.length, COLS = MAZE[0].length;
+ var TILE = 16;
+
+ var canvas = document.getElementById("c");
+ var ctx = canvas.getContext("2d");
+ canvas.width = COLS * TILE; // 448
+ canvas.height = ROWS * TILE; // 496
+
+ // ---- responsive scaling to fit iframe ----
+ function fit() {
+ try {
+ var wrap = document.getElementById("wrap");
+ var pad = 24;
+ var maxW = Math.max(160, (window.innerWidth || canvas.width) - pad);
+ var availH = (window.innerHeight || canvas.height) - 200; // leave room for hud/dpad
+ var maxH = Math.max(160, availH);
+ var scale = Math.min(maxW / canvas.width, maxH / canvas.height, 1.4);
+ if (!isFinite(scale) || scale <= 0) scale = 1;
+ canvas.style.width = Math.round(canvas.width * scale) + "px";
+ canvas.style.height = Math.round(canvas.height * scale) + "px";
+ } catch (e) { /* no-op */ }
+ }
+ window.addEventListener("resize", fit);
+
+ // ---- grid state ----
+ var grid = []; // char per cell (walls/paths)
+ var pellets = []; // 0 none, 1 pellet, 2 power
+ var pelletCount = 0;
+
+ function isWall(c, r) {
+ if (r < 0 || r >= ROWS) return true;
+ if (c < 0 || c >= COLS) return false; // horizontal tunnels are open
+ return grid[r][c] === "#";
+ }
+
+ function buildGrid() {
+ grid = [];
+ pellets = [];
+ pelletCount = 0;
+ for (var r = 0; r < ROWS; r++) {
+ var gr = [], pr = [];
+ var line = MAZE[r];
+ for (var c = 0; c < COLS; c++) {
+ var ch = line[c] || " ";
+ var pel = 0;
+ if (ch === ".") { pel = 1; pelletCount++; }
+ else if (ch === "o") { pel = 2; pelletCount++; }
+ gr.push(ch === "#" ? "#" : (ch === "-" ? "-" : " "));
+ pr.push(pel);
+ }
+ grid.push(gr);
+ pellets.push(pr);
+ }
+ }
+
+ // ---- entities ----
+ var DIRS = {
+ up: { x: 0, y: -1 },
+ down: { x: 0, y: 1 },
+ left: { x: -1, y: 0 },
+ right: { x: 1, y: 0 },
+ none: { x: 0, y: 0 }
+ };
+
+ var player = null;
+ var ghosts = [];
+ var GHOST_COLORS = ["#ef4444", "#f9a8d4", "#22d3ee", "#fb923c"];
+ var GHOST_NAMES = ["blinky", "pinky", "inky", "clyde"];
+
+ function makePlayer() {
+ return {
+ c: 13, r: 23,
+ px: 13 * TILE, py: 23 * TILE,
+ dir: "left", next: "left",
+ speed: 96, // px / second
+ mouth: 0
+ };
+ }
+
+ // ghost home center (in tiles) and spawn positions inside the house
+ var HOME = { c: 13, r: 14 };
+ var GHOST_SPAWNS = [
+ { c: 13, r: 11 }, // starts outside on top
+ { c: 13, r: 14 },
+ { c: 12, r: 14 },
+ { c: 14, r: 14 }
+ ];
+
+ function makeGhost(i) {
+ var sp = GHOST_SPAWNS[i];
+ return {
+ name: GHOST_NAMES[i],
+ color: GHOST_COLORS[i],
+ c: sp.c, r: sp.r,
+ px: sp.c * TILE, py: sp.r * TILE,
+ dir: (i === 0 ? "left" : "up"),
+ speed: 84 + (level - 1) * 4, // px / second, ramps with level
+ state: "scatter", // scatter | chase | frightened | eaten
+ inHouse: i !== 0,
+ releaseAt: i * 2.2, // seconds staggered release
+ scatterTarget: [
+ { c: COLS - 2, r: 0 }, { c: 1, r: 0 },
+ { c: COLS - 2, r: ROWS - 1 }, { c: 1, r: ROWS - 1 }
+ ][i]
+ };
+ }
+
+ // ---- game state ----
+ var state = "menu"; // menu | play | paused | dead | over | win
+ var score = 0, lives = 3, level = 1, best = 0;
+ var frightTimer = 0; // seconds remaining of frightened mode
+ var ghostEatChain = 0; // for 200/400/800/1600 scoring
+ var modeTimer = 0; // scatter/chase alternation
+ var modePhase = 0;
+ var levelElapsed = 0; // for staggered ghost release
+ var deathAnim = 0;
+ var readyTimer = 0;
+
+ var BEST_KEY = "abrams-chomp-best";
+ try {
+ var stored = localStorage.getItem(BEST_KEY);
+ if (stored != null && !isNaN(parseInt(stored, 10))) best = parseInt(stored, 10);
+ } catch (e) { best = 0; }
+
+ // scatter/chase schedule (seconds): scatter, chase, scatter, chase...
+ var MODE_SCHEDULE = [7, 20, 7, 20, 5, 20, 5, 99999];
+
+ // ---- audio (lazy, must never throw) ----
+ var audioCtx = null, muted = false;
+ function ensureAudio() {
+ if (audioCtx) return;
+ try {
+ var AC = window.AudioContext || window.webkitAudioContext;
+ if (AC) audioCtx = new AC();
+ } catch (e) { audioCtx = null; }
+ }
+ function blip(freq, dur, type, vol) {
+ if (muted || !audioCtx) return;
+ try {
+ var o = audioCtx.createOscillator();
+ var g = audioCtx.createGain();
+ o.type = type || "square";
+ o.frequency.value = freq;
+ g.gain.value = vol || 0.05;
+ o.connect(g); g.connect(audioCtx.destination);
+ var t = audioCtx.currentTime;
+ g.gain.setValueAtTime(g.gain.value, t);
+ g.gain.exponentialRampToValueAtTime(0.0001, t + (dur || 0.08));
+ o.start(t); o.stop(t + (dur || 0.08));
+ } catch (e) { /* ignore */ }
+ }
+
+ // ---- helpers ----
+ // c/r are the entity's CURRENT tile (the tile its top-left snaps to at a center).
+ // px/py are the top-left pixel; the entity is "centered" when px==c*TILE & py==r*TILE.
+ function canMove(c, r, dir, isGhost, allowDoor) {
+ var d = DIRS[dir];
+ var nc = c + d.x, nr = r + d.y;
+ if (nr < 0 || nr >= ROWS) return false;
+ // tunnel columns
+ if (nc < 0 || nc >= COLS) return true;
+ var cell = grid[nr][nc];
+ if (cell === "#") return false;
+ if (cell === "-") return !!allowDoor; // door only for ghosts entering/leaving
+ return true;
+ }
+
+ function opposite(dir) {
+ return { up: "down", down: "up", left: "right", right: "left", none: "none" }[dir];
+ }
+
+ // ---- input ----
+ var keymap = {
+ ArrowUp: "up", ArrowDown: "down", ArrowLeft: "left", ArrowRight: "right",
+ w: "up", s: "down", a: "left", d: "right",
+ W: "up", S: "down", A: "left", D: "right"
+ };
+ window.addEventListener("keydown", function (e) {
+ var k = e.key;
+ if (k === " " || k === "ArrowUp" || k === "ArrowDown" || k === "ArrowLeft" || k === "ArrowRight") {
+ e.preventDefault();
+ }
+ if (k === "p" || k === "P") { togglePause(); return; }
+ if ((k === "Enter" || k === " ") && (state === "menu" || state === "over" || state === "win")) {
+ startGame(); return;
+ }
+ var dir = keymap[k];
+ if (dir && player) { player.next = dir; ensureAudio(); }
+ }, { passive: false });
+
+ function setDir(dir) {
+ if (player && (state === "play")) { player.next = dir; }
+ ensureAudio();
+ }
+ var dpad = document.getElementById("dpad");
+ if (dpad) {
+ dpad.querySelectorAll("button[data-dir]").forEach(function (b) {
+ var dir = b.getAttribute("data-dir");
+ b.addEventListener("touchstart", function (e) { e.preventDefault(); setDir(dir); }, { passive: false });
+ b.addEventListener("mousedown", function (e) { e.preventDefault(); setDir(dir); });
+ });
+ }
+
+ document.getElementById("mute").addEventListener("click", function () {
+ muted = !muted;
+ this.innerHTML = muted ? "🔇" : "🔊";
+ ensureAudio();
+ });
+
+ document.getElementById("playBtn").addEventListener("click", function () {
+ ensureAudio(); startGame();
+ });
+
+ function togglePause() {
+ if (state === "play") { state = "paused"; showOverlay("PAUSED", "Press P to resume.", false); }
+ else if (state === "paused") { hideOverlay(); state = "play"; }
+ }
+
+ // ---- overlay ----
+ var overlay = document.getElementById("overlay");
+ function showOverlay(title, sub, showBtn) {
+ document.getElementById("ovTitle").textContent = title;
+ document.getElementById("ovSub").textContent = sub;
+ document.getElementById("playBtn").style.display = showBtn ? "" : "none";
+ overlay.classList.remove("hide");
+ }
+ function hideOverlay() { overlay.classList.add("hide"); }
+
+ // ---- lifecycle ----
+ function resetPositions() {
+ player = makePlayer();
+ ghosts = [makeGhost(0), makeGhost(1), makeGhost(2), makeGhost(3)];
+ frightTimer = 0; ghostEatChain = 0;
+ modeTimer = 0; modePhase = 0; levelElapsed = 0;
+ readyTimer = 1.1;
+ }
+
+ function newLevel() {
+ buildGrid();
+ resetPositions();
+ }
+
+ function startGame() {
+ score = 0; lives = 3; level = 1;
+ buildGrid();
+ resetPositions();
+ state = "play";
+ hideOverlay();
+ updateHUD();
+ }
+
+ function loseLife() {
+ lives--;
+ updateHUD();
+ if (lives <= 0) {
+ state = "over";
+ if (score > best) {
+ best = score;
+ try { localStorage.setItem(BEST_KEY, String(best)); } catch (e) {}
+ }
+ updateHUD();
+ showOverlay("GAME OVER", "Score " + score + " · Best " + best + ". Press PLAY to try again.", true);
+ } else {
+ resetPositions();
+ state = "play";
+ }
+ }
+
+ function winLevel() {
+ level++;
+ if (score > best) {
+ best = score;
+ try { localStorage.setItem(BEST_KEY, String(best)); } catch (e) {}
+ }
+ updateHUD();
+ newLevel();
+ state = "play";
+ }
+
+ function updateHUD() {
+ document.getElementById("score").textContent = score;
+ document.getElementById("lives").textContent = lives;
+ document.getElementById("best").textContent = best;
+ document.getElementById("level").textContent = level;
+ }
+
+ // ---- movement logic ----
+ // Move an entity `dist` pixels along its direction, snapping to tile centers so it
+ // can only turn / re-decide at a center, and never overshoots into a wall.
+ function moveEntity(ent, isGhost, dt) {
+ var sp = ent.speed;
+ if (isGhost && ent.state === "frightened") sp *= 0.62;
+ if (isGhost && ent.state === "eaten") sp *= 2.0;
+
+ // speed is in pixels/second; clamp dt so a stall can't teleport across the maze
+ var remaining = sp * Math.min(dt, 0.05);
+ var guard = 0;
+ while (remaining > 0.0001 && guard++ < 20) {
+ var atCenter = (ent.px === ent.c * TILE) && (ent.py === ent.r * TILE);
+
+ if (atCenter) {
+ // Decision point: choose direction.
+ if (!isGhost) {
+ if (player.next && canMove(ent.c, ent.r, player.next, false, false)) {
+ ent.dir = player.next;
+ }
+ if (!canMove(ent.c, ent.r, ent.dir, false, false)) {
+ ent.dir = "none";
+ }
+ } else {
+ chooseGhostDir(ent);
+ }
+ }
+
+ if (ent.dir === "none") break;
+
+ var d = DIRS[ent.dir];
+ // How far until the next tile boundary in this direction:
+ var targetC = ent.c + d.x, targetR = ent.r + d.y;
+
+ // If currently at center, verify we can enter the next tile.
+ if (atCenter) {
+ var allowDoor = isGhost && (ent.state === "eaten" || ent.inHouse);
+ if (!canMove(ent.c, ent.r, ent.dir, isGhost, allowDoor)) {
+ ent.dir = "none";
+ break;
+ }
+ }
+
+ var targetPx = targetC * TILE, targetPy = targetR * TILE;
+ var dxToTarget = targetPx - ent.px, dyToTarget = targetPy - ent.py;
+ var distToCenter = Math.abs(dxToTarget) + Math.abs(dyToTarget); // straight-line along one axis
+
+ if (distToCenter <= remaining) {
+ // arrive exactly at the next tile center
+ ent.px = targetPx; ent.py = targetPy;
+ ent.c = targetC; ent.r = targetR;
+ remaining -= distToCenter;
+ tunnelWrap(ent);
+ } else {
+ ent.px += d.x * remaining;
+ ent.py += d.y * remaining;
+ remaining = 0;
+ }
+ }
+ }
+
+ function tunnelWrap(ent) {
+ var w = COLS * TILE;
+ if (ent.c < 0) { ent.c = COLS - 1; ent.px = ent.c * TILE; }
+ else if (ent.c >= COLS) { ent.c = 0; ent.px = ent.c * TILE; }
+ }
+
+ function ghostTarget(g) {
+ if (g.state === "eaten") return { c: HOME.c, r: HOME.r - 3 }; // return to house entrance
+ if (g.state === "frightened") return null; // random
+ if (g.state === "scatter") return g.scatterTarget;
+
+ // chase — per-ghost AI
+ if (g.name === "blinky") {
+ return { c: player.c, r: player.r };
+ } else if (g.name === "pinky") {
+ var d = DIRS[player.dir];
+ return { c: player.c + d.x * 4, r: player.r + d.y * 4 };
+ } else if (g.name === "inky") {
+ var blinky = ghosts[0];
+ var d2 = DIRS[player.dir];
+ var ax = player.c + d2.x * 2, ay = player.r + d2.y * 2;
+ return { c: ax + (ax - blinky.c), r: ay + (ay - blinky.r) };
+ } else { // clyde
+ var dist = Math.abs(g.c - player.c) + Math.abs(g.r - player.r);
+ if (dist > 8) return { c: player.c, r: player.r };
+ return g.scatterTarget;
+ }
+ }
+
+ function chooseGhostDir(g) {
+ g.px = g.c * TILE; g.py = g.r * TILE;
+
+ // handle leaving / entering house
+ if (g.inHouse) {
+ // bob toward door and out
+ if (g.releaseAt <= levelElapsed) {
+ // move up out of the house through the door
+ var above = "up";
+ if (canMove(g.c, g.r, above, true, true)) {
+ // steer toward door column (13/14)
+ if (g.c < 13 && canMove(g.c, g.r, "right", true, true)) { g.dir = "right"; return; }
+ if (g.c > 14 && canMove(g.c, g.r, "left", true, true)) { g.dir = "left"; return; }
+ g.dir = "up";
+ if (g.r <= 11) { g.inHouse = false; }
+ return;
+ }
+ }
+ // idle bob
+ g.dir = (g.r <= 13) ? "down" : "up";
+ return;
+ }
+
+ if (g.state === "eaten" && g.c >= 12 && g.c <= 15 && g.r >= 11 && g.r <= 12) {
+ // reached house entrance: revive
+ g.state = (modePhase % 2 === 0) ? "scatter" : "chase";
+ g.inHouse = false;
+ g.speed = 84 + (level - 1) * 4;
+ }
+
+ var target = ghostTarget(g);
+ var options = ["up", "left", "down", "right"];
+ var best = null, bestDist = Infinity;
+ var back = opposite(g.dir);
+
+ // frightened: pick a random valid non-reverse direction
+ if (g.state === "frightened") {
+ var valid = [];
+ for (var i = 0; i < options.length; i++) {
+ var dir = options[i];
+ if (dir === back) continue;
+ if (canMove(g.c, g.r, dir, true, false)) valid.push(dir);
+ }
+ if (valid.length === 0) {
+ if (canMove(g.c, g.r, back, true, false)) g.dir = back;
+ } else {
+ g.dir = valid[(Math.random() * valid.length) | 0];
+ }
+ return;
+ }
+
+ for (var j = 0; j < options.length; j++) {
+ var dr = options[j];
+ if (dr === back) continue;
+ var allowDoor = (g.state === "eaten");
+ if (!canMove(g.c, g.r, dr, true, allowDoor)) continue;
+ var nd = DIRS[dr];
+ var nc = g.c + nd.x, nr = g.r + nd.y;
+ var dx = nc - target.c, dy = nr - target.r;
+ var dist = dx * dx + dy * dy;
+ // priority order up>left>down>right resolves ties naturally by iteration
+ if (dist < bestDist) { bestDist = dist; best = dr; }
+ }
+ if (best) g.dir = best;
+ else if (canMove(g.c, g.r, back, true, g.state === "eaten")) g.dir = back;
+ }
+
+ // ---- update ----
+ function update(dt) {
+ if (readyTimer > 0) {
+ readyTimer -= dt;
+ player.mouth += dt * 8;
+ return;
+ }
+
+ levelElapsed += dt;
+
+ // scatter/chase phase machine (only affects non-frightened, non-eaten)
+ if (frightTimer <= 0) {
+ modeTimer += dt;
+ if (modeTimer >= MODE_SCHEDULE[Math.min(modePhase, MODE_SCHEDULE.length - 1)]) {
+ modeTimer = 0; modePhase++;
+ var newMode = (modePhase % 2 === 0) ? "scatter" : "chase";
+ for (var i = 0; i < ghosts.length; i++) {
+ if (ghosts[i].state === "scatter" || ghosts[i].state === "chase") {
+ ghosts[i].state = newMode;
+ // reverse on mode switch (classic behavior)
+ ghosts[i].dir = opposite(ghosts[i].dir);
+ }
+ }
+ }
+ }
+
+ // fright timer
+ if (frightTimer > 0) {
+ frightTimer -= dt;
+ if (frightTimer <= 0) {
+ frightTimer = 0;
+ for (var g = 0; g < ghosts.length; g++) {
+ if (ghosts[g].state === "frightened") {
+ ghosts[g].state = (modePhase % 2 === 0) ? "scatter" : "chase";
+ }
+ }
+ }
+ }
+
+ // player move
+ moveEntity(player, false, dt);
+ player.mouth += dt * 10;
+
+ // eat pellet
+ if (player.c >= 0 && player.c < COLS && player.r >= 0 && player.r < ROWS) {
+ var p = pellets[player.r][player.c];
+ if (p === 1) {
+ pellets[player.r][player.c] = 0; pelletCount--; score += 10;
+ updateHUD(); blip(520, 0.04, "square", 0.03);
+ } else if (p === 2) {
+ pellets[player.r][player.c] = 0; pelletCount--; score += 50;
+ updateHUD();
+ // power!
+ frightTimer = Math.max(2.5, 7 - (level - 1) * 0.5);
+ ghostEatChain = 0;
+ for (var k = 0; k < ghosts.length; k++) {
+ if (ghosts[k].state === "scatter" || ghosts[k].state === "chase") {
+ ghosts[k].state = "frightened";
+ ghosts[k].dir = opposite(ghosts[k].dir);
+ }
+ }
+ blip(180, 0.2, "sawtooth", 0.05);
+ }
+ }
+
+ // ghosts move + collisions
+ for (var gi = 0; gi < ghosts.length; gi++) {
+ var gh = ghosts[gi];
+ moveEntity(gh, true, dt);
+ // collision (tile-level, tolerant)
+ var close = Math.abs(gh.px - player.px) < TILE * 0.7 && Math.abs(gh.py - player.py) < TILE * 0.7;
+ if (close) {
+ if (gh.state === "frightened") {
+ ghostEatChain++;
+ var pts = 200 * Math.pow(2, Math.min(ghostEatChain - 1, 3));
+ score += pts; updateHUD();
+ gh.state = "eaten"; gh.inHouse = false;
+ blip(880, 0.12, "square", 0.06);
+ } else if (gh.state !== "eaten") {
+ // player dies
+ blip(120, 0.5, "sawtooth", 0.06);
+ state = "dead"; deathAnim = 0;
+ return;
+ }
+ }
+ }
+
+ // win check
+ if (pelletCount <= 0) {
+ state = "win"; blip(660, 0.3, "triangle", 0.05);
+ }
+ }
+
+ function updateDeath(dt) {
+ deathAnim += dt;
+ if (deathAnim > 1.1) { loseLife(); }
+ }
+
+ function updateWin(dt) {
+ deathAnim += dt;
+ if (deathAnim > 0.9) { deathAnim = 0; winLevel(); }
+ }
+
+ // ---- draw ----
+ function draw() {
+ ctx.fillStyle = "#070a10";
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
+
+ // walls
+ ctx.lineWidth = 2;
+ for (var r = 0; r < ROWS; r++) {
+ for (var c = 0; c < COLS; c++) {
+ var cell = grid[r][c];
+ if (cell === "#") {
+ drawWall(c, r);
+ } else if (cell === "-") {
+ ctx.fillStyle = "#f9a8d4";
+ ctx.fillRect(c * TILE + 2, r * TILE + TILE / 2 - 1, TILE - 4, 2);
+ }
+ }
+ }
+
+ // pellets
+ for (var r2 = 0; r2 < ROWS; r2++) {
+ for (var c2 = 0; c2 < COLS; c2++) {
+ var pp = pellets[r2][c2];
+ if (pp === 1) {
+ ctx.fillStyle = "#f5d7a1";
+ ctx.beginPath();
+ ctx.arc(c2 * TILE + TILE / 2, r2 * TILE + TILE / 2, 2, 0, Math.PI * 2);
+ ctx.fill();
+ } else if (pp === 2) {
+ var pulse = 3 + Math.sin(Date.now() / 180) * 1.6;
+ ctx.fillStyle = "#fbbf24";
+ ctx.shadowColor = "#fbbf24"; ctx.shadowBlur = 8;
+ ctx.beginPath();
+ ctx.arc(c2 * TILE + TILE / 2, r2 * TILE + TILE / 2, pulse, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.shadowBlur = 0;
+ }
+ }
+ }
+
+ // ghosts
+ for (var gi = 0; gi < ghosts.length; gi++) drawGhost(ghosts[gi]);
+
+ // player
+ drawPlayer();
+
+ // ready text
+ if (readyTimer > 0 && state === "play") {
+ ctx.fillStyle = "#fde047";
+ ctx.font = "bold 16px -apple-system, sans-serif";
+ ctx.textAlign = "center";
+ ctx.fillText("READY!", canvas.width / 2, 17 * TILE + 4);
+ }
+ }
+
+ function drawWall(c, r) {
+ var x = c * TILE, y = r * TILE;
+ ctx.fillStyle = "#0e2a5e";
+ // inset rounded block with neon border
+ ctx.strokeStyle = "#3b82f6";
+ ctx.shadowColor = "#60a5fa"; ctx.shadowBlur = 4;
+ ctx.strokeRect(x + 2.5, y + 2.5, TILE - 5, TILE - 5);
+ ctx.shadowBlur = 0;
+ }
+
+ function drawPlayer() {
+ var cx = player.px + TILE / 2, cy = player.py + TILE / 2;
+ var rad = TILE / 2 - 1.5;
+ var m = 0;
+ if (state === "dead") {
+ // shrink/fade mouth open
+ m = Math.min(Math.PI, deathAnim * Math.PI * 1.6);
+ } else {
+ m = (Math.abs(Math.sin(player.mouth)) * 0.28 + 0.06) * Math.PI;
+ }
+ var ang = { up: -Math.PI / 2, down: Math.PI / 2, left: Math.PI, right: 0, none: 0 }[player.dir] || 0;
+
+ ctx.save();
+ ctx.translate(cx, cy);
+ ctx.rotate(ang);
+ ctx.fillStyle = "#fde047";
+ ctx.shadowColor = "#fde047"; ctx.shadowBlur = 8;
+ ctx.beginPath();
+ ctx.moveTo(0, 0);
+ ctx.arc(0, 0, rad, m, Math.PI * 2 - m);
+ ctx.closePath();
+ ctx.fill();
+ ctx.shadowBlur = 0;
+ ctx.restore();
+ }
+
+ function drawGhost(g) {
+ var x = g.px, y = g.py;
+ var cx = x + TILE / 2, cy = y + TILE / 2;
+ var rad = TILE / 2 - 1.5;
+ var body;
+ var eyesVisible = true;
+
+ if (g.state === "eaten") {
+ body = null; // only eyes
+ } else if (g.state === "frightened") {
+ var blink = frightTimer < 2 && (Math.floor(frightTimer * 6) % 2 === 0);
+ body = blink ? "#e5e7eb" : "#1e3a8a";
+ eyesVisible = false;
+ } else {
+ body = g.color;
+ }
+
+ if (body) {
+ ctx.fillStyle = body;
+ ctx.shadowColor = body; ctx.shadowBlur = 6;
+ ctx.beginPath();
+ ctx.arc(cx, cy - 1, rad, Math.PI, 0);
+ ctx.lineTo(cx + rad, cy + rad - 1);
+ // wavy skirt
+ var feet = 3, step = (rad * 2) / feet;
+ for (var f = 0; f < feet; f++) {
+ var fx = cx + rad - step * f;
+ ctx.lineTo(fx - step / 2, cy + rad - 4);
+ ctx.lineTo(fx - step, cy + rad - 1);
+ }
+ ctx.closePath();
+ ctx.fill();
+ ctx.shadowBlur = 0;
+ }
+
+ // eyes
+ if (g.state === "frightened") {
+ // scared face
+ ctx.fillStyle = "#fde047";
+ ctx.fillRect(cx - 4, cy - 2, 2, 2);
+ ctx.fillRect(cx + 2, cy - 2, 2, 2);
+ ctx.strokeStyle = "#fde047"; ctx.lineWidth = 1;
+ ctx.beginPath();
+ ctx.moveTo(cx - 4, cy + 4);
+ ctx.lineTo(cx - 1, cy + 2);
+ ctx.lineTo(cx + 1, cy + 4);
+ ctx.lineTo(cx + 4, cy + 2);
+ ctx.stroke();
+ } else {
+ var d = DIRS[g.dir];
+ ctx.fillStyle = "#ffffff";
+ ctx.beginPath(); ctx.arc(cx - 3, cy - 1, 2.4, 0, Math.PI * 2); ctx.fill();
+ ctx.beginPath(); ctx.arc(cx + 3, cy - 1, 2.4, 0, Math.PI * 2); ctx.fill();
+ ctx.fillStyle = "#1e3a8a";
+ ctx.beginPath(); ctx.arc(cx - 3 + d.x * 1.2, cy - 1 + d.y * 1.2, 1.2, 0, Math.PI * 2); ctx.fill();
+ ctx.beginPath(); ctx.arc(cx + 3 + d.x * 1.2, cy - 1 + d.y * 1.2, 1.2, 0, Math.PI * 2); ctx.fill();
+ }
+ }
+
+ // ---- main loop ----
+ var last = 0;
+ function loop(ts) {
+ try {
+ if (!last) last = ts;
+ var dt = Math.min(0.05, (ts - last) / 1000);
+ last = ts;
+
+ if (state === "play") update(dt);
+ else if (state === "dead") updateDeath(dt);
+ else if (state === "win") updateWin(dt);
+
+ if (grid.length) draw();
+ } catch (e) {
+ // keep the loop alive; never let the iframe throw
+ }
+ requestAnimationFrame(loop);
+ }
+
+ // ---- boot ----
+ function boot() {
+ buildGrid();
+ resetPositions();
+ fit();
+ updateHUD();
+ state = "menu";
+ showOverlay("ABRAMS CHOMP",
+ "Eat every pellet. Grab a power pellet to hunt the ghosts. Arrow keys / WASD or the D-pad.", true);
+ requestAnimationFrame(loop);
+ }
+
+ if (document.readyState === "loading") {
+ document.addEventListener("DOMContentLoaded", boot);
+ } else {
+ boot();
+ }
+})();
+</script>
+</body>
+</html>
diff --git a/games/gems/index.html b/games/gems/index.html
new file mode 100644
index 0000000..64548fa
--- /dev/null
+++ b/games/gems/index.html
@@ -0,0 +1,755 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
+<title>NEON GEMS</title>
+<style>
+ :root{
+ --bg:#0a0e14;
+ --panel:#0f1622;
+ --line:#1c2735;
+ --ink:#e8f0ff;
+ --muted:#7f93ad;
+ --accent:#3df2ff;
+ }
+ *{ box-sizing:border-box; -webkit-tap-highlight-color:transparent; }
+ html,body{ margin:0; padding:0; height:100%; }
+ body{
+ background:
+ radial-gradient(1200px 700px at 50% -10%, #12213a 0%, rgba(18,33,58,0) 60%),
+ var(--bg);
+ color:var(--ink);
+ font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
+ touch-action:none;
+ overflow:hidden;
+ display:flex;
+ align-items:center;
+ justify-content:center;
+ min-height:100%;
+ user-select:none;
+ -webkit-user-select:none;
+ }
+ #wrap{
+ display:flex;
+ flex-direction:column;
+ align-items:center;
+ gap:14px;
+ padding:16px;
+ width:100%;
+ max-width:560px;
+ }
+ #title{
+ margin:0;
+ font-size:clamp(20px,6vw,34px);
+ font-weight:800;
+ letter-spacing:.42em;
+ text-transform:uppercase;
+ text-indent:.42em;
+ background:linear-gradient(90deg,#3df2ff,#8a7dff 45%,#ff5db8);
+ -webkit-background-clip:text;
+ background-clip:text;
+ color:transparent;
+ filter:drop-shadow(0 0 12px rgba(61,242,255,.35));
+ text-align:center;
+ }
+ #hud{
+ width:100%;
+ display:flex;
+ gap:10px;
+ justify-content:center;
+ }
+ .stat{
+ flex:1;
+ background:linear-gradient(180deg,var(--panel),#0b1220);
+ border:1px solid var(--line);
+ border-radius:12px;
+ padding:8px 10px;
+ text-align:center;
+ box-shadow:inset 0 1px 0 rgba(255,255,255,.03);
+ }
+ .stat .lbl{
+ font-size:10px;
+ letter-spacing:.22em;
+ text-transform:uppercase;
+ color:var(--muted);
+ }
+ .stat .val{
+ font-size:clamp(16px,5vw,24px);
+ font-weight:800;
+ margin-top:2px;
+ font-variant-numeric:tabular-nums;
+ color:var(--ink);
+ }
+ #boardWrap{
+ position:relative;
+ width:100%;
+ border-radius:16px;
+ background:linear-gradient(180deg,#0c1420,#080c13);
+ border:1px solid var(--line);
+ box-shadow:0 20px 60px rgba(0,0,0,.55), inset 0 0 60px rgba(61,242,255,.04);
+ padding:10px;
+ touch-action:none;
+ }
+ canvas{ display:block; width:100%; height:auto; border-radius:10px; touch-action:none; }
+ #foot{
+ display:flex;
+ gap:10px;
+ align-items:center;
+ justify-content:center;
+ width:100%;
+ }
+ button{
+ font-family:inherit;
+ font-weight:700;
+ letter-spacing:.16em;
+ text-transform:uppercase;
+ font-size:11px;
+ color:var(--ink);
+ background:linear-gradient(180deg,#132033,#0c1524);
+ border:1px solid var(--line);
+ border-radius:10px;
+ padding:9px 14px;
+ cursor:pointer;
+ transition:transform .08s ease, border-color .15s ease, box-shadow .15s ease;
+ }
+ button:hover{ border-color:var(--accent); box-shadow:0 0 14px rgba(61,242,255,.25); }
+ button:active{ transform:translateY(1px); }
+ #msg{
+ min-height:16px;
+ font-size:11px;
+ letter-spacing:.16em;
+ text-transform:uppercase;
+ color:var(--muted);
+ text-align:center;
+ }
+</style>
+</head>
+<body>
+ <div id="wrap">
+ <h1 id="title">Neon Gems</h1>
+ <div id="hud">
+ <div class="stat"><div class="lbl">Score</div><div class="val" id="scoreEl">0</div></div>
+ <div class="stat"><div class="lbl">Moves</div><div class="val" id="movesEl">30</div></div>
+ <div class="stat"><div class="lbl">Best</div><div class="val" id="bestEl">0</div></div>
+ </div>
+ <div id="boardWrap">
+ <canvas id="game"></canvas>
+ </div>
+ <div id="msg">Swap gems to match 3 or more</div>
+ <div id="foot">
+ <button id="soundBtn" type="button">Sound: On</button>
+ <button id="newBtn" type="button">New Game</button>
+ </div>
+ </div>
+
+<script>
+(function(){
+ "use strict";
+
+ function boot(){
+ var canvas = document.getElementById('game');
+ if(!canvas) return;
+ var ctx = canvas.getContext('2d');
+ if(!ctx) return;
+
+ var scoreEl = document.getElementById('scoreEl');
+ var movesEl = document.getElementById('movesEl');
+ var bestEl = document.getElementById('bestEl');
+ var msgEl = document.getElementById('msg');
+ var soundBtn= document.getElementById('soundBtn');
+ var newBtn = document.getElementById('newBtn');
+ var boardWrap = document.getElementById('boardWrap');
+
+ var N = 8; // grid size
+ var START_MOVES = 30;
+ var BEST_KEY = "abrams-gems-best";
+
+ // Gem palette: distinct saturated neon hues
+ var GEMS = [
+ { core:"#ff3d6e", glow:"#ff3d6e" }, // pink/red
+ { core:"#ffb43d", glow:"#ffb43d" }, // amber
+ { core:"#ffe93d", glow:"#ffe93d" }, // yellow
+ { core:"#4dff88", glow:"#4dff88" }, // green
+ { core:"#3df2ff", glow:"#3df2ff" }, // cyan
+ { core:"#7d8bff", glow:"#7d8bff" }, // indigo
+ { core:"#c96dff", glow:"#c96dff" } // violet
+ ];
+ var NC = GEMS.length;
+
+ // DPR-aware sizing
+ var cell = 48; // logical px per cell (set in resize)
+ var dpr = 1;
+
+ // Board state: grid[r][c] = color index (0..NC-1) or -1 empty
+ var grid = [];
+ var score = 0, moves = START_MOVES, best = 0;
+ var soundOn = true;
+
+ // Per-cell render offset (for fall/swap animation)
+ // offset[r][c] = {dx, dy} in logical pixels, animated to 0
+ var offX = [], offY = [];
+ // pop animation: pop[r][c] = scale factor while clearing (1 -> 0)
+ var popScale = [];
+ var spawnScale = []; // 0 -> 1 for newly spawned/fallen gems
+
+ var sel = null; // {r,c} currently selected
+ var animating = false; // block input during resolve animations
+ var gameOver = false;
+
+ // ---------- storage ----------
+ function loadBest(){
+ try{
+ var v = localStorage.getItem(BEST_KEY);
+ best = v ? (parseInt(v,10)||0) : 0;
+ }catch(e){ best = 0; }
+ }
+ function saveBest(){
+ try{ localStorage.setItem(BEST_KEY, String(best)); }catch(e){}
+ }
+
+ // ---------- audio ----------
+ var audioCtx = null;
+ function ensureAudio(){
+ if(audioCtx) return;
+ try{
+ var AC = window.AudioContext || window.webkitAudioContext;
+ if(AC) audioCtx = new AC();
+ }catch(e){ audioCtx = null; }
+ }
+ function blip(freq, dur, type, vol){
+ if(!soundOn || !audioCtx) return;
+ try{
+ var t = audioCtx.currentTime;
+ var o = audioCtx.createOscillator();
+ var g = audioCtx.createGain();
+ o.type = type || "sine";
+ o.frequency.setValueAtTime(freq, t);
+ g.gain.setValueAtTime(0.0001, t);
+ g.gain.exponentialRampToValueAtTime(vol||0.14, t+0.01);
+ g.gain.exponentialRampToValueAtTime(0.0001, t+(dur||0.12));
+ o.connect(g); g.connect(audioCtx.destination);
+ o.start(t);
+ o.stop(t+(dur||0.12)+0.02);
+ }catch(e){}
+ }
+ function sfxSwap(){ blip(420,0.09,"triangle",0.10); }
+ function sfxBad(){ blip(160,0.14,"sawtooth",0.08); }
+ function sfxMatch(chain){ blip(520 + chain*90, 0.14, "square", 0.12); }
+ function sfxDrop(){ blip(300,0.06,"sine",0.06); }
+
+ // ---------- grid helpers ----------
+ function rndColor(){ return Math.floor(Math.random()*NC); }
+
+ function inBounds(r,c){ return r>=0 && r<N && c>=0 && c<N; }
+
+ // Build initial board with NO pre-existing match-3
+ function fillInitial(){
+ grid = [];
+ for(var r=0;r<N;r++){
+ grid[r] = [];
+ for(var c=0;c<N;c++){
+ var col;
+ var guard = 0;
+ do{
+ col = rndColor();
+ guard++;
+ // reject if it completes a horizontal or vertical run of 3
+ }while(guard<50 && (
+ (c>=2 && grid[r][c-1]===col && grid[r][c-2]===col) ||
+ (r>=2 && grid[r-1][c]===col && grid[r-2][c]===col)
+ ));
+ grid[r][c] = col;
+ }
+ }
+ // guarantee at least one valid move
+ if(!hasValidMove()){ shuffleBoard(); }
+ }
+
+ // find all matched cells (>=3 in row/col). returns 2D boolean.
+ function findMatches(){
+ var mark = [];
+ for(var r=0;r<N;r++){ mark[r]=[]; for(var c=0;c<N;c++) mark[r][c]=false; }
+ var r,c;
+ // horizontal
+ for(r=0;r<N;r++){
+ var run=1;
+ for(c=1;c<N;c++){
+ if(grid[r][c]!==-1 && grid[r][c]===grid[r][c-1]) run++;
+ else {
+ if(run>=3){ for(var k=0;k<run;k++) mark[r][c-1-k]=true; }
+ run=1;
+ }
+ }
+ if(run>=3){ for(var k2=0;k2<run;k2++) mark[r][N-1-k2]=true; }
+ }
+ // vertical
+ for(c=0;c<N;c++){
+ var run2=1;
+ for(r=1;r<N;r++){
+ if(grid[r][c]!==-1 && grid[r][c]===grid[r-1][c]) run2++;
+ else {
+ if(run2>=3){ for(var k3=0;k3<run2;k3++) mark[r-1-k3][c]=true; }
+ run2=1;
+ }
+ }
+ if(run2>=3){ for(var k4=0;k4<run2;k4++) mark[N-1-k4][c]=true; }
+ }
+ return mark;
+ }
+
+ function countMarked(mark){
+ var n=0;
+ for(var r=0;r<N;r++) for(var c=0;c<N;c++) if(mark[r][c]) n++;
+ return n;
+ }
+
+ // would swapping (r1,c1)<->(r2,c2) produce a match?
+ function swapProducesMatch(r1,c1,r2,c2){
+ var t = grid[r1][c1]; grid[r1][c1]=grid[r2][c2]; grid[r2][c2]=t;
+ var m = findMatches();
+ var ok = countMarked(m) > 0;
+ var t2 = grid[r1][c1]; grid[r1][c1]=grid[r2][c2]; grid[r2][c2]=t2;
+ return ok;
+ }
+
+ function hasValidMove(){
+ for(var r=0;r<N;r++){
+ for(var c=0;c<N;c++){
+ if(c<N-1 && swapProducesMatch(r,c,r,c+1)) return true;
+ if(r<N-1 && swapProducesMatch(r,c,r+1,c)) return true;
+ }
+ }
+ return false;
+ }
+
+ function shuffleBoard(){
+ var guard=0;
+ do{
+ // Fisher-Yates over flat colors
+ var flat=[];
+ for(var r=0;r<N;r++) for(var c=0;c<N;c++) flat.push(grid[r][c]);
+ for(var i=flat.length-1;i>0;i--){
+ var j=Math.floor(Math.random()*(i+1));
+ var tmp=flat[i]; flat[i]=flat[j]; flat[j]=tmp;
+ }
+ var idx=0;
+ for(var r2=0;r2<N;r2++) for(var c2=0;c2<N;c2++) grid[r2][c2]=flat[idx++];
+ guard++;
+ }while(guard<80 && (countMarked(findMatches())>0 || !hasValidMove()));
+ }
+
+ // ---------- animation arrays ----------
+ function resetAnimArrays(){
+ offX=[]; offY=[]; popScale=[]; spawnScale=[];
+ for(var r=0;r<N;r++){
+ offX[r]=[]; offY[r]=[]; popScale[r]=[]; spawnScale[r]=[];
+ for(var c=0;c<N;c++){ offX[r][c]=0; offY[r][c]=0; popScale[r][c]=1; spawnScale[r][c]=1; }
+ }
+ }
+
+ // ---------- new game ----------
+ function newGame(){
+ score=0; moves=START_MOVES; gameOver=false; sel=null; animating=false;
+ fillInitial();
+ resetAnimArrays();
+ updateHUD();
+ setMsg("Swap gems to match 3 or more");
+ draw();
+ }
+
+ // ---------- HUD ----------
+ function updateHUD(){
+ scoreEl.textContent = String(score);
+ movesEl.textContent = String(moves);
+ bestEl.textContent = String(best);
+ }
+ function setMsg(t){ msgEl.textContent = t; }
+
+ // ---------- sizing ----------
+ function resize(){
+ // available width from wrapper (minus padding)
+ var wrapW = boardWrap.clientWidth - 20; // 10px padding each side
+ if(wrapW < 120) wrapW = 120;
+ // keep board square, cap so it fits typical iframe heights too
+ var maxByH = (window.innerHeight || 600) - 230;
+ var size = Math.min(wrapW, maxByH > 200 ? maxByH : wrapW);
+ size = Math.max(200, Math.min(size, 520));
+ cell = size / N;
+ dpr = Math.max(1, Math.min(window.devicePixelRatio || 1, 3));
+ canvas.width = Math.round(size * dpr);
+ canvas.height = Math.round(size * dpr);
+ canvas.style.width = size + "px";
+ canvas.style.height = size + "px";
+ draw();
+ }
+
+ // ---------- drawing ----------
+ function drawGem(cx, cy, rad, gi, alpha, scale){
+ if(gi<0) return;
+ var g = GEMS[gi];
+ var R = rad*scale;
+ if(R<=0.5) return;
+ ctx.save();
+ ctx.globalAlpha = alpha;
+ // outer glow
+ ctx.shadowColor = g.glow;
+ ctx.shadowBlur = R*0.9;
+ // body gradient
+ var grad = ctx.createRadialGradient(cx - R*0.3, cy - R*0.35, R*0.15, cx, cy, R);
+ grad.addColorStop(0, "#ffffff");
+ grad.addColorStop(0.18, g.core);
+ grad.addColorStop(1, shade(g.core, -0.45));
+ ctx.fillStyle = grad;
+ roundRectPath(cx-R, cy-R, R*2, R*2, R*0.42);
+ ctx.fill();
+ // inner rim
+ ctx.shadowBlur = 0;
+ ctx.lineWidth = Math.max(1, R*0.10);
+ ctx.strokeStyle = shade(g.core, 0.35);
+ ctx.globalAlpha = alpha*0.8;
+ roundRectPath(cx-R*0.92, cy-R*0.92, R*1.84, R*1.84, R*0.36);
+ ctx.stroke();
+ // sparkle highlight
+ ctx.globalAlpha = alpha*0.85;
+ ctx.fillStyle = "rgba(255,255,255,0.85)";
+ ctx.beginPath();
+ ctx.ellipse(cx - R*0.32, cy - R*0.38, R*0.22, R*0.13, -0.6, 0, Math.PI*2);
+ ctx.fill();
+ ctx.restore();
+ }
+
+ function roundRectPath(x,y,w,h,r){
+ if(r>w/2) r=w/2; if(r>h/2) r=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();
+ }
+
+ // shade a hex color by amt (-1..1)
+ function shade(hex, amt){
+ var h = hex.replace('#','');
+ var r = parseInt(h.substring(0,2),16);
+ var g = parseInt(h.substring(2,4),16);
+ var b = parseInt(h.substring(4,6),16);
+ if(amt<0){
+ r = Math.round(r*(1+amt)); g = Math.round(g*(1+amt)); b = Math.round(b*(1+amt));
+ } else {
+ r = Math.round(r+(255-r)*amt); g = Math.round(g+(255-g)*amt); b = Math.round(b+(255-b)*amt);
+ }
+ r=Math.max(0,Math.min(255,r)); g=Math.max(0,Math.min(255,g)); b=Math.max(0,Math.min(255,b));
+ return "rgb("+r+","+g+","+b+")";
+ }
+
+ function draw(){
+ var W = canvas.width, H = canvas.height;
+ ctx.setTransform(dpr,0,0,dpr,0,0);
+ var size = W/dpr;
+ // bg
+ ctx.clearRect(0,0,size,size);
+ var bg = ctx.createLinearGradient(0,0,0,size);
+ bg.addColorStop(0,"#0b1220");
+ bg.addColorStop(1,"#070b11");
+ ctx.fillStyle = bg;
+ ctx.fillRect(0,0,size,size);
+
+ // grid cells backdrop
+ for(var r=0;r<N;r++){
+ for(var c=0;c<N;c++){
+ var x = c*cell, y = r*cell;
+ ctx.fillStyle = ((r+c)%2===0) ? "rgba(255,255,255,0.018)" : "rgba(255,255,255,0.006)";
+ ctx.fillRect(x,y,cell,cell);
+ }
+ }
+
+ // selection highlight
+ if(sel){
+ var sx = sel.c*cell, sy = sel.r*cell;
+ ctx.save();
+ ctx.strokeStyle = "#3df2ff";
+ ctx.shadowColor = "#3df2ff";
+ ctx.shadowBlur = 14;
+ ctx.lineWidth = 3;
+ roundRectPath(sx+3, sy+3, cell-6, cell-6, cell*0.2);
+ ctx.stroke();
+ ctx.restore();
+ }
+
+ // gems
+ var rad = cell*0.40;
+ for(var r2=0;r2<N;r2++){
+ for(var c2=0;c2<N;c2++){
+ var gi = grid[r2][c2];
+ if(gi<0) continue;
+ var cx = c2*cell + cell/2 + offX[r2][c2];
+ var cy = r2*cell + cell/2 + offY[r2][c2];
+ var ps = popScale[r2][c2];
+ var ss = spawnScale[r2][c2];
+ var sc = ps * ss;
+ if(sc<=0.02) continue;
+ drawGem(cx, cy, rad, gi, Math.min(1, sc<1?sc:1)*(ps<1?ps:1), sc);
+ }
+ }
+ }
+
+ // ---------- animation loop ----------
+ var rafId = null;
+ function animateOffsets(cb){
+ animating = true;
+ var speed = 0.22; // easing factor per frame
+ function step(){
+ var moving = false;
+ for(var r=0;r<N;r++){
+ for(var c=0;c<N;c++){
+ // ease offsets to 0
+ if(Math.abs(offX[r][c])>0.5){ offX[r][c]*= (1-speed); moving=true; } else offX[r][c]=0;
+ if(Math.abs(offY[r][c])>0.5){ offY[r][c]*= (1-speed); moving=true; } else offY[r][c]=0;
+ // spawn scale ease to 1
+ if(spawnScale[r][c] < 0.999){ spawnScale[r][c] += (1-spawnScale[r][c])*0.28; if(spawnScale[r][c]>0.995) spawnScale[r][c]=1; moving=true; }
+ }
+ }
+ draw();
+ if(moving){ rafId = requestAnimationFrame(step); }
+ else { animating=false; cb && cb(); }
+ }
+ rafId = requestAnimationFrame(step);
+ }
+
+ function animatePops(marks, cb){
+ animating = true;
+ function step(){
+ var moving=false;
+ for(var r=0;r<N;r++){
+ for(var c=0;c<N;c++){
+ if(marks[r][c] && popScale[r][c]>0.02){
+ popScale[r][c] -= 0.14;
+ if(popScale[r][c]<0) popScale[r][c]=0;
+ moving=true;
+ }
+ }
+ }
+ draw();
+ if(moving) rafId=requestAnimationFrame(step);
+ else { animating=false; cb && cb(); }
+ }
+ rafId=requestAnimationFrame(step);
+ }
+
+ // ---------- resolve cascades ----------
+ function resolveBoard(chain){
+ var marks = findMatches();
+ var n = countMarked(marks);
+ if(n===0){
+ // done cascading
+ animating=false;
+ checkEndAndShuffle();
+ draw();
+ return;
+ }
+ sfxMatch(chain||0);
+ // score: base 10 per gem, cascade multiplier
+ var mult = 1 + (chain||0)*0.5;
+ var gained = Math.round(n * 10 * mult);
+ score += gained;
+ if(score>best){ best=score; saveBest(); }
+ updateHUD();
+ if((chain||0)>0) setMsg("Combo x"+(chain+1)+" +"+gained);
+ else setMsg("+"+gained);
+
+ // animate pop, then collapse + refill
+ animatePops(marks, function(){
+ // clear matched
+ for(var r=0;r<N;r++) for(var c=0;c<N;c++){ if(marks[r][c]){ grid[r][c]=-1; popScale[r][c]=1; } }
+ collapseAndRefill();
+ // animate the falls then resolve again (cascade)
+ animateOffsets(function(){
+ resolveBoard((chain||0)+1);
+ });
+ });
+ }
+
+ // gravity: pull down, spawn new at top; set offY so they animate falling
+ function collapseAndRefill(){
+ for(var c=0;c<N;c++){
+ // gather existing from bottom up
+ var writeR = N-1;
+ for(var r=N-1;r>=0;r--){
+ if(grid[r][c]!==-1){
+ if(writeR!==r){
+ grid[writeR][c]=grid[r][c];
+ grid[r][c]=-1;
+ // offset so it visually starts where it was, then eases to 0
+ offY[writeR][c] = (r-writeR)*cell;
+ spawnScale[writeR][c]=1;
+ }
+ writeR--;
+ }
+ }
+ // fill remaining top with new gems, dropping in from above
+ var spawnIdx = 0;
+ for(var rr=writeR; rr>=0; rr--){
+ grid[rr][c]=rndColor();
+ offY[rr][c] = -(cell*(spawnIdx+1)) - cell; // start above the board
+ spawnScale[rr][c]=0.35;
+ spawnIdx++;
+ }
+ }
+ sfxDrop();
+ }
+
+ function checkEndAndShuffle(){
+ if(!hasValidMove()){
+ setMsg("No moves — reshuffling…");
+ shuffleBoard();
+ resetAnimArrays();
+ draw();
+ // after shuffle there should be no immediate matches (guarded)
+ }
+ if(moves<=0){
+ gameOver=true;
+ setMsg("Out of moves — Score "+score+". Tap New Game.");
+ }
+ }
+
+ // ---------- input ----------
+ function cellFromEvent(clientX, clientY){
+ var rect = canvas.getBoundingClientRect();
+ var x = clientX - rect.left;
+ var y = clientY - rect.top;
+ // canvas CSS size maps to N*cell logical (cell already logical)
+ var px = x * (canvas.width/dpr) / rect.width;
+ var py = y * (canvas.height/dpr) / rect.height;
+ var c = Math.floor(px / cell);
+ var r = Math.floor(py / cell);
+ if(!inBounds(r,c)) return null;
+ return {r:r,c:c};
+ }
+
+ function tryMove(a, b){
+ // must be adjacent
+ var dr = Math.abs(a.r-b.r), dc = Math.abs(a.c-b.c);
+ if(dr+dc!==1) return false;
+ if(gameOver) return false;
+ sfxSwap();
+ // do swap
+ var t=grid[a.r][a.c]; grid[a.r][a.c]=grid[b.r][b.c]; grid[b.r][b.c]=t;
+ // set offsets for swap animation (each moves toward its new home)
+ offX[a.r][a.c] = (b.c-a.c)*cell; offY[a.r][a.c] = (b.r-a.r)*cell;
+ offX[b.r][b.c] = (a.c-b.c)*cell; offY[b.r][b.c] = (a.r-b.r)*cell;
+
+ var m = findMatches();
+ if(countMarked(m)===0){
+ // invalid: animate swap in, then swap back
+ sfxBad();
+ animateOffsets(function(){
+ var t2=grid[a.r][a.c]; grid[a.r][a.c]=grid[b.r][b.c]; grid[b.r][b.c]=t2;
+ offX[a.r][a.c]=(b.c-a.c)*cell; offY[a.r][a.c]=(b.r-a.r)*cell;
+ offX[b.r][b.c]=(a.c-b.c)*cell; offY[b.r][b.c]=(a.r-b.r)*cell;
+ animateOffsets(function(){ setMsg("No match there"); });
+ });
+ return true; // was a valid adjacent attempt (but no match)
+ }
+ // valid move
+ moves--;
+ updateHUD();
+ animateOffsets(function(){
+ resolveBoard(0);
+ });
+ return true;
+ }
+
+ var pointerDown = null; // {r,c,x,y}
+ function onDown(clientX, clientY){
+ ensureAudio();
+ if(animating || gameOver) return;
+ var cellPos = cellFromEvent(clientX, clientY);
+ if(!cellPos) return;
+ pointerDown = {r:cellPos.r, c:cellPos.c, x:clientX, y:clientY};
+ if(sel){
+ // second click: attempt swap or reselect
+ if(sel.r===cellPos.r && sel.c===cellPos.c){
+ sel=null; draw(); return;
+ }
+ var dr=Math.abs(sel.r-cellPos.r), dc=Math.abs(sel.c-cellPos.c);
+ if(dr+dc===1){
+ var a={r:sel.r,c:sel.c}; sel=null;
+ tryMove(a, cellPos);
+ draw();
+ } else {
+ sel={r:cellPos.r,c:cellPos.c}; draw();
+ }
+ } else {
+ sel={r:cellPos.r,c:cellPos.c}; draw();
+ }
+ }
+
+ function onUp(clientX, clientY){
+ if(!pointerDown) return;
+ if(animating || gameOver){ pointerDown=null; return; }
+ var dx = clientX - pointerDown.x;
+ var dy = clientY - pointerDown.y;
+ var absx=Math.abs(dx), absy=Math.abs(dy);
+ var threshold = cell*0.3;
+ if(Math.max(absx,absy) > threshold){
+ // treat as swipe from the down cell
+ var a={r:pointerDown.r, c:pointerDown.c};
+ var b;
+ if(absx>absy) b={r:a.r, c:a.c + (dx>0?1:-1)};
+ else b={r:a.r + (dy>0?1:-1), c:a.c};
+ if(inBounds(b.r,b.c)){
+ sel=null;
+ tryMove(a,b);
+ draw();
+ }
+ }
+ pointerDown=null;
+ }
+
+ // mouse
+ canvas.addEventListener('mousedown', function(e){ e.preventDefault(); onDown(e.clientX,e.clientY); });
+ window.addEventListener('mouseup', function(e){ onUp(e.clientX,e.clientY); });
+ // touch
+ canvas.addEventListener('touchstart', function(e){
+ if(e.touches.length){ e.preventDefault(); var t=e.touches[0]; onDown(t.clientX,t.clientY); }
+ }, {passive:false});
+ canvas.addEventListener('touchmove', function(e){ e.preventDefault(); }, {passive:false});
+ canvas.addEventListener('touchend', function(e){
+ e.preventDefault();
+ var t = (e.changedTouches && e.changedTouches.length) ? e.changedTouches[0] : null;
+ if(t) onUp(t.clientX,t.clientY);
+ }, {passive:false});
+
+ // buttons
+ soundBtn.addEventListener('click', function(){
+ soundOn = !soundOn;
+ soundBtn.textContent = "Sound: " + (soundOn?"On":"Off");
+ if(soundOn){ ensureAudio(); blip(660,0.08,"sine",0.1); }
+ });
+ newBtn.addEventListener('click', function(){ ensureAudio(); newGame(); });
+
+ // resize handling
+ var resizeTimer=null;
+ window.addEventListener('resize', function(){
+ clearTimeout(resizeTimer);
+ resizeTimer=setTimeout(resize, 60);
+ });
+
+ // ---------- init ----------
+ loadBest();
+ newGame();
+ resize();
+ }
+
+ if(document.readyState === "loading"){
+ document.addEventListener("DOMContentLoaded", boot);
+ } else {
+ boot();
+ }
+})();
+</script>
+</body>
+</html>
diff --git a/games/hopper/index.html b/games/hopper/index.html
new file mode 100644
index 0000000..d8ee5b4
--- /dev/null
+++ b/games/hopper/index.html
@@ -0,0 +1,790 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
+<title>HOPPER</title>
+<style>
+ html,body{margin:0;padding:0;height:100%;background:#0a0e14;overflow:hidden;}
+ body{
+ touch-action:none;
+ -webkit-user-select:none;user-select:none;-webkit-tap-highlight-color:transparent;
+ font-family:'Trebuchet MS','Segoe UI',system-ui,-apple-system,sans-serif;
+ display:flex;align-items:center;justify-content:center;
+ color:#cfe;
+ }
+ #wrap{position:relative;width:100%;height:100%;display:flex;align-items:center;justify-content:center;}
+ canvas{display:block;background:#0a0e14;touch-action:none;image-rendering:optimizeQuality;}
+</style>
+</head>
+<body>
+<div id="wrap"><canvas id="c"></canvas></div>
+<script>
+(function(){
+ "use strict";
+
+ function boot(){
+ var canvas = document.getElementById('c');
+ if(!canvas){ return; }
+ var ctx = canvas.getContext('2d');
+ if(!ctx){ return; }
+
+ // ---------- Config ----------
+ var COLS = 13; // grid columns
+ var ROWS = 15; // grid rows (0 = home row, 14 = start row)
+ var BEST_KEY = "abrams-hopper-best";
+ var DPR = 1;
+ var cell = 40; // px per cell (computed on resize)
+ var W = COLS*cell, H = ROWS*cell;
+ var offX = 0, offY = 0; // canvas draw offsets (letterbox)
+
+ // ---------- Audio (lazy) ----------
+ var actx = null;
+ function ensureAudio(){
+ if(actx) return;
+ try{
+ var AC = window.AudioContext || window.webkitAudioContext;
+ if(AC){ actx = new AC(); }
+ }catch(e){ actx = null; }
+ }
+ function blip(freq, dur, type, vol){
+ if(!actx) return;
+ try{
+ if(actx.state === "suspended"){ actx.resume(); }
+ var o = actx.createOscillator();
+ var g = actx.createGain();
+ o.type = type || "square";
+ o.frequency.value = freq;
+ g.gain.value = 0.0001;
+ o.connect(g); g.connect(actx.destination);
+ var t = actx.currentTime;
+ var v = (vol==null?0.12:vol);
+ g.gain.setValueAtTime(0.0001, t);
+ g.gain.exponentialRampToValueAtTime(v, t+0.01);
+ g.gain.exponentialRampToValueAtTime(0.0001, t+dur);
+ o.start(t);
+ o.stop(t+dur+0.02);
+ }catch(e){}
+ }
+ function sHop(){ blip(660,0.08,"square",0.10); }
+ function sSplat(){ blip(120,0.28,"sawtooth",0.16); setTimeout(function(){blip(80,0.22,"sawtooth",0.14);},40); }
+ function sHome(){ blip(880,0.10,"triangle",0.14); setTimeout(function(){blip(1320,0.14,"triangle",0.12);},90); }
+ function sLevel(){ blip(523,0.10,"triangle",0.12); setTimeout(function(){blip(659,0.10,"triangle",0.12);},110); setTimeout(function(){blip(784,0.16,"triangle",0.12);},220); }
+
+ // ---------- Layout ----------
+ // Rows (top to bottom):
+ // 0 : HOME row (goal slots)
+ // 1..5 : RIVER lanes (logs/turtles)
+ // 6 : SAFE median
+ // 7..11 : ROAD lanes (cars)
+ // 12 : SAFE start-adjacent
+ // 13..14 : start area (grass)
+ var HOME_ROW = 0;
+ var RIVER_ROWS = [1,2,3,4,5];
+ var MEDIAN_ROW = 6;
+ var ROAD_ROWS = [7,8,9,10,11];
+ var SAFE_ROW = 12;
+ var START_ROW = 13;
+
+ // Home slots: positions (in columns) that must be filled
+ var HOME_SLOTS = []; // {col, filled}
+ function setupHomeSlots(){
+ HOME_SLOTS = [];
+ var cols = [1,4,6,8,11]; // 5 slots spread across
+ for(var i=0;i<cols.length;i++){
+ HOME_SLOTS.push({col:cols[i], filled:false});
+ }
+ }
+
+ // ---------- Entities ----------
+ var lanes = []; // active moving lanes: {row, type:'car'|'log', dir, speed, gap, len, color, items:[{x}]}
+ function buildLanes(level){
+ lanes = [];
+ var baseSpeed = 1.4 + (level-1)*0.28; // cells/sec baseline
+ // Road lanes (cars)
+ var carColors = ["#ff3b6b","#ffd23b","#3bff9d","#5b8cff","#ff8f3b"];
+ for(var r=0;r<ROAD_ROWS.length;r++){
+ var row = ROAD_ROWS[r];
+ var dir = (r%2===0)? 1 : -1;
+ var speed = baseSpeed*(0.75 + r*0.16) * (1 + (level-1)*0.05);
+ var len = 1 + (r%2); // 1 or 2 cells long
+ var gap = 3.2 + (r%3)*0.9;
+ lanes.push(makeLane(row,'car',dir,speed,gap,len,carColors[r%carColors.length]));
+ }
+ // River lanes (logs & turtles)
+ for(var rr=0;rr<RIVER_ROWS.length;rr++){
+ var rw = RIVER_ROWS[rr];
+ var d = (rr%2===0)? -1 : 1;
+ var sp = baseSpeed*(0.6 + rr*0.14) * (1 + (level-1)*0.05);
+ var isTurtle = (rr%2===1);
+ var llen = isTurtle ? (2 + (rr%2)) : (2 + (rr%3)); // 2..4
+ var lgap = 2.6 + (rr%2)*0.8;
+ var lane = makeLane(rw, 'log', d, sp, lgap, llen, isTurtle?"#2ee6c8":"#c8975a");
+ lane.turtle = isTurtle;
+ lanes.push(lane);
+ }
+ }
+ function makeLane(row,type,dir,speed,gap,len,color){
+ var items = [];
+ var span = len + gap;
+ // fill across a wider range so wrap looks continuous
+ var total = Math.ceil((COLS + span*2)/span);
+ var startX = -span;
+ for(var i=0;i<total;i++){
+ items.push({ x: startX + i*span + (Math.random()*0.4-0.2) });
+ }
+ return {row:row,type:type,dir:dir,speed:speed,gap:gap,len:len,color:color,items:items,span:span};
+ }
+
+ // ---------- Player ----------
+ var player = {col:6, row:START_ROW, px:6, py:START_ROW, riding:null, alive:true};
+ function resetPlayerPos(){
+ player.col = 6; player.row = START_ROW;
+ player.px = 6; player.py = START_ROW;
+ player.riding = null; player.alive = true;
+ }
+
+ // ---------- Game State ----------
+ var state = "title"; // title | play | dead | over | win
+ var level = 1;
+ var lives = 3;
+ var score = 0;
+ var best = 0;
+ try{ var b = localStorage.getItem(BEST_KEY); if(b){ best = parseInt(b,10)||0; } }catch(e){}
+ var timeMax = 30; // seconds per crossing
+ var timeLeft = timeMax;
+ var deadTimer = 0;
+ var farthestRow = START_ROW;
+
+ function startGame(){
+ level = 1; lives = 3; score = 0;
+ setupHomeSlots();
+ buildLanes(level);
+ resetPlayerPos();
+ timeMax = 30; timeLeft = timeMax;
+ farthestRow = START_ROW;
+ state = "play";
+ }
+ function nextLevel(){
+ level++;
+ setupHomeSlots();
+ buildLanes(level);
+ resetPlayerPos();
+ timeMax = Math.max(18, 30 - (level-1)*2);
+ timeLeft = timeMax;
+ farthestRow = START_ROW;
+ score += 500; // level bonus
+ state = "play";
+ sLevel();
+ }
+ function loseLife(){
+ lives--;
+ sSplat();
+ if(lives<=0){
+ commitBest();
+ state = "over";
+ }else{
+ state = "dead";
+ deadTimer = 0.9;
+ }
+ }
+ function commitBest(){
+ if(score>best){
+ best = score;
+ try{ localStorage.setItem(BEST_KEY, String(best)); }catch(e){}
+ }
+ }
+
+ // ---------- Movement ----------
+ function tryMove(dc,dr){
+ if(state!=="play" || !player.alive) return;
+ var nc = player.col + dc;
+ var nr = player.row + dr;
+ if(nc<0||nc>=COLS||nr<0||nr>ROWS-1) return;
+ // Landing on home row: must hit an empty slot
+ if(nr===HOME_ROW){
+ var slot = null;
+ for(var i=0;i<HOME_SLOTS.length;i++){
+ if(!HOME_SLOTS[i].filled && Math.abs(HOME_SLOTS[i].col - nc) <= 0){
+ slot = HOME_SLOTS[i]; break;
+ }
+ }
+ // allow snapping to nearest empty slot within 1 col
+ if(!slot){
+ for(var j=0;j<HOME_SLOTS.length;j++){
+ if(!HOME_SLOTS[j].filled && Math.abs(HOME_SLOTS[j].col - nc) === 1){
+ slot = HOME_SLOTS[j]; break;
+ }
+ }
+ }
+ if(slot){
+ slot.filled = true;
+ score += 100 + Math.round(timeLeft*10);
+ sHome();
+ // Check all filled
+ var allFilled = true;
+ for(var k=0;k<HOME_SLOTS.length;k++){ if(!HOME_SLOTS[k].filled){ allFilled=false; break; } }
+ if(allFilled){
+ nextLevel();
+ }else{
+ resetPlayerPos();
+ timeLeft = timeMax;
+ farthestRow = START_ROW;
+ }
+ return;
+ }else{
+ // hit the wall between slots => death
+ player.col = nc; player.row = nr;
+ player.px = nc; player.py = nr;
+ player.alive = false;
+ loseLife();
+ return;
+ }
+ }
+ player.col = nc; player.row = nr;
+ player.px = nc; player.py = nr;
+ player.riding = null;
+ // forward progress score
+ if(nr < farthestRow){
+ farthestRow = nr;
+ score += 10;
+ }
+ sHop();
+ }
+
+ // ---------- Update ----------
+ function update(dt){
+ if(state==="dead"){
+ deadTimer -= dt;
+ if(deadTimer<=0){
+ resetPlayerPos();
+ timeLeft = timeMax;
+ farthestRow = START_ROW;
+ state = "play";
+ }
+ return;
+ }
+ if(state!=="play") return;
+
+ // move lanes
+ for(var i=0;i<lanes.length;i++){
+ var ln = lanes[i];
+ var d = ln.dir * ln.speed * dt;
+ for(var m=0;m<ln.items.length;m++){
+ ln.items[m].x += d;
+ // wrap
+ if(ln.dir>0 && ln.items[m].x > COLS+1){
+ ln.items[m].x -= ln.span * ln.items.length;
+ }else if(ln.dir<0 && ln.items[m].x < -ln.len-1){
+ ln.items[m].x += ln.span * ln.items.length;
+ }
+ }
+ }
+
+ // timer
+ timeLeft -= dt;
+ if(timeLeft<=0){
+ timeLeft = 0;
+ player.alive = false;
+ loseLife();
+ return;
+ }
+
+ // River carry / drown logic
+ var row = player.row;
+ var inRiver = (RIVER_ROWS.indexOf(row) !== -1);
+ if(inRiver){
+ var onLog = null;
+ for(var L=0;L<lanes.length;L++){
+ if(lanes[L].row!==row) continue;
+ var lane = lanes[L];
+ for(var it=0; it<lane.items.length; it++){
+ var x0 = lane.items[it].x;
+ var x1 = x0 + lane.len;
+ // player center on this log?
+ if(player.px+0.5 >= x0-0.05 && player.px+0.5 <= x1+0.05){
+ onLog = {lane:lane, item:lane.items[it]};
+ break;
+ }
+ }
+ if(onLog) break;
+ }
+ if(onLog){
+ // carried by log
+ player.px += onLog.lane.dir * onLog.lane.speed * dt;
+ player.riding = onLog;
+ // carried off screen => drown
+ if(player.px < -0.6 || player.px > COLS-0.4){
+ player.alive = false;
+ loseLife();
+ return;
+ }
+ player.col = Math.round(player.px);
+ }else{
+ // in water, no log => drown
+ player.alive = false;
+ loseLife();
+ return;
+ }
+ }else{
+ player.riding = null;
+ player.px = player.col;
+ }
+
+ // Road collision
+ if(ROAD_ROWS.indexOf(row) !== -1){
+ for(var c=0;c<lanes.length;c++){
+ if(lanes[c].row!==row) continue;
+ var cl = lanes[c];
+ for(var ci=0; ci<cl.items.length; ci++){
+ var cx0 = cl.items[ci].x;
+ var cx1 = cx0 + cl.len;
+ var pcx = player.col + 0.5;
+ if(pcx > cx0 && pcx < cx1){
+ player.alive = false;
+ loseLife();
+ return;
+ }
+ }
+ }
+ }
+ }
+
+ // ---------- Render ----------
+ function drawRoundRect(x,y,w,h,r){
+ var rr = Math.min(r, w/2, h/2);
+ ctx.beginPath();
+ ctx.moveTo(x+rr,y);
+ ctx.arcTo(x+w,y,x+w,y+h,rr);
+ ctx.arcTo(x+w,y+h,x,y+h,rr);
+ ctx.arcTo(x,y+h,x,y,rr);
+ ctx.arcTo(x,y,x+w,y,rr);
+ ctx.closePath();
+ }
+
+ function render(){
+ ctx.save();
+ ctx.setTransform(DPR,0,0,DPR,0,0);
+ ctx.clearRect(0,0,canvas.width,canvas.height);
+ // full bg
+ ctx.fillStyle = "#0a0e14";
+ ctx.fillRect(0,0,canvas.width/DPR,canvas.height/DPR);
+ ctx.translate(offX, offY);
+
+ // playfield bg
+ ctx.fillStyle = "#080b11";
+ ctx.fillRect(0,0,W,H);
+
+ // ----- row backgrounds -----
+ // home band
+ ctx.fillStyle = "#0d1a14";
+ ctx.fillRect(0, HOME_ROW*cell, W, cell);
+ // river band
+ ctx.fillStyle = "#071726";
+ ctx.fillRect(0, RIVER_ROWS[0]*cell, W, RIVER_ROWS.length*cell);
+ // subtle water lines
+ ctx.strokeStyle = "rgba(60,140,220,0.12)";
+ ctx.lineWidth = 1;
+ for(var rl=0; rl<RIVER_ROWS.length; rl++){
+ var ry = (RIVER_ROWS[rl])*cell + cell*0.5;
+ ctx.beginPath(); ctx.moveTo(0,ry); ctx.lineTo(W,ry); ctx.stroke();
+ }
+ // median
+ ctx.fillStyle = "#12241a";
+ ctx.fillRect(0, MEDIAN_ROW*cell, W, cell);
+ // road band
+ ctx.fillStyle = "#0c0f16";
+ ctx.fillRect(0, ROAD_ROWS[0]*cell, W, ROAD_ROWS.length*cell);
+ // lane dashes
+ ctx.strokeStyle = "rgba(255,255,255,0.10)";
+ ctx.setLineDash([cell*0.35, cell*0.3]);
+ ctx.lineWidth = 2;
+ for(var rd=0; rd<ROAD_ROWS.length; rd++){
+ var yy = (ROAD_ROWS[rd])*cell + cell;
+ if(rd<ROAD_ROWS.length){
+ ctx.beginPath(); ctx.moveTo(0,yy); ctx.lineTo(W,yy); ctx.stroke();
+ }
+ }
+ ctx.setLineDash([]);
+ // safe & start grass
+ ctx.fillStyle = "#0e1f16";
+ ctx.fillRect(0, SAFE_ROW*cell, W, (ROWS-SAFE_ROW)*cell);
+
+ // neon band edges
+ ctx.strokeStyle = "rgba(46,230,200,0.25)";
+ ctx.lineWidth = 2;
+ ctx.strokeRect(1,1,W-2,H-2);
+
+ // ----- home slots -----
+ for(var hs=0; hs<HOME_SLOTS.length; hs++){
+ var slot = HOME_SLOTS[hs];
+ var sx = slot.col*cell;
+ var sy = HOME_ROW*cell;
+ ctx.save();
+ if(slot.filled){
+ ctx.shadowColor = "#2ee6c8"; ctx.shadowBlur = 14;
+ ctx.fillStyle = "#2ee6c8";
+ drawRoundRect(sx+cell*0.16, sy+cell*0.16, cell*0.68, cell*0.68, 8);
+ ctx.fill();
+ ctx.shadowBlur = 0;
+ ctx.fillStyle = "#04120e";
+ ctx.fillRect(sx+cell*0.42, sy+cell*0.28, cell*0.16, cell*0.34);
+ }else{
+ ctx.strokeStyle = "rgba(120,255,220,0.55)";
+ ctx.lineWidth = 2;
+ drawRoundRect(sx+cell*0.14, sy+cell*0.14, cell*0.72, cell*0.72, 8);
+ ctx.stroke();
+ }
+ ctx.restore();
+ }
+
+ // ----- lanes -----
+ for(var i=0;i<lanes.length;i++){
+ var ln = lanes[i];
+ var y = ln.row*cell;
+ for(var m=0;m<ln.items.length;m++){
+ var ix = ln.items[m].x*cell;
+ if(ix > W+cell || ix < -ln.len*cell-cell) continue;
+ if(ln.type==='car'){
+ ctx.save();
+ ctx.shadowColor = ln.color; ctx.shadowBlur = 12;
+ ctx.fillStyle = ln.color;
+ drawRoundRect(ix+3, y+cell*0.18, ln.len*cell-6, cell*0.64, 7);
+ ctx.fill();
+ ctx.shadowBlur = 0;
+ // windows
+ ctx.fillStyle = "rgba(10,14,20,0.6)";
+ ctx.fillRect(ix+ (ln.dir>0? ln.len*cell-16 : 8), y+cell*0.28, 8, cell*0.44);
+ ctx.restore();
+ }else{
+ // log or turtle
+ ctx.save();
+ if(ln.turtle){
+ ctx.shadowColor = ln.color; ctx.shadowBlur = 10;
+ ctx.fillStyle = ln.color;
+ for(var t=0;t<ln.len;t++){
+ ctx.beginPath();
+ ctx.ellipse(ix + t*cell + cell*0.5, y+cell*0.5, cell*0.34, cell*0.30, 0, 0, Math.PI*2);
+ ctx.fill();
+ }
+ ctx.shadowBlur=0;
+ ctx.fillStyle="rgba(4,30,26,0.5)";
+ for(var t2=0;t2<ln.len;t2++){
+ ctx.beginPath();
+ ctx.ellipse(ix + t2*cell + cell*0.5, y+cell*0.5, cell*0.16, cell*0.14, 0, 0, Math.PI*2);
+ ctx.fill();
+ }
+ }else{
+ ctx.shadowColor = "rgba(200,150,90,0.5)"; ctx.shadowBlur = 8;
+ ctx.fillStyle = ln.color;
+ drawRoundRect(ix+2, y+cell*0.20, ln.len*cell-4, cell*0.60, 9);
+ ctx.fill();
+ ctx.shadowBlur=0;
+ ctx.strokeStyle="rgba(80,50,20,0.5)";
+ ctx.lineWidth=1;
+ for(var g=1;g<ln.len;g++){
+ ctx.beginPath();
+ ctx.moveTo(ix+g*cell, y+cell*0.22);
+ ctx.lineTo(ix+g*cell, y+cell*0.78);
+ ctx.stroke();
+ }
+ }
+ ctx.restore();
+ }
+ }
+ }
+
+ // ----- player -----
+ if(state!=="over" && state!=="title"){
+ var pxPix = player.px*cell;
+ var pyPix = player.row*cell;
+ ctx.save();
+ var glow = player.alive ? "#7dffb0" : "#ff5b6b";
+ ctx.shadowColor = glow; ctx.shadowBlur = 18;
+ ctx.fillStyle = glow;
+ // body
+ drawRoundRect(pxPix+cell*0.18, pyPix+cell*0.18, cell*0.64, cell*0.64, 10);
+ ctx.fill();
+ ctx.shadowBlur = 0;
+ // eyes
+ ctx.fillStyle = "#04120e";
+ ctx.beginPath();
+ ctx.arc(pxPix+cell*0.38, pyPix+cell*0.40, cell*0.07, 0, Math.PI*2);
+ ctx.arc(pxPix+cell*0.62, pyPix+cell*0.40, cell*0.07, 0, Math.PI*2);
+ ctx.fill();
+ ctx.restore();
+ }
+
+ // ----- HUD -----
+ drawHUD();
+
+ // overlays
+ if(state==="title") drawTitle();
+ else if(state==="over") drawOver();
+
+ ctx.restore();
+ }
+
+ function drawHUD(){
+ var pad = 6;
+ ctx.save();
+ ctx.textBaseline = "top";
+ // score
+ var fs = Math.max(11, Math.round(cell*0.32));
+ ctx.font = "bold "+fs+"px 'Trebuchet MS',sans-serif";
+ ctx.fillStyle = "#8ff";
+ ctx.textAlign = "left";
+ ctx.fillText("SCORE "+score, pad+2, pad);
+ ctx.fillStyle = "#8fa";
+ ctx.textAlign = "center";
+ ctx.fillText("BEST "+best, W/2, pad);
+ // lives
+ ctx.textAlign = "right";
+ ctx.fillStyle = "#ff7d9c";
+ var lifeStr = "";
+ for(var i=0;i<lives;i++){ lifeStr += "♥"; }
+ ctx.fillText("LV"+level+" "+lifeStr, W-pad-2, pad);
+
+ // timer bar
+ if(state==="play" || state==="dead"){
+ var barY = HOME_ROW*cell + cell + 3;
+ var barH = 5;
+ var frac = Math.max(0, timeLeft/timeMax);
+ ctx.fillStyle = "rgba(255,255,255,0.08)";
+ ctx.fillRect(6, barY, W-12, barH);
+ var col = frac>0.5? "#2ee6c8" : (frac>0.25? "#ffd23b" : "#ff3b6b");
+ ctx.shadowColor = col; ctx.shadowBlur = 8;
+ ctx.fillStyle = col;
+ ctx.fillRect(6, barY, (W-12)*frac, barH);
+ ctx.shadowBlur = 0;
+ }
+ ctx.restore();
+ }
+
+ function neonTitleText(txt, cx, cy, size, color){
+ ctx.save();
+ ctx.textAlign = "center";
+ ctx.textBaseline = "middle";
+ ctx.font = "bold "+size+"px 'Trebuchet MS',sans-serif";
+ // letter-spacing manual
+ var ls = size*0.16;
+ var chars = txt.split("");
+ var total = 0;
+ for(var i=0;i<chars.length;i++){ total += ctx.measureText(chars[i]).width + ls; }
+ total -= ls;
+ var x = cx - total/2;
+ ctx.shadowColor = color; ctx.shadowBlur = 22;
+ ctx.fillStyle = color;
+ for(var j=0;j<chars.length;j++){
+ var w = ctx.measureText(chars[j]).width;
+ ctx.fillText(chars[j], x + w/2, cy);
+ x += w + ls;
+ }
+ ctx.restore();
+ }
+
+ function drawTitle(){
+ ctx.save();
+ ctx.fillStyle = "rgba(4,7,12,0.82)";
+ ctx.fillRect(0,0,W,H);
+ neonTitleText("HOPPER", W/2, H*0.30, Math.round(cell*1.05), "#2ee6c8");
+ ctx.textAlign = "center";
+ ctx.fillStyle = "#bfe";
+ var s = Math.max(12, Math.round(cell*0.34));
+ ctx.font = s+"px 'Trebuchet MS',sans-serif";
+ ctx.fillText("Cross the road & river", W/2, H*0.44);
+ ctx.fillText("Fill all home slots", W/2, H*0.50);
+ ctx.fillStyle = "#8fa";
+ ctx.font = "bold "+s+"px 'Trebuchet MS',sans-serif";
+ ctx.fillText("ARROWS / WASD to hop", W/2, H*0.60);
+ // pulsing prompt
+ var a = 0.55 + 0.45*Math.sin(Date.now()/300);
+ ctx.fillStyle = "rgba(125,255,176,"+a.toFixed(3)+")";
+ ctx.font = "bold "+Math.round(cell*0.42)+"px 'Trebuchet MS',sans-serif";
+ ctx.fillText("TAP / PRESS TO START", W/2, H*0.72);
+ ctx.restore();
+ }
+
+ function drawOver(){
+ ctx.save();
+ ctx.fillStyle = "rgba(12,4,8,0.85)";
+ ctx.fillRect(0,0,W,H);
+ neonTitleText("GAME OVER", W/2, H*0.34, Math.round(cell*0.82), "#ff3b6b");
+ ctx.textAlign="center";
+ var s = Math.max(12, Math.round(cell*0.4));
+ ctx.font = "bold "+s+"px 'Trebuchet MS',sans-serif";
+ ctx.fillStyle = "#8ff";
+ ctx.fillText("SCORE "+score, W/2, H*0.48);
+ ctx.fillStyle = "#8fa";
+ ctx.fillText("BEST "+best, W/2, H*0.55);
+ var a = 0.55 + 0.45*Math.sin(Date.now()/300);
+ ctx.fillStyle = "rgba(125,255,176,"+a.toFixed(3)+")";
+ ctx.font = "bold "+Math.round(cell*0.42)+"px 'Trebuchet MS',sans-serif";
+ ctx.fillText("TAP / PRESS TO RETRY", W/2, H*0.68);
+ ctx.restore();
+ }
+
+ // ---------- D-pad (touch) ----------
+ var dpad = null; // {cx,cy,r} in canvas coords
+ function computeDpad(){
+ // place bottom-left area over start grass
+ var r = cell*1.1;
+ dpad = { cx: r*1.15, cy: H - r*1.15, r: r };
+ }
+ function drawDpad(){
+ if(!dpad) return;
+ if(state!=="play" && state!=="dead") return;
+ ctx.save();
+ ctx.translate(offX, offY);
+ ctx.globalAlpha = 0.5;
+ var d = dpad;
+ var arm = d.r*0.62, w = d.r*0.42;
+ ctx.fillStyle = "rgba(46,230,200,0.14)";
+ ctx.strokeStyle = "rgba(46,230,200,0.55)";
+ ctx.lineWidth = 2;
+ function armRect(ang){
+ ctx.save();
+ ctx.translate(d.cx,d.cy);
+ ctx.rotate(ang);
+ drawRoundRect(w*0.2, -w/2, arm, w, 6);
+ ctx.fill(); ctx.stroke();
+ // arrow tip
+ ctx.fillStyle="rgba(180,255,235,0.8)";
+ ctx.beginPath();
+ ctx.moveTo(arm+w*0.2, 0);
+ ctx.lineTo(arm-w*0.05, -w*0.28);
+ ctx.lineTo(arm-w*0.05, w*0.28);
+ ctx.closePath();
+ ctx.fill();
+ ctx.fillStyle="rgba(46,230,200,0.14)";
+ ctx.restore();
+ }
+ armRect(-Math.PI/2); // up
+ armRect(Math.PI/2); // down
+ armRect(Math.PI); // left
+ armRect(0); // right
+ ctx.restore();
+ }
+ function dpadHit(x,y){
+ if(!dpad) return null;
+ var dx = x - dpad.cx, dy = y - dpad.cy;
+ var dist = Math.sqrt(dx*dx+dy*dy);
+ if(dist > dpad.r*1.35) return null;
+ if(dist < dpad.r*0.18) return null;
+ if(Math.abs(dx) > Math.abs(dy)){
+ return dx>0 ? "right" : "left";
+ }else{
+ return dy>0 ? "down" : "up";
+ }
+ }
+
+ // ---------- Input ----------
+ function handleDir(dir){
+ if(dir==="up") tryMove(0,-1);
+ else if(dir==="down") tryMove(0,1);
+ else if(dir==="left") tryMove(-1,0);
+ else if(dir==="right") tryMove(1,0);
+ }
+
+ function onKey(e){
+ var k = e.key;
+ var used = false;
+ if(k==="ArrowUp"||k==="w"||k==="W"){ used=true; act("up"); }
+ else if(k==="ArrowDown"||k==="s"||k==="S"){ used=true; act("down"); }
+ else if(k==="ArrowLeft"||k==="a"||k==="A"){ used=true; act("left"); }
+ else if(k==="ArrowRight"||k==="d"||k==="D"){ used=true; act("right"); }
+ else if(k===" "||k==="Enter"){ used=true; act("start"); }
+ if(used){ e.preventDefault(); }
+ }
+ function act(dir){
+ ensureAudio();
+ if(state==="title"){ startGame(); return; }
+ if(state==="over"){ startGame(); return; }
+ handleDir(dir);
+ }
+
+ window.addEventListener("keydown", onKey, {passive:false});
+
+ // touch / pointer
+ function canvasPoint(e){
+ var rect = canvas.getBoundingClientRect();
+ var cx = (e.clientX - rect.left) - offX;
+ var cy = (e.clientY - rect.top) - offY;
+ return {x:cx, y:cy};
+ }
+ function onPointerDown(e){
+ e.preventDefault();
+ ensureAudio();
+ if(state==="title"||state==="over"){ startGame(); return; }
+ var p = canvasPoint(e);
+ var hit = dpadHit(p.x, p.y);
+ if(hit){ handleDir(hit); return; }
+ // swipe-region fallback: tap relative to player => hop toward tap
+ var pcx = (player.px+0.5)*cell;
+ var pcy = (player.row+0.5)*cell;
+ var dx = p.x - pcx, dy = p.y - pcy;
+ if(Math.abs(dx)<cell*0.4 && Math.abs(dy)<cell*0.4){ tryMove(0,-1); return; }
+ if(Math.abs(dx) > Math.abs(dy)){ handleDir(dx>0?"right":"left"); }
+ else{ handleDir(dy>0?"down":"up"); }
+ }
+ canvas.addEventListener("pointerdown", onPointerDown, {passive:false});
+ canvas.addEventListener("touchstart", function(e){ e.preventDefault(); }, {passive:false});
+
+ // ---------- Resize ----------
+ function resize(){
+ var wrap = document.getElementById("wrap");
+ var availW = wrap.clientWidth || window.innerWidth;
+ var availH = wrap.clientHeight || window.innerHeight;
+ if(availW<=0) availW = window.innerWidth;
+ if(availH<=0) availH = window.innerHeight;
+ DPR = Math.min(window.devicePixelRatio||1, 2);
+
+ // choose cell to fit both dims
+ var cw = Math.floor(availW / COLS);
+ var ch = Math.floor(availH / ROWS);
+ cell = Math.max(12, Math.min(cw, ch));
+ W = COLS*cell; H = ROWS*cell;
+
+ canvas.width = Math.floor(availW*DPR);
+ canvas.height = Math.floor(availH*DPR);
+ canvas.style.width = availW+"px";
+ canvas.style.height = availH+"px";
+
+ offX = Math.floor((availW - W)/2);
+ offY = Math.floor((availH - H)/2);
+
+ computeDpad();
+
+ // rebuild lane item x extents not needed (grid-relative), but ensure lanes exist
+ if(lanes.length===0 && state!=="title"){ buildLanes(level); }
+ }
+ window.addEventListener("resize", resize);
+
+ // ---------- Loop ----------
+ var last = 0;
+ function frame(ts){
+ if(!last) last = ts;
+ var dt = (ts - last)/1000;
+ last = ts;
+ if(dt>0.1) dt = 0.1; // clamp
+ update(dt);
+ render();
+ drawDpad();
+ requestAnimationFrame(frame);
+ }
+
+ // ---------- Init ----------
+ setupHomeSlots();
+ resize();
+ requestAnimationFrame(frame);
+ }
+
+ if(document.readyState === "loading"){
+ document.addEventListener("DOMContentLoaded", boot);
+ }else{
+ boot();
+ }
+})();
+</script>
+</body>
+</html>
diff --git a/games/invaders/index.html b/games/invaders/index.html
new file mode 100644
index 0000000..0b75e8c
--- /dev/null
+++ b/games/invaders/index.html
@@ -0,0 +1,891 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
+<title>NEON INVADERS</title>
+<style>
+ * { margin: 0; padding: 0; box-sizing: border-box; }
+ html, body {
+ width: 100%; height: 100%;
+ background: #05070c;
+ overflow: hidden;
+ touch-action: none;
+ -webkit-user-select: none; user-select: none;
+ -webkit-tap-highlight-color: transparent;
+ font-family: "Courier New", ui-monospace, monospace;
+ }
+ #wrap {
+ position: fixed; inset: 0;
+ display: flex; flex-direction: column;
+ align-items: center; justify-content: center;
+ background:
+ radial-gradient(ellipse at 50% 30%, #101a2b 0%, #0a0e14 55%, #05070c 100%);
+ }
+ #title {
+ color: #39ff14;
+ font-weight: bold;
+ letter-spacing: 0.42em;
+ text-transform: uppercase;
+ font-size: clamp(14px, 3.6vw, 26px);
+ text-shadow: 0 0 8px #39ff14, 0 0 18px rgba(57,255,20,.5);
+ padding: 8px 0 6px;
+ text-align: center;
+ }
+ #stage {
+ position: relative;
+ border: 1px solid rgba(57,255,20,.28);
+ box-shadow: 0 0 22px rgba(57,255,20,.15), inset 0 0 40px rgba(10,20,40,.6);
+ background: #060a12;
+ border-radius: 6px;
+ overflow: hidden;
+ }
+ canvas { display: block; touch-action: none; }
+ #touch {
+ position: absolute; inset: 0;
+ display: none;
+ pointer-events: none;
+ }
+ .tbtn {
+ position: absolute;
+ pointer-events: auto;
+ display: flex; align-items: center; justify-content: center;
+ color: #7ffcff;
+ border: 1px solid rgba(127,252,255,.4);
+ background: rgba(10,20,35,.35);
+ border-radius: 12px;
+ font-size: 26px; font-weight: bold;
+ text-shadow: 0 0 6px #7ffcff;
+ backdrop-filter: blur(2px);
+ user-select: none;
+ }
+ .tbtn:active { background: rgba(127,252,255,.22); }
+ #bLeft { left: 12px; bottom: 14px; width: 62px; height: 62px; }
+ #bRight { left: 88px; bottom: 14px; width: 62px; height: 62px; }
+ #bFire { right: 14px; bottom: 14px; width: 78px; height: 78px;
+ color:#ff4fd8; border-color:rgba(255,79,216,.5); text-shadow:0 0 6px #ff4fd8; }
+ #hint {
+ color: rgba(160,200,220,.55);
+ font-size: clamp(9px, 1.9vw, 12px);
+ letter-spacing: .12em;
+ padding: 8px 6px 10px;
+ text-align: center;
+ }
+</style>
+</head>
+<body>
+ <div id="wrap">
+ <div id="title">Neon Invaders</div>
+ <div id="stage">
+ <canvas id="c"></canvas>
+ <div id="touch">
+ <div class="tbtn" id="bLeft">◀</div>
+ <div class="tbtn" id="bRight">▶</div>
+ <div class="tbtn" id="bFire">FIRE</div>
+ </div>
+ </div>
+ <div id="hint">← → / A D MOVE • SPACE FIRE • P PAUSE</div>
+ </div>
+
+<script>
+(function () {
+ "use strict";
+
+ var BEST_KEY = "abrams-invaders-best";
+
+ function boot() {
+ var canvas = document.getElementById("c");
+ var ctx = canvas.getContext("2d");
+ var stage = document.getElementById("stage");
+ var touchLayer = document.getElementById("touch");
+
+ // Logical resolution (game world). Canvas is scaled to fit.
+ var W = 480, H = 640;
+ var DPR = 1;
+
+ function fitCanvas() {
+ // Available space inside the wrap, minus title/hint chrome.
+ var availW = Math.min(window.innerWidth - 12, 520);
+ var availH = window.innerHeight - 120;
+ if (availW < 200) availW = 200;
+ if (availH < 300) availH = 300;
+ var scale = Math.min(availW / W, availH / H);
+ if (!isFinite(scale) || scale <= 0) scale = 1;
+ var cssW = Math.round(W * scale);
+ var cssH = Math.round(H * scale);
+ DPR = Math.min(window.devicePixelRatio || 1, 2);
+ canvas.style.width = cssW + "px";
+ canvas.style.height = cssH + "px";
+ canvas.width = Math.round(W * DPR);
+ canvas.height = Math.round(H * DPR);
+ stage.style.width = cssW + "px";
+ stage.style.height = cssH + "px";
+ ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
+ }
+
+ // ---- Audio (lazy) ----
+ var actx = null, muted = false;
+ function ensureAudio() {
+ if (actx || muted) return;
+ try {
+ var AC = window.AudioContext || window.webkitAudioContext;
+ if (!AC) { muted = true; return; }
+ actx = new AC();
+ } catch (e) { muted = true; actx = null; }
+ }
+ function blip(type, freq, dur, vol) {
+ if (!actx) return;
+ try {
+ if (actx.state === "suspended") { actx.resume().catch(function(){}); }
+ var o = actx.createOscillator();
+ var g = actx.createGain();
+ o.type = type;
+ o.frequency.value = freq;
+ var t = actx.currentTime;
+ g.gain.setValueAtTime(0.0001, t);
+ g.gain.exponentialRampToValueAtTime(vol, 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);
+ } catch (e) { /* never throw from audio */ }
+ }
+ function sShoot() { blip("square", 880, 0.09, 0.05); }
+ function sHit() { blip("triangle", 320, 0.10, 0.06); }
+ function sBoom() { blip("sawtooth", 120, 0.32, 0.08); }
+ function sUfo() { blip("sine", 1200, 0.14, 0.05); }
+ function sStep(i) { blip("square", 200 + (i % 4) * 30, 0.05, 0.03); }
+
+ // ---- Game state ----
+ var STATE = { MENU: 0, PLAY: 1, WAVE: 2, OVER: 3, PAUSED: 4 };
+ var state = STATE.MENU;
+
+ var best = 0;
+ try {
+ var stored = localStorage.getItem(BEST_KEY);
+ if (stored != null) { var n = parseInt(stored, 10); if (!isNaN(n)) best = n; }
+ } catch (e) { best = 0; }
+
+ var score = 0, lives = 3, wave = 1;
+
+ var player, bullets, bombs, invaders, bunkers, ufo, particles;
+ var invDir, invStepTimer, invStepInterval, invDropPending;
+ var fireCooldown, ufoTimer, stepIndex;
+ var flashTimer = 0;
+
+ var keys = {};
+ var touch = { left: false, right: false, fire: false };
+
+ // ---- Entity setup ----
+ function makePlayer() {
+ return { x: W / 2, y: H - 46, w: 40, h: 18, speed: 260, cool: 0 };
+ }
+
+ function makeBunkers() {
+ var arr = [];
+ var count = 4;
+ var bw = 58, bh = 40;
+ var gap = (W - count * bw) / (count + 1);
+ var by = H - 130;
+ for (var b = 0; b < count; b++) {
+ var bx = gap + b * (bw + gap);
+ var cellsX = 7, cellsY = 5;
+ var cw = bw / cellsX, ch = bh / cellsY;
+ var cells = [];
+ for (var yy = 0; yy < cellsY; yy++) {
+ for (var xx = 0; xx < cellsX; xx++) {
+ // carve an arch shape
+ var isNotch = (yy >= 3) && (xx >= 2 && xx <= 4);
+ if (isNotch) continue;
+ cells.push({
+ x: bx + xx * cw, y: by + yy * ch,
+ w: cw, h: ch, hp: 3
+ });
+ }
+ }
+ arr.push({ cells: cells });
+ }
+ return arr;
+ }
+
+ function buildInvaders() {
+ invaders = [];
+ var cols = 8, rows = 5;
+ var cellW = 44, cellH = 34;
+ var gridW = cols * cellW;
+ var startX = (W - gridW) / 2 + cellW / 2;
+ var startY = 70;
+ for (var r = 0; r < rows; r++) {
+ for (var col = 0; col < cols; col++) {
+ var type = r === 0 ? 2 : (r < 3 ? 1 : 0);
+ invaders.push({
+ x: startX + col * cellW,
+ y: startY + r * cellH,
+ w: 28, h: 20,
+ type: type,
+ alive: true,
+ row: r, col: col
+ });
+ }
+ }
+ invDir = 1;
+ invStepTimer = 0;
+ invStepInterval = Math.max(0.18, 0.75 - (wave - 1) * 0.06);
+ invDropPending = false;
+ stepIndex = 0;
+ }
+
+ function startGame() {
+ score = 0; lives = 3; wave = 1;
+ player = makePlayer();
+ bullets = []; bombs = []; particles = [];
+ bunkers = makeBunkers();
+ ufo = null;
+ fireCooldown = 0;
+ ufoTimer = rand(8, 16);
+ buildInvaders();
+ state = STATE.PLAY;
+ }
+
+ function nextWave() {
+ wave++;
+ player.x = W / 2;
+ bullets = []; bombs = [];
+ bunkers = makeBunkers();
+ ufo = null;
+ ufoTimer = rand(8, 16);
+ buildInvaders();
+ state = STATE.PLAY;
+ flashTimer = 0.6;
+ }
+
+ function rand(a, b) { return a + Math.random() * (b - a); }
+
+ // ---- Particles ----
+ function burst(x, y, color, n) {
+ for (var i = 0; i < n; i++) {
+ var a = Math.random() * Math.PI * 2;
+ var sp = rand(30, 160);
+ particles.push({
+ x: x, y: y,
+ vx: Math.cos(a) * sp, vy: Math.sin(a) * sp,
+ life: rand(0.3, 0.7), max: 0.7, color: color
+ });
+ }
+ }
+
+ // ---- Collision ----
+ function overlap(ax, ay, aw, ah, bx, by, bw, bh) {
+ return ax < bx + bw && ax + aw > bx && ay < by + bh && ay + ah > by;
+ }
+
+ // ---- Update ----
+ function update(dt) {
+ if (state !== STATE.PLAY) {
+ // still animate particles on OVER for a moment
+ updateParticles(dt);
+ return;
+ }
+
+ if (flashTimer > 0) flashTimer -= dt;
+
+ // player movement
+ var mv = 0;
+ if (keys.left || touch.left) mv -= 1;
+ if (keys.right || touch.right) mv += 1;
+ player.x += mv * player.speed * dt;
+ var half = player.w / 2;
+ if (player.x < half) player.x = half;
+ if (player.x > W - half) player.x = W - half;
+
+ // firing
+ if (player.cool > 0) player.cool -= dt;
+ if ((keys.fire || touch.fire) && player.cool <= 0) {
+ bullets.push({ x: player.x, y: player.y - 12, w: 3, h: 14, vy: -520 });
+ player.cool = 0.34;
+ sShoot();
+ }
+
+ // bullets
+ for (var i = bullets.length - 1; i >= 0; i--) {
+ var bl = bullets[i];
+ bl.y += bl.vy * dt;
+ if (bl.y + bl.h < 0) { bullets.splice(i, 1); continue; }
+ }
+
+ // invader movement (grid step)
+ updateInvaders(dt);
+
+ // bombs (from invaders)
+ for (var j = bombs.length - 1; j >= 0; j--) {
+ var bo = bombs[j];
+ bo.y += bo.vy * dt;
+ bo.t = (bo.t || 0) + dt;
+ if (bo.y > H) { bombs.splice(j, 1); continue; }
+ }
+
+ // UFO
+ updateUfo(dt);
+
+ // collisions
+ handleCollisions();
+
+ updateParticles(dt);
+
+ // win condition
+ var anyAlive = false;
+ for (var k = 0; k < invaders.length; k++) { if (invaders[k].alive) { anyAlive = true; break; } }
+ if (!anyAlive) {
+ state = STATE.WAVE;
+ waveDelay = 1.4;
+ }
+ }
+
+ var waveDelay = 0;
+
+ function updateInvaders(dt) {
+ var aliveList = [];
+ var minX = 1e9, maxX = -1e9, maxY = -1e9;
+ for (var i = 0; i < invaders.length; i++) {
+ var inv = invaders[i];
+ if (!inv.alive) continue;
+ aliveList.push(inv);
+ if (inv.x - inv.w / 2 < minX) minX = inv.x - inv.w / 2;
+ if (inv.x + inv.w / 2 > maxX) maxX = inv.x + inv.w / 2;
+ if (inv.y + inv.h / 2 > maxY) maxY = inv.y + inv.h / 2;
+ }
+ if (aliveList.length === 0) return;
+
+ // Speed scales up as fewer remain.
+ var frac = aliveList.length / invaders.length;
+ var interval = invStepInterval * (0.35 + 0.65 * frac);
+ invStepTimer += dt;
+ if (invStepTimer >= interval) {
+ invStepTimer = 0;
+ stepIndex++;
+ sStep(stepIndex);
+
+ var stepX = 12;
+ var willHit = (invDir > 0 && maxX + stepX >= W - 6) ||
+ (invDir < 0 && minX - stepX <= 6);
+ if (willHit) {
+ invDir *= -1;
+ for (var d = 0; d < aliveList.length; d++) aliveList[d].y += 18;
+ } else {
+ for (var m = 0; m < aliveList.length; m++) aliveList[m].x += stepX * invDir;
+ }
+
+ // reached player level -> game over
+ for (var q = 0; q < aliveList.length; q++) {
+ if (aliveList[q].y + aliveList[q].h / 2 >= player.y - 6) {
+ loseAllLives();
+ return;
+ }
+ }
+ }
+
+ // random bomb drops from bottom-most invaders per column
+ var dropChance = (0.35 + wave * 0.12) * dt;
+ if (Math.random() < dropChance && aliveList.length) {
+ // find a random column's lowest alive invader
+ var shooter = aliveList[(Math.random() * aliveList.length) | 0];
+ // ensure it's the lowest in its column
+ var lowest = shooter;
+ for (var c = 0; c < aliveList.length; c++) {
+ if (aliveList[c].col === shooter.col && aliveList[c].y > lowest.y) lowest = aliveList[c];
+ }
+ bombs.push({ x: lowest.x, y: lowest.y + 12, w: 5, h: 12, vy: rand(170, 210) + wave * 8, t: 0 });
+ }
+ }
+
+ function updateUfo(dt) {
+ if (ufo) {
+ ufo.x += ufo.vx * dt;
+ if ((ufo.vx > 0 && ufo.x > W + 40) || (ufo.vx < 0 && ufo.x < -40)) {
+ ufo = null;
+ }
+ } else {
+ ufoTimer -= dt;
+ if (ufoTimer <= 0) {
+ var fromLeft = Math.random() < 0.5;
+ ufo = {
+ x: fromLeft ? -30 : W + 30,
+ y: 42, w: 40, h: 16,
+ vx: fromLeft ? 90 : -90,
+ value: [50, 100, 150, 300][(Math.random() * 4) | 0]
+ };
+ ufoTimer = rand(12, 22);
+ }
+ }
+ }
+
+ function updateParticles(dt) {
+ for (var i = particles.length - 1; i >= 0; i--) {
+ var p = particles[i];
+ p.life -= dt;
+ if (p.life <= 0) { particles.splice(i, 1); continue; }
+ p.x += p.vx * dt;
+ p.y += p.vy * dt;
+ p.vy += 120 * dt;
+ }
+ }
+
+ function damageBunkerAt(x, y, w, h) {
+ for (var b = 0; b < bunkers.length; b++) {
+ var cells = bunkers[b].cells;
+ for (var i = cells.length - 1; i >= 0; i--) {
+ var cl = cells[i];
+ if (overlap(x, y, w, h, cl.x, cl.y, cl.w, cl.h)) {
+ cl.hp--;
+ burst(cl.x + cl.w / 2, cl.y + cl.h / 2, "#39ff14", 4);
+ if (cl.hp <= 0) cells.splice(i, 1);
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ function handleCollisions() {
+ // player bullets
+ for (var i = bullets.length - 1; i >= 0; i--) {
+ var bl = bullets[i];
+ var bx = bl.x - bl.w / 2, by = bl.y;
+
+ // bunkers
+ if (damageBunkerAt(bx, by, bl.w, bl.h)) { bullets.splice(i, 1); continue; }
+
+ // ufo
+ if (ufo && overlap(bx, by, bl.w, bl.h, ufo.x - ufo.w / 2, ufo.y - ufo.h / 2, ufo.w, ufo.h)) {
+ addScore(ufo.value);
+ burst(ufo.x, ufo.y, "#ff4fd8", 22);
+ sUfo();
+ ufo = null;
+ bullets.splice(i, 1);
+ continue;
+ }
+
+ // invaders
+ var hitInv = false;
+ for (var k = 0; k < invaders.length; k++) {
+ var inv = invaders[k];
+ if (!inv.alive) continue;
+ if (overlap(bx, by, bl.w, bl.h, inv.x - inv.w / 2, inv.y - inv.h / 2, inv.w, inv.h)) {
+ inv.alive = false;
+ addScore((inv.type + 1) * 10);
+ burst(inv.x, inv.y, invColor(inv.type), 16);
+ sHit();
+ hitInv = true;
+ break;
+ }
+ }
+ if (hitInv) { bullets.splice(i, 1); continue; }
+ }
+
+ // enemy bombs
+ for (var j = bombs.length - 1; j >= 0; j--) {
+ var bo = bombs[j];
+ var ox = bo.x - bo.w / 2, oy = bo.y;
+
+ if (damageBunkerAt(ox, oy, bo.w, bo.h)) { bombs.splice(j, 1); continue; }
+
+ if (overlap(ox, oy, bo.w, bo.h, player.x - player.w / 2, player.y - player.h / 2, player.w, player.h)) {
+ bombs.splice(j, 1);
+ playerHit();
+ continue;
+ }
+ }
+ }
+
+ function invColor(type) {
+ return type === 2 ? "#ff4fd8" : (type === 1 ? "#7ffcff" : "#39ff14");
+ }
+
+ function addScore(v) {
+ score += v;
+ if (score > best) {
+ best = score;
+ try { localStorage.setItem(BEST_KEY, String(best)); } catch (e) { /* ignore */ }
+ }
+ }
+
+ function playerHit() {
+ lives--;
+ burst(player.x, player.y, "#ffd23f", 26);
+ sBoom();
+ flashTimer = 0.35;
+ if (lives <= 0) {
+ gameOver();
+ } else {
+ player.x = W / 2;
+ player.cool = 0.6;
+ }
+ }
+
+ function loseAllLives() {
+ lives = 0;
+ burst(player.x, player.y, "#ffd23f", 30);
+ sBoom();
+ gameOver();
+ }
+
+ function gameOver() {
+ state = STATE.OVER;
+ }
+
+ // ---- Render ----
+ function draw() {
+ ctx.clearRect(0, 0, W, H);
+
+ // starfield backdrop
+ drawStars();
+
+ if (flashTimer > 0 && (state === STATE.PLAY || state === STATE.WAVE)) {
+ ctx.fillStyle = "rgba(255,210,63," + Math.min(0.25, flashTimer * 0.4) + ")";
+ ctx.fillRect(0, 0, W, H);
+ }
+
+ if (state === STATE.MENU) {
+ drawMenu();
+ drawParticles();
+ return;
+ }
+
+ // bunkers
+ for (var b = 0; b < bunkers.length; b++) {
+ var cells = bunkers[b].cells;
+ for (var i = 0; i < cells.length; i++) {
+ var cl = cells[i];
+ var a = cl.hp / 3;
+ ctx.fillStyle = "rgba(57,255,20," + (0.35 + a * 0.55) + ")";
+ ctx.fillRect(cl.x + 0.5, cl.y + 0.5, cl.w - 1, cl.h - 1);
+ }
+ }
+
+ // invaders
+ for (var k = 0; k < invaders.length; k++) {
+ if (invaders[k].alive) drawInvader(invaders[k]);
+ }
+
+ // ufo
+ if (ufo) drawUfo(ufo);
+
+ // bullets
+ ctx.save();
+ ctx.shadowBlur = 8; ctx.shadowColor = "#ffffff";
+ ctx.fillStyle = "#ffffff";
+ for (var m = 0; m < bullets.length; m++) {
+ var bl = bullets[m];
+ ctx.fillRect(bl.x - bl.w / 2, bl.y, bl.w, bl.h);
+ }
+ ctx.restore();
+
+ // bombs
+ ctx.save();
+ ctx.shadowBlur = 8; ctx.shadowColor = "#ff4fd8";
+ for (var n = 0; n < bombs.length; n++) {
+ var bo = bombs[n];
+ ctx.fillStyle = ((bo.t * 20) | 0) % 2 ? "#ff4fd8" : "#ffb0ec";
+ ctx.fillRect(bo.x - bo.w / 2, bo.y, bo.w, bo.h);
+ }
+ ctx.restore();
+
+ // player
+ if (state === STATE.PLAY || state === STATE.WAVE || state === STATE.PAUSED) drawPlayer();
+
+ drawParticles();
+ drawHUD();
+
+ if (state === STATE.OVER) drawGameOver();
+ if (state === STATE.PAUSED) drawPaused();
+ if (state === STATE.WAVE) drawWaveBanner();
+ }
+
+ var stars = [];
+ function initStars() {
+ stars = [];
+ for (var i = 0; i < 60; i++) {
+ stars.push({ x: Math.random() * W, y: Math.random() * H, s: Math.random() * 1.6 + 0.4, t: Math.random() * 6.28 });
+ }
+ }
+ function drawStars() {
+ for (var i = 0; i < stars.length; i++) {
+ var st = stars[i];
+ var tw = 0.4 + 0.6 * Math.abs(Math.sin(st.t + performance.now() / 900));
+ ctx.fillStyle = "rgba(120,180,220," + (tw * 0.5) + ")";
+ ctx.fillRect(st.x, st.y, st.s, st.s);
+ }
+ }
+
+ function drawPlayer() {
+ var px = player.x, py = player.y, w = player.w, h = player.h;
+ ctx.save();
+ ctx.shadowBlur = 12; ctx.shadowColor = "#39ff14";
+ ctx.fillStyle = "#39ff14";
+ // cannon body
+ ctx.beginPath();
+ ctx.moveTo(px - w / 2, py + h / 2);
+ ctx.lineTo(px + w / 2, py + h / 2);
+ ctx.lineTo(px + w / 2, py + h / 2 - 6);
+ ctx.lineTo(px + 8, py + h / 2 - 6);
+ ctx.lineTo(px + 4, py - h / 2 + 2);
+ ctx.lineTo(px - 4, py - h / 2 + 2);
+ ctx.lineTo(px - 8, py + h / 2 - 6);
+ ctx.lineTo(px - w / 2, py + h / 2 - 6);
+ ctx.closePath();
+ ctx.fill();
+ ctx.fillRect(px - 2, py - h / 2 - 4, 4, 6);
+ ctx.restore();
+ }
+
+ function drawInvader(inv) {
+ var x = inv.x - inv.w / 2, y = inv.y - inv.h / 2, w = inv.w, h = inv.h;
+ var col = invColor(inv.type);
+ var frame = stepIndex % 2;
+ ctx.save();
+ ctx.shadowBlur = 8; ctx.shadowColor = col;
+ ctx.fillStyle = col;
+ var px = w / 8, py = h / 5;
+ // simple 8x5 sprite bitmaps
+ var A = [
+ [0,0,1,0,0,1,0,0],
+ [0,1,1,1,1,1,1,0],
+ [1,1,0,1,1,0,1,1],
+ [1,1,1,1,1,1,1,1],
+ frame ? [0,1,0,0,0,0,1,0] : [1,0,1,0,0,1,0,1]
+ ];
+ for (var ry = 0; ry < 5; ry++) {
+ for (var rx = 0; rx < 8; rx++) {
+ if (A[ry][rx]) ctx.fillRect(x + rx * px, y + ry * py, px + 0.5, py + 0.5);
+ }
+ }
+ ctx.restore();
+ }
+
+ function drawUfo(u) {
+ ctx.save();
+ ctx.shadowBlur = 12; ctx.shadowColor = "#ff4fd8";
+ ctx.fillStyle = "#ff4fd8";
+ ctx.beginPath();
+ ctx.ellipse(u.x, u.y, u.w / 2, u.h / 2, 0, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.fillStyle = "#ffd23f";
+ ctx.beginPath();
+ ctx.ellipse(u.x, u.y - 3, u.w / 4, u.h / 3, 0, Math.PI, 0);
+ ctx.fill();
+ ctx.restore();
+ }
+
+ function drawParticles() {
+ for (var i = 0; i < particles.length; i++) {
+ var p = particles[i];
+ var a = Math.max(0, p.life / p.max);
+ ctx.globalAlpha = a;
+ ctx.fillStyle = p.color;
+ ctx.fillRect(p.x - 1.5, p.y - 1.5, 3, 3);
+ }
+ ctx.globalAlpha = 1;
+ }
+
+ function drawHUD() {
+ ctx.save();
+ ctx.font = "bold 15px 'Courier New', monospace";
+ ctx.textBaseline = "top";
+ ctx.fillStyle = "#39ff14";
+ ctx.shadowBlur = 6; ctx.shadowColor = "#39ff14";
+ ctx.textAlign = "left";
+ ctx.fillText("SCORE " + pad(score, 5), 12, 10);
+ ctx.textAlign = "center";
+ ctx.fillStyle = "#7ffcff"; ctx.shadowColor = "#7ffcff";
+ ctx.fillText("WAVE " + wave, W / 2, 10);
+ ctx.textAlign = "right";
+ ctx.fillStyle = "#ffd23f"; ctx.shadowColor = "#ffd23f";
+ ctx.fillText("BEST " + pad(best, 5), W - 12, 10);
+ ctx.restore();
+
+ // lives
+ for (var i = 0; i < lives; i++) {
+ var lx = 20 + i * 26, ly = H - 22;
+ ctx.save();
+ ctx.shadowBlur = 6; ctx.shadowColor = "#39ff14";
+ ctx.fillStyle = "#39ff14";
+ ctx.beginPath();
+ ctx.moveTo(lx - 8, ly + 6);
+ ctx.lineTo(lx + 8, ly + 6);
+ ctx.lineTo(lx + 4, ly - 4);
+ ctx.lineTo(lx - 4, ly - 4);
+ ctx.closePath();
+ ctx.fill();
+ ctx.fillRect(lx - 1, ly - 8, 2, 4);
+ ctx.restore();
+ }
+ }
+
+ function drawMenu() {
+ centerText("NEON INVADERS", W / 2, H / 2 - 70, "bold 34px 'Courier New', monospace", "#39ff14", 8, 0.12);
+ centerText("DEFEND THE GRID", W / 2, H / 2 - 30, "14px 'Courier New', monospace", "#7ffcff", 4, 0.3);
+ var blink = (Math.floor(performance.now() / 500) % 2) === 0;
+ if (blink) centerText("PRESS SPACE / TAP TO START", W / 2, H / 2 + 30, "bold 15px 'Courier New', monospace", "#ffd23f", 6, 0.1);
+ centerText("BEST " + pad(best, 5), W / 2, H / 2 + 80, "14px 'Courier New', monospace", "#ff4fd8", 4, 0.2);
+ }
+
+ function drawGameOver() {
+ ctx.fillStyle = "rgba(5,7,12,.6)";
+ ctx.fillRect(0, 0, W, H);
+ centerText("GAME OVER", W / 2, H / 2 - 50, "bold 34px 'Courier New', monospace", "#ff4fd8", 10, 0.1);
+ centerText("SCORE " + pad(score, 5), W / 2, H / 2, "16px 'Courier New', monospace", "#39ff14", 5, 0.15);
+ centerText("BEST " + pad(best, 5), W / 2, H / 2 + 26, "16px 'Courier New', monospace", "#ffd23f", 5, 0.15);
+ var blink = (Math.floor(performance.now() / 500) % 2) === 0;
+ if (blink) centerText("PRESS SPACE / TAP TO RESTART", W / 2, H / 2 + 70, "bold 14px 'Courier New', monospace", "#7ffcff", 5, 0.08);
+ }
+
+ function drawPaused() {
+ ctx.fillStyle = "rgba(5,7,12,.55)";
+ ctx.fillRect(0, 0, W, H);
+ centerText("PAUSED", W / 2, H / 2 - 10, "bold 30px 'Courier New', monospace", "#7ffcff", 8, 0.2);
+ centerText("PRESS P TO RESUME", W / 2, H / 2 + 30, "13px 'Courier New', monospace", "#ffd23f", 4, 0.1);
+ }
+
+ function drawWaveBanner() {
+ centerText("WAVE " + wave + " CLEARED", W / 2, H / 2 - 10, "bold 26px 'Courier New', monospace", "#39ff14", 8, 0.14);
+ }
+
+ function centerText(txt, x, y, font, color, glow, spacing) {
+ ctx.save();
+ ctx.font = font;
+ ctx.textAlign = "center";
+ ctx.textBaseline = "middle";
+ ctx.shadowBlur = glow || 0;
+ ctx.shadowColor = color;
+ ctx.fillStyle = color;
+ if (spacing) {
+ // manual letter spacing
+ var total = 0, i;
+ var widths = [];
+ for (i = 0; i < txt.length; i++) {
+ var wch = ctx.measureText(txt[i]).width + spacing * 20;
+ widths.push(wch); total += wch;
+ }
+ var cx = x - total / 2;
+ ctx.textAlign = "left";
+ for (i = 0; i < txt.length; i++) {
+ ctx.fillText(txt[i], cx, y);
+ cx += widths[i];
+ }
+ } else {
+ ctx.fillText(txt, x, y);
+ }
+ ctx.restore();
+ }
+
+ function pad(n, len) {
+ var s = String(Math.max(0, n | 0));
+ while (s.length < len) s = "0" + s;
+ return s;
+ }
+
+ // ---- Input ----
+ function onKeyDown(e) {
+ var k = e.key;
+ if (k === "ArrowLeft" || k === "a" || k === "A") { keys.left = true; e.preventDefault(); }
+ else if (k === "ArrowRight" || k === "d" || k === "D") { keys.right = true; e.preventDefault(); }
+ else if (k === " " || k === "Spacebar" || k === "ArrowUp") {
+ keys.fire = true; e.preventDefault();
+ ensureAudio();
+ if (state === STATE.MENU || state === STATE.OVER) startGame();
+ }
+ else if (k === "p" || k === "P") {
+ if (state === STATE.PLAY) state = STATE.PAUSED;
+ else if (state === STATE.PAUSED) state = STATE.PLAY;
+ }
+ else if (k === "m" || k === "M") { muted = !muted; }
+ }
+ function onKeyUp(e) {
+ var k = e.key;
+ if (k === "ArrowLeft" || k === "a" || k === "A") keys.left = false;
+ else if (k === "ArrowRight" || k === "d" || k === "D") keys.right = false;
+ else if (k === " " || k === "Spacebar" || k === "ArrowUp") keys.fire = false;
+ }
+
+ window.addEventListener("keydown", onKeyDown, { passive: false });
+ window.addEventListener("keyup", onKeyUp);
+
+ // Touch controls
+ function bindTouch(el, key) {
+ function down(ev) {
+ ev.preventDefault();
+ ensureAudio();
+ touch[key] = true;
+ if (key === "fire" && (state === STATE.MENU || state === STATE.OVER)) startGame();
+ }
+ function up(ev) { ev.preventDefault(); touch[key] = false; }
+ el.addEventListener("touchstart", down, { passive: false });
+ el.addEventListener("touchend", up, { passive: false });
+ el.addEventListener("touchcancel", up, { passive: false });
+ el.addEventListener("mousedown", down);
+ el.addEventListener("mouseup", up);
+ el.addEventListener("mouseleave", up);
+ }
+ bindTouch(document.getElementById("bLeft"), "left");
+ bindTouch(document.getElementById("bRight"), "right");
+ bindTouch(document.getElementById("bFire"), "fire");
+
+ // Tap on canvas also starts / fires on touch devices
+ canvas.addEventListener("touchstart", function (ev) {
+ ev.preventDefault();
+ ensureAudio();
+ if (state === STATE.MENU || state === STATE.OVER) startGame();
+ }, { passive: false });
+ canvas.addEventListener("mousedown", function () {
+ ensureAudio();
+ if (state === STATE.MENU || state === STATE.OVER) startGame();
+ });
+
+ // Show touch controls when a touch is detected
+ function enableTouchUI() {
+ touchLayer.style.display = "block";
+ window.removeEventListener("touchstart", enableTouchUI);
+ }
+ window.addEventListener("touchstart", enableTouchUI, { passive: true });
+ if (("ontouchstart" in window) || (navigator.maxTouchPoints > 0)) {
+ touchLayer.style.display = "block";
+ }
+
+ window.addEventListener("resize", fitCanvas);
+
+ // ---- Loop ----
+ var last = performance.now();
+ function frame(now) {
+ var dt = (now - last) / 1000;
+ last = now;
+ if (dt > 0.05) dt = 0.05; // clamp for tab-switch spikes
+
+ if (state === STATE.WAVE) {
+ updateParticles(dt);
+ waveDelay -= dt;
+ if (waveDelay <= 0) nextWave();
+ } else {
+ update(dt);
+ }
+
+ draw();
+ requestAnimationFrame(frame);
+ }
+
+ // ---- Init ----
+ initStars();
+ fitCanvas();
+ // initialize placeholder arrays for menu particle draws
+ particles = [];
+ bullets = []; bombs = []; invaders = []; bunkers = []; player = makePlayer();
+ requestAnimationFrame(frame);
+ }
+
+ if (document.readyState === "loading") {
+ document.addEventListener("DOMContentLoaded", boot);
+ } else {
+ boot();
+ }
+})();
+</script>
+</body>
+</html>
diff --git a/games/missile/index.html b/games/missile/index.html
new file mode 100644
index 0000000..a8a3c16
--- /dev/null
+++ b/games/missile/index.html
@@ -0,0 +1,886 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
+<title>MISSILE COMMAND</title>
+<style>
+ html, body {
+ margin: 0;
+ padding: 0;
+ width: 100%;
+ height: 100%;
+ background: #0a0e14;
+ overflow: hidden;
+ touch-action: none;
+ -webkit-user-select: none;
+ user-select: none;
+ -webkit-tap-highlight-color: transparent;
+ }
+ #wrap {
+ position: fixed;
+ inset: 0;
+ display: block;
+ background: #0a0e14;
+ }
+ canvas {
+ display: block;
+ width: 100%;
+ height: 100%;
+ background: #0a0e14;
+ cursor: crosshair;
+ }
+</style>
+</head>
+<body>
+<div id="wrap">
+ <canvas id="game"></canvas>
+</div>
+<script>
+(function () {
+ "use strict";
+
+ function boot() {
+ var canvas = document.getElementById("game");
+ if (!canvas || !canvas.getContext) return;
+ var ctx = canvas.getContext("2d");
+ if (!ctx) return;
+
+ var BEST_KEY = "abrams-missile-best";
+ var best = 0;
+ try {
+ var stored = localStorage.getItem(BEST_KEY);
+ if (stored !== null) { var n = parseInt(stored, 10); if (!isNaN(n)) best = n; }
+ } catch (e) { best = 0; }
+
+ // Logical design resolution; we scale to fit the iframe.
+ var W = 900, H = 600;
+ var dpr = 1;
+
+ function resize() {
+ var cw = window.innerWidth || document.documentElement.clientWidth || W;
+ var ch = window.innerHeight || document.documentElement.clientHeight || H;
+ dpr = Math.max(1, Math.min(2, window.devicePixelRatio || 1));
+ canvas.width = Math.max(1, Math.floor(cw * dpr));
+ canvas.height = Math.max(1, Math.floor(ch * dpr));
+ canvas.style.width = cw + "px";
+ canvas.style.height = ch + "px";
+ W = cw;
+ H = ch;
+ }
+
+ window.addEventListener("resize", resize, { passive: true });
+ resize();
+
+ // ---- Audio (lazy) ----
+ var audioCtx = null;
+ var audioOK = true;
+ function ensureAudio() {
+ if (audioCtx || !audioOK) return;
+ try {
+ var AC = window.AudioContext || window.webkitAudioContext;
+ if (!AC) { audioOK = false; return; }
+ audioCtx = new AC();
+ } catch (e) { audioOK = false; audioCtx = null; }
+ }
+ function blip(type, freq, dur, vol) {
+ if (!audioOK) return;
+ try {
+ ensureAudio();
+ if (!audioCtx) return;
+ if (audioCtx.state === "suspended" && audioCtx.resume) { audioCtx.resume(); }
+ var o = audioCtx.createOscillator();
+ var g = audioCtx.createGain();
+ o.type = type;
+ o.frequency.setValueAtTime(freq, audioCtx.currentTime);
+ g.gain.setValueAtTime(vol, audioCtx.currentTime);
+ g.gain.exponentialRampToValueAtTime(0.0001, audioCtx.currentTime + dur);
+ o.connect(g);
+ g.connect(audioCtx.destination);
+ o.start();
+ o.stop(audioCtx.currentTime + dur + 0.02);
+ } catch (e) { /* never throw */ }
+ }
+ function sfxLaunch() { blip("square", 660, 0.12, 0.06); }
+ function sfxExplode() {
+ blip("sawtooth", 180, 0.30, 0.09);
+ blip("triangle", 90, 0.35, 0.06);
+ }
+ function sfxCityHit() { blip("sawtooth", 70, 0.5, 0.12); }
+
+ // ---- Game state ----
+ var STATE_START = 0, STATE_PLAY = 1, STATE_WAVEEND = 2, STATE_OVER = 3;
+ var state = STATE_START;
+
+ var cities = []; // {x, alive}
+ var bases = []; // {x, ammo, maxAmmo}
+ var enemies = []; // incoming missiles
+ var friendlies = []; // counter-missiles
+ var blasts = []; // explosions
+ var particles = [];
+
+ var wave = 1;
+ var score = 0;
+ var waveEnemiesTotal = 0;
+ var waveEnemiesSpawned = 0;
+ var spawnTimer = 0;
+ var spawnInterval = 1.2;
+ var waveEndTimer = 0;
+ var bonusStep = 0;
+ var bonusCityIndex = 0;
+ var bonusAmmoBase = 0;
+ var bonusAmmoLeft = 0;
+ var flashTimer = 0;
+
+ var GROUND_H = 70; // reserved from bottom in logical coords (relative to H)
+
+ function groundY() { return H - 64; }
+
+ function cityBaseCount() { return 6; }
+
+ function setupField() {
+ cities = [];
+ bases = [];
+ var n = cityBaseCount();
+ // 3 bases + 6 cities arranged: base, 2 cities, base, 2 cities, base? classic: bases at far L, mid, far R
+ // We'll spread bases and cities across width.
+ var margin = W * 0.06;
+ var usable = W - margin * 2;
+ // base positions: left, center, right
+ var basePositions = [0.5 / 10, 5 / 10, 9.5 / 10];
+ // city positions between bases
+ var cityFractions = [1.7 / 10, 2.6 / 10, 3.5 / 10, 6.5 / 10, 7.4 / 10, 8.3 / 10];
+ for (var i = 0; i < basePositions.length; i++) {
+ bases.push({ x: margin + basePositions[i] * usable, ammo: 10, maxAmmo: 10 });
+ }
+ for (var j = 0; j < cityFractions.length; j++) {
+ cities.push({ x: margin + cityFractions[j] * usable, alive: true });
+ }
+ }
+
+ function repositionField() {
+ // Recompute x positions on resize while preserving alive/ammo.
+ var margin = W * 0.06;
+ var usable = W - margin * 2;
+ var basePositions = [0.5 / 10, 5 / 10, 9.5 / 10];
+ var cityFractions = [1.7 / 10, 2.6 / 10, 3.5 / 10, 6.5 / 10, 7.4 / 10, 8.3 / 10];
+ for (var i = 0; i < bases.length && i < basePositions.length; i++) {
+ bases[i].x = margin + basePositions[i] * usable;
+ }
+ for (var j = 0; j < cities.length && j < cityFractions.length; j++) {
+ cities[j].x = margin + cityFractions[j] * usable;
+ }
+ }
+
+ function startWave(w) {
+ wave = w;
+ enemies = [];
+ friendlies = [];
+ blasts = [];
+ particles = [];
+ waveEnemiesTotal = 8 + Math.floor(w * 2.5);
+ waveEnemiesSpawned = 0;
+ spawnTimer = 0.5;
+ spawnInterval = Math.max(0.35, 1.3 - w * 0.07);
+ // refill ammo
+ for (var i = 0; i < bases.length; i++) {
+ bases[i].ammo = bases[i].maxAmmo;
+ }
+ state = STATE_PLAY;
+ }
+
+ function newGame() {
+ score = 0;
+ wave = 1;
+ setupField();
+ startWave(1);
+ }
+
+ function aliveCities() {
+ var c = 0;
+ for (var i = 0; i < cities.length; i++) if (cities[i].alive) c++;
+ return c;
+ }
+
+ function enemySpeed() {
+ return (28 + wave * 5) * (H / 600);
+ }
+
+ function spawnEnemy() {
+ var speed = enemySpeed() * (0.8 + Math.random() * 0.5);
+ var sx = Math.random() * W;
+ // target a city or base
+ var targets = [];
+ for (var i = 0; i < cities.length; i++) if (cities[i].alive) targets.push(cities[i].x);
+ for (var j = 0; j < bases.length; j++) targets.push(bases[j].x);
+ if (targets.length === 0) targets = [W / 2];
+ var tx = targets[Math.floor(Math.random() * targets.length)] + (Math.random() - 0.5) * 40;
+ var ty = groundY();
+ var canSplit = wave >= 3 && Math.random() < Math.min(0.5, 0.15 + wave * 0.03);
+ var smart = wave >= 5 && Math.random() < Math.min(0.4, (wave - 4) * 0.06);
+ enemies.push({
+ x: sx, y: -10,
+ tx: tx, ty: ty,
+ sx: sx, sy: -10,
+ speed: speed,
+ split: canSplit,
+ splitY: H * (0.35 + Math.random() * 0.2),
+ smart: smart,
+ trail: [],
+ dead: false
+ });
+ }
+
+ function spawnSplit(e) {
+ var count = 2 + (Math.random() < 0.3 ? 1 : 0);
+ for (var i = 0; i < count; i++) {
+ var speed = e.speed * (0.9 + Math.random() * 0.3);
+ var targets = [];
+ for (var c = 0; c < cities.length; c++) if (cities[c].alive) targets.push(cities[c].x);
+ for (var b = 0; b < bases.length; b++) targets.push(bases[b].x);
+ if (targets.length === 0) targets = [W / 2];
+ var tx = targets[Math.floor(Math.random() * targets.length)] + (Math.random() - 0.5) * 60;
+ enemies.push({
+ x: e.x, y: e.y,
+ tx: tx, ty: groundY(),
+ sx: e.x, sy: e.y,
+ speed: speed,
+ split: false,
+ splitY: 0,
+ smart: false,
+ trail: [],
+ dead: false
+ });
+ }
+ }
+
+ function launchAt(px, py) {
+ // choose nearest base with ammo
+ var best_i = -1, bestD = Infinity;
+ for (var i = 0; i < bases.length; i++) {
+ if (bases[i].ammo <= 0) continue;
+ var d = Math.abs(bases[i].x - px);
+ if (d < bestD) { bestD = d; best_i = i; }
+ }
+ if (best_i < 0) return; // no ammo anywhere
+ var b = bases[best_i];
+ b.ammo--;
+ var ox = b.x;
+ var oy = groundY();
+ var dx = px - ox, dy = py - oy;
+ var dist = Math.sqrt(dx * dx + dy * dy) || 1;
+ var spd = 620 * (H / 600);
+ friendlies.push({
+ x: ox, y: oy,
+ vx: dx / dist * spd,
+ vy: dy / dist * spd,
+ tx: px, ty: py,
+ trail: [],
+ dead: false
+ });
+ sfxLaunch();
+ }
+
+ function addBlast(x, y, maxR) {
+ blasts.push({ x: x, y: y, r: 4, maxR: maxR, growing: true, life: 0 });
+ sfxExplode();
+ }
+
+ function addParticles(x, y, color, n) {
+ for (var i = 0; i < n; i++) {
+ var a = Math.random() * Math.PI * 2;
+ var s = 30 + Math.random() * 120;
+ particles.push({
+ x: x, y: y,
+ vx: Math.cos(a) * s,
+ vy: Math.sin(a) * s - 30,
+ life: 0.5 + Math.random() * 0.6,
+ maxLife: 1,
+ color: color
+ });
+ }
+ }
+
+ function destroyCity(c) {
+ if (!c.alive) return;
+ c.alive = false;
+ addParticles(c.x, groundY() - 8, "#ff5a7a", 26);
+ flashTimer = 0.35;
+ sfxCityHit();
+ }
+
+ // ---- Input ----
+ function pointerToLogical(clientX, clientY) {
+ var rect = canvas.getBoundingClientRect();
+ var x = (clientX - rect.left) / rect.width * W;
+ var y = (clientY - rect.top) / rect.height * H;
+ return { x: x, y: y };
+ }
+
+ function handleFire(clientX, clientY) {
+ ensureAudio();
+ if (state === STATE_START) { newGame(); return; }
+ if (state === STATE_OVER) { newGame(); return; }
+ if (state === STATE_WAVEEND) { return; }
+ if (state !== STATE_PLAY) return;
+ var p = pointerToLogical(clientX, clientY);
+ if (p.y > groundY() - 6) p.y = groundY() - 6;
+ launchAt(p.x, p.y);
+ }
+
+ canvas.addEventListener("mousedown", function (e) {
+ e.preventDefault();
+ handleFire(e.clientX, e.clientY);
+ });
+
+ canvas.addEventListener("touchstart", function (e) {
+ e.preventDefault();
+ if (e.changedTouches && e.changedTouches.length) {
+ var t = e.changedTouches[0];
+ handleFire(t.clientX, t.clientY);
+ }
+ }, { passive: false });
+
+ window.addEventListener("keydown", function (e) {
+ if (e.key === " " || e.key === "Enter") {
+ if (state === STATE_START || state === STATE_OVER) newGame();
+ }
+ });
+
+ // ---- Update ----
+ function update(dt) {
+ flashTimer = Math.max(0, flashTimer - dt);
+
+ if (state === STATE_PLAY) {
+ // spawn
+ if (waveEnemiesSpawned < waveEnemiesTotal) {
+ spawnTimer -= dt;
+ if (spawnTimer <= 0) {
+ spawnEnemy();
+ waveEnemiesSpawned++;
+ spawnTimer = spawnInterval * (0.6 + Math.random() * 0.8);
+ }
+ }
+ }
+
+ // update enemies
+ var gy = groundY();
+ for (var i = enemies.length - 1; i >= 0; i--) {
+ var e = enemies[i];
+ if (e.dead) { enemies.splice(i, 1); continue; }
+ var dx = e.tx - e.x, dy = e.ty - e.y;
+ var dd = Math.sqrt(dx * dx + dy * dy) || 1;
+ var step = e.speed * dt;
+ // smart bombs slightly weave / retarget toward alive cities
+ e.x += dx / dd * step;
+ e.y += dy / dd * step;
+
+ e.trail.push({ x: e.x, y: e.y });
+ if (e.trail.length > 16) e.trail.shift();
+
+ // split
+ if (e.split && e.y >= e.splitY) {
+ e.split = false;
+ spawnSplit(e);
+ }
+
+ if (e.y >= e.ty - 2 || e.y >= gy) {
+ // hit ground: destroy nearest city/base if near
+ e.dead = true;
+ addParticles(e.x, gy - 4, "#ff8a5a", 10);
+ // check city hits
+ for (var c = 0; c < cities.length; c++) {
+ if (cities[c].alive && Math.abs(cities[c].x - e.x) < W * 0.035) {
+ destroyCity(cities[c]);
+ }
+ }
+ for (var b = 0; b < bases.length; b++) {
+ if (Math.abs(bases[b].x - e.x) < W * 0.03) {
+ bases[b].ammo = 0; // knocked out for the wave visually
+ }
+ }
+ enemies.splice(i, 1);
+ }
+ }
+
+ // update friendlies
+ for (var f = friendlies.length - 1; f >= 0; f--) {
+ var fm = friendlies[f];
+ if (fm.dead) { friendlies.splice(f, 1); continue; }
+ fm.x += fm.vx * dt;
+ fm.y += fm.vy * dt;
+ fm.trail.push({ x: fm.x, y: fm.y });
+ if (fm.trail.length > 22) fm.trail.shift();
+ var ddx = fm.tx - fm.x, ddy = fm.ty - fm.y;
+ // reached target when passing it
+ if (ddx * fm.vx + ddy * fm.vy <= 0) {
+ fm.dead = true;
+ addBlast(fm.tx, fm.ty, Math.max(46, W * 0.06));
+ friendlies.splice(f, 1);
+ }
+ }
+
+ // update blasts
+ for (var bl = blasts.length - 1; bl >= 0; bl--) {
+ var B = blasts[bl];
+ B.life += dt;
+ if (B.growing) {
+ B.r += (B.maxR) * dt * 3.2;
+ if (B.r >= B.maxR) { B.r = B.maxR; B.growing = false; }
+ } else {
+ B.r -= B.maxR * dt * 1.9;
+ if (B.r <= 0) { blasts.splice(bl, 1); continue; }
+ }
+ // destroy enemies inside blast
+ for (var ei = enemies.length - 1; ei >= 0; ei--) {
+ var en = enemies[ei];
+ var edx = en.x - B.x, edy = en.y - B.y;
+ if (edx * edx + edy * edy <= B.r * B.r) {
+ en.dead = true;
+ score += 25;
+ addParticles(en.x, en.y, "#7dffea", 8);
+ enemies.splice(ei, 1);
+ }
+ }
+ }
+
+ // update particles
+ for (var p = particles.length - 1; p >= 0; p--) {
+ var pt = particles[p];
+ pt.life -= dt;
+ if (pt.life <= 0) { particles.splice(p, 1); continue; }
+ pt.vy += 140 * dt;
+ pt.x += pt.vx * dt;
+ pt.y += pt.vy * dt;
+ }
+
+ // check wave end / game over
+ if (state === STATE_PLAY) {
+ if (aliveCities() === 0) {
+ // let remaining blasts fade a touch, then game over
+ state = STATE_OVER;
+ if (score > best) {
+ best = score;
+ try { localStorage.setItem(BEST_KEY, String(best)); } catch (e2) {}
+ }
+ } else if (waveEnemiesSpawned >= waveEnemiesTotal && enemies.length === 0 && friendlies.length === 0) {
+ // wave clear -> bonus phase
+ state = STATE_WAVEEND;
+ waveEndTimer = 0;
+ bonusStep = 0;
+ bonusCityIndex = 0;
+ bonusAmmoLeft = 0;
+ for (var bb = 0; bb < bases.length; bb++) bonusAmmoLeft += bases[bb].ammo;
+ bonusAmmoBase = bonusAmmoLeft;
+ }
+ }
+
+ if (state === STATE_WAVEEND) {
+ waveEndTimer += dt;
+ // tally city bonus (100 each) then ammo bonus (10 each)
+ // simple timed tally for feel
+ if (bonusStep === 0 && waveEndTimer > 0.4) {
+ bonusStep = 1;
+ } else if (bonusStep === 1) {
+ if (waveEndTimer > 0.5 + bonusCityIndex * 0.12) {
+ if (bonusCityIndex < cities.length) {
+ if (cities[bonusCityIndex].alive) { score += 100; blip("sine", 880, 0.08, 0.05); }
+ bonusCityIndex++;
+ } else {
+ bonusStep = 2;
+ waveEndTimer = 0;
+ }
+ }
+ } else if (bonusStep === 2) {
+ if (waveEndTimer > 0.05 && bonusAmmoLeft > 0) {
+ if (waveEndTimer > 0.02) {
+ score += 5;
+ bonusAmmoLeft--;
+ waveEndTimer = 0;
+ blip("sine", 1200, 0.04, 0.04);
+ }
+ } else if (bonusAmmoLeft <= 0) {
+ bonusStep = 3;
+ waveEndTimer = 0;
+ }
+ } else if (bonusStep === 3) {
+ if (waveEndTimer > 1.0) {
+ startWave(wave + 1);
+ }
+ }
+ }
+ }
+
+ // ---- Draw ----
+ var starField = null;
+ function buildStars() {
+ starField = [];
+ var n = 70;
+ for (var i = 0; i < n; i++) {
+ starField.push({
+ x: Math.random(),
+ y: Math.random() * 0.75,
+ s: Math.random() * 1.6 + 0.3,
+ tw: Math.random() * Math.PI * 2
+ });
+ }
+ }
+ buildStars();
+
+ var tGlobal = 0;
+
+ function drawBackground() {
+ // gradient sky
+ var g = ctx.createLinearGradient(0, 0, 0, H);
+ g.addColorStop(0, "#0a0e14");
+ g.addColorStop(0.6, "#0b1220");
+ g.addColorStop(1, "#0d1a2b");
+ ctx.fillStyle = g;
+ ctx.fillRect(0, 0, W, H);
+
+ // stars
+ for (var i = 0; i < starField.length; i++) {
+ var st = starField[i];
+ var a = 0.4 + 0.5 * Math.abs(Math.sin(tGlobal * 1.5 + st.tw));
+ ctx.fillStyle = "rgba(180,220,255," + a.toFixed(3) + ")";
+ ctx.fillRect(st.x * W, st.y * H, st.s, st.s);
+ }
+ }
+
+ function drawGround() {
+ var gy = groundY();
+ // ground glow line
+ ctx.save();
+ ctx.strokeStyle = "#1de9b6";
+ ctx.shadowColor = "#1de9b6";
+ ctx.shadowBlur = 14;
+ ctx.lineWidth = 2;
+ ctx.beginPath();
+ ctx.moveTo(0, gy + 10);
+ ctx.lineTo(W, gy + 10);
+ ctx.stroke();
+ ctx.restore();
+
+ // ground fill
+ ctx.fillStyle = "#08131c";
+ ctx.fillRect(0, gy + 10, W, H - (gy + 10));
+ }
+
+ function drawCity(c) {
+ var gy = groundY();
+ var cw = W * 0.05;
+ var bx = c.x - cw / 2;
+ if (!c.alive) {
+ // rubble
+ ctx.save();
+ ctx.fillStyle = "#20303c";
+ for (var r = 0; r < 4; r++) {
+ var rw = cw * (0.2 + Math.random() * 0.1);
+ ctx.fillRect(bx + r * (cw / 4), gy - 4 - Math.random() * 5, rw, 5 + Math.random() * 4);
+ }
+ ctx.restore();
+ return;
+ }
+ ctx.save();
+ ctx.shadowColor = "#36e0ff";
+ ctx.shadowBlur = 12;
+ // skyline: a few towers
+ var towers = [
+ { w: cw * 0.20, h: 20 },
+ { w: cw * 0.16, h: 32 },
+ { w: cw * 0.22, h: 26 },
+ { w: cw * 0.16, h: 16 }
+ ];
+ var tx = bx;
+ var hue = "#2ad4ff";
+ for (var t = 0; t < towers.length; t++) {
+ var tw = towers[t].w;
+ var th = towers[t].h * (H / 600);
+ ctx.fillStyle = hue;
+ ctx.globalAlpha = 0.9;
+ ctx.fillRect(tx, gy - th, tw, th);
+ tx += tw + cw * 0.02;
+ }
+ ctx.globalAlpha = 1;
+ ctx.restore();
+ // little windows
+ ctx.fillStyle = "rgba(255,255,180,0.5)";
+ var wx = bx + 3;
+ for (var wxi = 0; wxi < 5; wxi++) {
+ ctx.fillRect(wx + wxi * (cw / 6), gy - 10, 1.5, 1.5);
+ }
+ }
+
+ function drawBase(b) {
+ var gy = groundY();
+ ctx.save();
+ var lowAmmo = b.ammo <= 0;
+ var col = lowAmmo ? "#5a6b78" : "#ffd23f";
+ ctx.shadowColor = col;
+ ctx.shadowBlur = lowAmmo ? 0 : 14;
+ ctx.fillStyle = col;
+ // triangular battery
+ ctx.beginPath();
+ var bw = W * 0.03;
+ ctx.moveTo(b.x - bw, gy + 8);
+ ctx.lineTo(b.x + bw, gy + 8);
+ ctx.lineTo(b.x, gy - 16);
+ ctx.closePath();
+ ctx.fill();
+ ctx.restore();
+
+ // ammo count
+ ctx.fillStyle = lowAmmo ? "#7a8792" : "#fff3c4";
+ ctx.font = "bold " + Math.round(11 * (H / 600) + 6) + "px monospace";
+ ctx.textAlign = "center";
+ ctx.textBaseline = "top";
+ ctx.fillText(String(b.ammo), b.x, gy + 12);
+ }
+
+ function drawEnemies() {
+ for (var i = 0; i < enemies.length; i++) {
+ var e = enemies[i];
+ // trail
+ ctx.save();
+ var col = e.smart ? "#ff4de0" : "#ff5a5a";
+ ctx.strokeStyle = col;
+ ctx.shadowColor = col;
+ ctx.shadowBlur = 8;
+ ctx.lineWidth = 1.6;
+ ctx.beginPath();
+ ctx.moveTo(e.sx, e.sy);
+ for (var t = 0; t < e.trail.length; t++) {
+ ctx.lineTo(e.trail[t].x, e.trail[t].y);
+ }
+ ctx.lineTo(e.x, e.y);
+ ctx.stroke();
+ // head
+ ctx.fillStyle = "#fff";
+ ctx.beginPath();
+ ctx.arc(e.x, e.y, e.smart ? 3.4 : 2.6, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.restore();
+ }
+ }
+
+ function drawFriendlies() {
+ for (var i = 0; i < friendlies.length; i++) {
+ var f = friendlies[i];
+ ctx.save();
+ ctx.strokeStyle = "#7dffea";
+ ctx.shadowColor = "#7dffea";
+ ctx.shadowBlur = 10;
+ ctx.lineWidth = 1.8;
+ ctx.beginPath();
+ if (f.trail.length) {
+ ctx.moveTo(f.trail[0].x, f.trail[0].y);
+ for (var t = 1; t < f.trail.length; t++) ctx.lineTo(f.trail[t].x, f.trail[t].y);
+ ctx.lineTo(f.x, f.y);
+ }
+ ctx.stroke();
+ // target marker
+ ctx.strokeStyle = "rgba(125,255,234,0.5)";
+ ctx.shadowBlur = 0;
+ ctx.lineWidth = 1;
+ ctx.beginPath();
+ ctx.moveTo(f.tx - 5, f.ty); ctx.lineTo(f.tx + 5, f.ty);
+ ctx.moveTo(f.tx, f.ty - 5); ctx.lineTo(f.tx, f.ty + 5);
+ ctx.stroke();
+ ctx.restore();
+ }
+ }
+
+ function drawBlasts() {
+ for (var i = 0; i < blasts.length; i++) {
+ var B = blasts[i];
+ var hueT = (tGlobal * 6 + i) % 3;
+ var color = hueT < 1 ? "#fff6a8" : (hueT < 2 ? "#7dffea" : "#ff8ad4");
+ ctx.save();
+ ctx.globalCompositeOperation = "lighter";
+ // outer ring
+ ctx.strokeStyle = color;
+ ctx.shadowColor = color;
+ ctx.shadowBlur = 20;
+ ctx.lineWidth = 3;
+ ctx.beginPath();
+ ctx.arc(B.x, B.y, B.r, 0, Math.PI * 2);
+ ctx.stroke();
+ // inner fill
+ var grad = ctx.createRadialGradient(B.x, B.y, 0, B.x, B.y, B.r);
+ grad.addColorStop(0, "rgba(255,255,255,0.5)");
+ grad.addColorStop(0.5, "rgba(125,255,234,0.25)");
+ grad.addColorStop(1, "rgba(125,255,234,0)");
+ ctx.fillStyle = grad;
+ ctx.beginPath();
+ ctx.arc(B.x, B.y, B.r, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.restore();
+ }
+ }
+
+ function drawParticles() {
+ for (var i = 0; i < particles.length; i++) {
+ var p = particles[i];
+ var a = Math.max(0, Math.min(1, p.life));
+ ctx.save();
+ ctx.globalAlpha = a;
+ ctx.fillStyle = p.color;
+ ctx.shadowColor = p.color;
+ ctx.shadowBlur = 6;
+ ctx.fillRect(p.x, p.y, 2.2, 2.2);
+ ctx.restore();
+ }
+ }
+
+ function drawHUD() {
+ ctx.save();
+ ctx.textBaseline = "top";
+ var fs = Math.round(14 * (H / 600) + 6);
+ ctx.font = "bold " + fs + "px monospace";
+ ctx.textAlign = "left";
+ ctx.fillStyle = "#7dffea";
+ ctx.shadowColor = "#7dffea";
+ ctx.shadowBlur = 8;
+ ctx.fillText("SCORE " + score, 14, 12);
+ ctx.textAlign = "center";
+ ctx.fillStyle = "#ffd23f";
+ ctx.shadowColor = "#ffd23f";
+ ctx.fillText("WAVE " + wave, W / 2, 12);
+ ctx.textAlign = "right";
+ ctx.fillStyle = "#ff8ad4";
+ ctx.shadowColor = "#ff8ad4";
+ ctx.fillText("BEST " + best, W - 14, 12);
+ ctx.restore();
+ }
+
+ function drawTitleText(lines, subLines) {
+ ctx.save();
+ ctx.textAlign = "center";
+ ctx.textBaseline = "middle";
+ var cy = H * 0.4;
+ var big = Math.round(Math.min(W * 0.075, 54));
+ ctx.font = "bold " + big + "px monospace";
+ // letter-spaced uppercase title with glow
+ for (var li = 0; li < lines.length; li++) {
+ var txt = lines[li];
+ ctx.fillStyle = "#7dffea";
+ ctx.shadowColor = "#36e0ff";
+ ctx.shadowBlur = 24;
+ drawSpaced(txt.toUpperCase(), W / 2, cy + li * (big * 1.2), big * 0.18);
+ }
+ ctx.shadowBlur = 0;
+ var sm = Math.round(Math.min(W * 0.03, 18));
+ ctx.font = "bold " + sm + "px monospace";
+ ctx.fillStyle = "#ffd23f";
+ var sy = cy + lines.length * (big * 1.2) + 24;
+ for (var si = 0; si < subLines.length; si++) {
+ ctx.shadowColor = "#ffd23f";
+ ctx.shadowBlur = 10;
+ ctx.fillText(subLines[si], W / 2, sy + si * (sm * 1.6));
+ }
+ ctx.restore();
+ }
+
+ function drawSpaced(text, cx, cy, extra) {
+ // measure with tracking
+ var widths = [];
+ var total = 0;
+ for (var i = 0; i < text.length; i++) {
+ var w = ctx.measureText(text[i]).width;
+ widths.push(w);
+ total += w + (i < text.length - 1 ? extra : 0);
+ }
+ var x = cx - total / 2;
+ for (var j = 0; j < text.length; j++) {
+ ctx.textAlign = "left";
+ ctx.fillText(text[j], x, cy);
+ x += widths[j] + extra;
+ }
+ ctx.textAlign = "center";
+ }
+
+ function draw() {
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
+ drawBackground();
+
+ // field
+ var i;
+ for (i = 0; i < cities.length; i++) drawCity(cities[i]);
+ for (i = 0; i < bases.length; i++) drawBase(bases[i]);
+ drawGround();
+
+ drawEnemies();
+ drawFriendlies();
+ drawBlasts();
+ drawParticles();
+
+ // city-hit flash
+ if (flashTimer > 0) {
+ ctx.save();
+ ctx.fillStyle = "rgba(255,60,110," + (flashTimer * 0.6).toFixed(3) + ")";
+ ctx.fillRect(0, 0, W, H);
+ ctx.restore();
+ }
+
+ drawHUD();
+
+ if (state === STATE_START) {
+ drawTitleText(["Missile", "Command"], ["TAP / CLICK TO DEFEND", "PROTECT YOUR 6 CITIES"]);
+ } else if (state === STATE_OVER) {
+ drawTitleText(["Game", "Over"], ["SCORE " + score, "TAP TO PLAY AGAIN"]);
+ } else if (state === STATE_WAVEEND) {
+ ctx.save();
+ ctx.textAlign = "center";
+ ctx.textBaseline = "middle";
+ var big = Math.round(Math.min(W * 0.05, 34));
+ ctx.font = "bold " + big + "px monospace";
+ ctx.fillStyle = "#ffd23f";
+ ctx.shadowColor = "#ffd23f";
+ ctx.shadowBlur = 18;
+ drawSpaced("WAVE " + wave + " CLEAR", W / 2, H * 0.4, big * 0.14);
+ ctx.shadowBlur = 8;
+ var sm = Math.round(Math.min(W * 0.028, 16));
+ ctx.font = "bold " + sm + "px monospace";
+ ctx.fillStyle = "#7dffea";
+ ctx.shadowColor = "#7dffea";
+ ctx.fillText("BONUS TALLY...", W / 2, H * 0.4 + big + 20);
+ ctx.restore();
+ }
+ }
+
+ // ---- Loop ----
+ var last = 0;
+ var running = true;
+ function frame(now) {
+ if (!running) return;
+ if (!last) last = now;
+ var dt = (now - last) / 1000;
+ last = now;
+ if (dt > 0.05) dt = 0.05; // clamp
+ tGlobal += dt;
+
+ // keep field positions in sync with size
+ repositionField();
+
+ try {
+ update(dt);
+ draw();
+ } catch (err) {
+ // never let a frame throw a pageerror
+ running = false;
+ }
+ window.requestAnimationFrame(frame);
+ }
+
+ window.requestAnimationFrame(frame);
+ }
+
+ if (document.readyState === "loading") {
+ document.addEventListener("DOMContentLoaded", boot);
+ } else {
+ boot();
+ }
+})();
+</script>
+</body>
+</html>
diff --git a/games/racer/index.html b/games/racer/index.html
new file mode 100644
index 0000000..84c5673
--- /dev/null
+++ b/games/racer/index.html
@@ -0,0 +1,882 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8" />
+<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
+<title>NEON DRIFTER</title>
+<style>
+ :root {
+ --bg: #0a0e14;
+ --neon: #ff2fd0;
+ --cyan: #22e7ff;
+ --amber: #ffd23f;
+ --edge: #7a3cff;
+ }
+ * { box-sizing: border-box; margin: 0; padding: 0; }
+ html, body {
+ width: 100%; height: 100%;
+ background: var(--bg);
+ overflow: hidden;
+ touch-action: none;
+ -webkit-user-select: none;
+ user-select: none;
+ -webkit-tap-highlight-color: transparent;
+ font-family: "Segoe UI", Helvetica, Arial, sans-serif;
+ }
+ #wrap {
+ position: fixed;
+ inset: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: radial-gradient(ellipse at 50% 120%, #16203a 0%, #0a0e14 60%, #05070c 100%);
+ }
+ canvas { display: block; touch-action: none; }
+
+ /* Touch controls */
+ .touch {
+ position: fixed;
+ bottom: 0;
+ width: 40%;
+ height: 45%;
+ max-height: 320px;
+ z-index: 20;
+ display: none;
+ align-items: flex-end;
+ justify-content: center;
+ padding-bottom: 22px;
+ -webkit-user-select: none;
+ user-select: none;
+ }
+ .touch.left { left: 0; }
+ .touch.right { right: 0; }
+ .touch .btn {
+ width: 92px; height: 92px;
+ border-radius: 50%;
+ border: 2px solid rgba(34,231,255,0.55);
+ background: rgba(34,231,255,0.08);
+ color: #cffcff;
+ font-size: 40px;
+ line-height: 88px;
+ text-align: center;
+ box-shadow: 0 0 18px rgba(34,231,255,0.35), inset 0 0 14px rgba(34,231,255,0.15);
+ backdrop-filter: blur(2px);
+ }
+ .touch.active .btn {
+ background: rgba(255,47,208,0.22);
+ border-color: rgba(255,47,208,0.8);
+ box-shadow: 0 0 24px rgba(255,47,208,0.6), inset 0 0 16px rgba(255,47,208,0.25);
+ }
+ @media (pointer: coarse) {
+ .touch { display: flex; }
+ }
+</style>
+</head>
+<body>
+ <div id="wrap">
+ <canvas id="game"></canvas>
+ </div>
+ <div class="touch left" id="touchLeft"><div class="btn">←</div></div>
+ <div class="touch right" id="touchRight"><div class="btn">→</div></div>
+
+<script>
+(function () {
+ "use strict";
+
+ function boot() {
+ var canvas = document.getElementById("game");
+ if (!canvas) return;
+ var ctx = canvas.getContext("2d");
+ if (!ctx) return;
+
+ var BEST_KEY = "abrams-racer-best";
+
+ // ---- logical dimensions (portrait road) ----
+ var W = 480, H = 800; // logical
+ var DPR = 1;
+
+ function fitCanvas() {
+ var vw = Math.max(1, window.innerWidth || document.documentElement.clientWidth || 480);
+ var vh = Math.max(1, window.innerHeight || document.documentElement.clientHeight || 800);
+ var targetAR = W / H;
+ var winAR = vw / vh;
+ var cssW, cssH;
+ if (winAR > targetAR) { cssH = vh; cssW = vh * targetAR; }
+ else { cssW = vw; cssH = vw / targetAR; }
+ DPR = Math.min(window.devicePixelRatio || 1, 2);
+ canvas.style.width = cssW + "px";
+ canvas.style.height = cssH + "px";
+ canvas.width = Math.round(W * DPR);
+ canvas.height = Math.round(H * DPR);
+ ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
+ }
+ window.addEventListener("resize", fitCanvas, { passive: true });
+ window.addEventListener("orientationchange", fitCanvas, { passive: true });
+ fitCanvas();
+
+ // ---- road geometry ----
+ var LANES = 4;
+ var ROAD_MARGIN = 56; // side margin to road edge
+ var roadLeft = ROAD_MARGIN;
+ var roadRight = W - ROAD_MARGIN;
+ var roadW = roadRight - roadLeft;
+ var laneW = roadW / LANES;
+
+ function laneCenter(i) { return roadLeft + laneW * (i + 0.5); }
+
+ // ---- game state ----
+ var STATE_MENU = 0, STATE_PLAY = 1, STATE_OVER = 2;
+ var state = STATE_MENU;
+
+ var best = 0;
+ try {
+ var stored = localStorage.getItem(BEST_KEY);
+ if (stored != null) { var b = parseFloat(stored); if (isFinite(b)) best = b; }
+ } catch (e) {}
+
+ var player, traffic, pickups, particles;
+ var dashOffset, scroll, distance, score, speed, baseSpeed, elapsed, spawnTimer, pickTimer;
+ var boost, shakeMag, newBest;
+
+ function resetGame() {
+ player = {
+ x: laneCenter(1),
+ y: H - 150,
+ w: 46, h: 84,
+ vx: 0,
+ color: "#22e7ff"
+ };
+ traffic = [];
+ pickups = [];
+ particles = [];
+ dashOffset = 0;
+ scroll = 0;
+ distance = 0;
+ score = 0;
+ baseSpeed = 260; // px/sec world scroll
+ speed = baseSpeed;
+ elapsed = 0;
+ spawnTimer = 0.6;
+ pickTimer = 3.0;
+ boost = 0;
+ shakeMag = 0;
+ newBest = false;
+ }
+ resetGame();
+
+ // ---- input ----
+ var keys = { left: false, right: false, up: false, down: false };
+ var touchL = false, touchR = false;
+
+ function onKeyDown(e) {
+ var k = e.key;
+ var handled = true;
+ if (k === "ArrowLeft" || k === "a" || k === "A") keys.left = true;
+ else if (k === "ArrowRight" || k === "d" || k === "D") keys.right = true;
+ else if (k === "ArrowUp" || k === "w" || k === "W") keys.up = true;
+ else if (k === "ArrowDown" || k === "s" || k === "S") keys.down = true;
+ else if (k === " " || k === "Enter") {
+ if (state === STATE_MENU) startPlay();
+ else if (state === STATE_OVER) { resetGame(); startPlay(); }
+ } else handled = false;
+
+ if (handled) {
+ // resume/init audio on first gesture
+ ensureAudio();
+ if (state === STATE_MENU && (k === "ArrowLeft" || k === "ArrowRight" || k === "a" || k === "A" || k === "d" || k === "D")) {
+ startPlay();
+ }
+ e.preventDefault();
+ }
+ }
+ function onKeyUp(e) {
+ var k = e.key;
+ if (k === "ArrowLeft" || k === "a" || k === "A") keys.left = false;
+ else if (k === "ArrowRight" || k === "d" || k === "D") keys.right = false;
+ else if (k === "ArrowUp" || k === "w" || k === "W") keys.up = false;
+ else if (k === "ArrowDown" || k === "s" || k === "S") keys.down = false;
+ }
+ window.addEventListener("keydown", onKeyDown, { passive: false });
+ window.addEventListener("keyup", onKeyUp, { passive: false });
+
+ // touch buttons
+ function bindTouch(el, setter) {
+ if (!el) return;
+ function down(e) {
+ e.preventDefault();
+ ensureAudio();
+ el.classList.add("active");
+ setter(true);
+ if (state === STATE_MENU) startPlay();
+ else if (state === STATE_OVER) { resetGame(); startPlay(); }
+ }
+ function up(e) {
+ e.preventDefault();
+ el.classList.remove("active");
+ setter(false);
+ }
+ el.addEventListener("touchstart", down, { passive: false });
+ el.addEventListener("touchend", up, { passive: false });
+ el.addEventListener("touchcancel", up, { passive: false });
+ el.addEventListener("mousedown", down);
+ window.addEventListener("mouseup", up);
+ }
+ bindTouch(document.getElementById("touchLeft"), function (v) { touchL = v; });
+ bindTouch(document.getElementById("touchRight"), function (v) { touchR = v; });
+
+ // tap canvas to start/restart
+ canvas.addEventListener("pointerdown", function (e) {
+ ensureAudio();
+ if (state === STATE_MENU) { e.preventDefault(); startPlay(); }
+ else if (state === STATE_OVER) { e.preventDefault(); resetGame(); startPlay(); }
+ });
+
+ // prevent scroll gestures
+ document.addEventListener("touchmove", function (e) { e.preventDefault(); }, { passive: false });
+
+ // ---- audio (lazy) ----
+ var actx = null, engineOsc = null, engineGain = null, engineFilter = null;
+ function ensureAudio() {
+ try {
+ if (!actx) {
+ var AC = window.AudioContext || window.webkitAudioContext;
+ if (!AC) return;
+ actx = new AC();
+ engineGain = actx.createGain();
+ engineGain.gain.value = 0.0;
+ engineFilter = actx.createBiquadFilter();
+ engineFilter.type = "lowpass";
+ engineFilter.frequency.value = 700;
+ engineOsc = actx.createOscillator();
+ engineOsc.type = "sawtooth";
+ engineOsc.frequency.value = 70;
+ engineOsc.connect(engineFilter);
+ engineFilter.connect(engineGain);
+ engineGain.connect(actx.destination);
+ engineOsc.start();
+ }
+ if (actx.state === "suspended") actx.resume();
+ } catch (e) { actx = null; }
+ }
+ function blip(freq, dur, type, vol) {
+ if (!actx) return;
+ try {
+ var o = actx.createOscillator();
+ var g = actx.createGain();
+ o.type = type || "square";
+ o.frequency.value = freq;
+ g.gain.value = 0.0001;
+ o.connect(g); g.connect(actx.destination);
+ var t = actx.currentTime;
+ g.gain.setValueAtTime(0.0001, t);
+ g.gain.exponentialRampToValueAtTime(vol || 0.15, t + 0.01);
+ g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
+ o.start(t);
+ o.stop(t + dur + 0.02);
+ } catch (e) {}
+ }
+ function crashSound() {
+ if (!actx) return;
+ try {
+ var o = actx.createOscillator();
+ var g = actx.createGain();
+ o.type = "sawtooth";
+ o.frequency.setValueAtTime(220, actx.currentTime);
+ o.frequency.exponentialRampToValueAtTime(40, actx.currentTime + 0.45);
+ g.gain.setValueAtTime(0.3, actx.currentTime);
+ g.gain.exponentialRampToValueAtTime(0.0001, actx.currentTime + 0.5);
+ o.connect(g); g.connect(actx.destination);
+ o.start();
+ o.stop(actx.currentTime + 0.55);
+ } catch (e) {}
+ }
+ function updateEngine() {
+ if (!actx || !engineGain || !engineOsc) return;
+ try {
+ var target = state === STATE_PLAY ? 0.05 : 0.0;
+ engineGain.gain.setTargetAtTime(target, actx.currentTime, 0.1);
+ var f = 55 + (speed - baseSpeed) * 0.06 + (boost > 0 ? 40 : 0);
+ engineOsc.frequency.setTargetAtTime(f, actx.currentTime, 0.08);
+ engineFilter.frequency.setTargetAtTime(600 + (speed - 200) * 1.2, actx.currentTime, 0.1);
+ } catch (e) {}
+ }
+
+ function startPlay() {
+ if (state === STATE_PLAY) return;
+ state = STATE_PLAY;
+ ensureAudio();
+ blip(660, 0.08, "square", 0.12);
+ }
+
+ // ---- spawning ----
+ var TRAFFIC_COLORS = ["#ff2fd0", "#ffd23f", "#9d5bff", "#ff5a3c", "#3cff8a"];
+
+ function spawnTraffic() {
+ var lane = Math.floor(Math.random() * LANES);
+ var w = 46, h = 84;
+ // occasionally a wider slow truck
+ if (Math.random() < 0.18) { w = 54; h = 110; }
+ var carSpeed = 60 + Math.random() * 120; // slower than world; downward relative
+ traffic.push({
+ lane: lane,
+ x: laneCenter(lane),
+ y: -h - 20,
+ w: w, h: h,
+ rel: carSpeed,
+ color: TRAFFIC_COLORS[(Math.random() * TRAFFIC_COLORS.length) | 0]
+ });
+ }
+
+ function spawnPickup() {
+ var lane = Math.floor(Math.random() * LANES);
+ var type = Math.random() < 0.4 ? "boost" : "coin";
+ pickups.push({
+ x: laneCenter(lane),
+ y: -30,
+ r: 14,
+ type: type,
+ t: 0
+ });
+ }
+
+ function laneOccupiedNear(lane, y, minGap) {
+ for (var i = 0; i < traffic.length; i++) {
+ var t = traffic[i];
+ if (t.lane === lane && Math.abs(t.y - y) < minGap) return true;
+ }
+ return false;
+ }
+
+ // ---- particles ----
+ function addParticles(x, y, color, n, spread) {
+ for (var i = 0; i < n; i++) {
+ particles.push({
+ x: x, y: y,
+ vx: (Math.random() - 0.5) * spread,
+ vy: (Math.random() - 0.5) * spread - 30,
+ life: 0.4 + Math.random() * 0.5,
+ age: 0,
+ color: color,
+ size: 2 + Math.random() * 3
+ });
+ }
+ }
+
+ // ---- collision ----
+ function rectsOverlap(ax, ay, aw, ah, bx, by, bw, bh) {
+ return Math.abs(ax - bx) * 2 < (aw + bw) && Math.abs(ay - by) * 2 < (ah + bh);
+ }
+
+ // ---- update ----
+ function update(dt) {
+ if (state === STATE_PLAY) {
+ elapsed += dt;
+ // speed ramps over time
+ baseSpeed = 260 + elapsed * 9;
+ if (baseSpeed > 720) baseSpeed = 720;
+ var boostFactor = boost > 0 ? 1.55 : 1;
+ // throttle (up/down) small modifier
+ var throttle = 1;
+ if (keys.up) throttle = 1.18;
+ else if (keys.down) throttle = 0.72;
+ speed = baseSpeed * boostFactor * throttle;
+
+ if (boost > 0) boost -= dt;
+
+ // steering
+ var steerLeft = keys.left || touchL;
+ var steerRight = keys.right || touchR;
+ var accel = 900;
+ if (steerLeft && !steerRight) player.vx -= accel * dt;
+ else if (steerRight && !steerLeft) player.vx += accel * dt;
+ else player.vx *= Math.pow(0.001, dt); // strong damping when no input
+ // clamp lateral speed
+ var maxVx = 420;
+ if (player.vx > maxVx) player.vx = maxVx;
+ if (player.vx < -maxVx) player.vx = -maxVx;
+ player.x += player.vx * dt;
+ // road clamp
+ var half = player.w / 2 + 4;
+ if (player.x < roadLeft + half) { player.x = roadLeft + half; player.vx = 0; }
+ if (player.x > roadRight - half) { player.x = roadRight - half; player.vx = 0; }
+
+ // scroll + distance
+ scroll += speed * dt;
+ dashOffset = (dashOffset + speed * dt) % 80;
+ distance += speed * dt * 0.02; // meters-ish
+ score += speed * dt * 0.05;
+
+ // spawn traffic
+ spawnTimer -= dt;
+ var spawnEvery = Math.max(0.35, 1.1 - elapsed * 0.01);
+ if (spawnTimer <= 0) {
+ spawnTimer = spawnEvery * (0.6 + Math.random() * 0.8);
+ // try a lane that isn't crowded near top
+ var tries = 3;
+ while (tries-- > 0) {
+ var lane = Math.floor(Math.random() * LANES);
+ if (!laneOccupiedNear(lane, -60, 160)) {
+ var w = 46, h = 84;
+ if (Math.random() < 0.18) { w = 54; h = 110; }
+ traffic.push({
+ lane: lane, x: laneCenter(lane), y: -h - 20,
+ w: w, h: h, rel: 60 + Math.random() * 120,
+ color: TRAFFIC_COLORS[(Math.random() * TRAFFIC_COLORS.length) | 0]
+ });
+ break;
+ }
+ }
+ }
+
+ // spawn pickups
+ pickTimer -= dt;
+ if (pickTimer <= 0) {
+ pickTimer = 3.5 + Math.random() * 3.5;
+ var plane = Math.floor(Math.random() * LANES);
+ if (!laneOccupiedNear(plane, -40, 120)) {
+ pickups.push({
+ x: laneCenter(plane), y: -30, r: 14,
+ type: Math.random() < 0.4 ? "boost" : "coin", t: 0
+ });
+ }
+ }
+
+ // move traffic (world scrolls down; traffic moves down toward player)
+ for (var i = traffic.length - 1; i >= 0; i--) {
+ var t = traffic[i];
+ t.y += (speed - t.rel) * dt; // relative to player
+ // engine exhaust trail
+ if (Math.random() < 0.3) addParticles(t.x, t.y - t.h / 2, "rgba(120,140,180,0.4)", 1, 20);
+ if (t.y - t.h > H + 40) { traffic.splice(i, 1); continue; }
+ // collision
+ if (rectsOverlap(player.x, player.y, player.w - 8, player.h - 10, t.x, t.y, t.w - 8, t.h - 10)) {
+ gameOver();
+ return;
+ }
+ }
+
+ // move pickups
+ for (var j = pickups.length - 1; j >= 0; j--) {
+ var p = pickups[j];
+ p.y += speed * dt;
+ p.t += dt;
+ if (p.y - p.r > H + 40) { pickups.splice(j, 1); continue; }
+ var dx = p.x - player.x, dy = p.y - player.y;
+ if (Math.abs(dx) < (player.w / 2 + p.r) && Math.abs(dy) < (player.h / 2 + p.r)) {
+ if (p.type === "boost") {
+ boost = 2.2;
+ score += 50;
+ addParticles(p.x, p.y, "#22e7ff", 18, 220);
+ blip(880, 0.14, "sawtooth", 0.16);
+ blip(1320, 0.14, "sawtooth", 0.1);
+ } else {
+ score += 100;
+ addParticles(p.x, p.y, "#ffd23f", 14, 180);
+ blip(1046, 0.09, "square", 0.14);
+ }
+ pickups.splice(j, 1);
+ }
+ }
+
+ // player exhaust / speed particles
+ if (Math.random() < (boost > 0 ? 0.9 : 0.4)) {
+ addParticles(player.x + (Math.random() - 0.5) * 18, player.y + player.h / 2,
+ boost > 0 ? "#22e7ff" : "rgba(255,120,220,0.6)", 1, 40);
+ }
+ }
+
+ // particles always update
+ for (var k = particles.length - 1; k >= 0; k--) {
+ var pt = particles[k];
+ pt.age += dt;
+ if (pt.age >= pt.life) { particles.splice(k, 1); continue; }
+ pt.x += pt.vx * dt;
+ pt.y += pt.vy * dt;
+ pt.vy += 60 * dt;
+ }
+
+ // shake decay
+ if (shakeMag > 0) { shakeMag -= dt * 40; if (shakeMag < 0) shakeMag = 0; }
+
+ updateEngine();
+ }
+
+ function gameOver() {
+ state = STATE_OVER;
+ shakeMag = 18;
+ addParticles(player.x, player.y, "#ff5a3c", 40, 400);
+ addParticles(player.x, player.y, "#ffd23f", 30, 320);
+ crashSound();
+ if (distance > best) {
+ best = distance;
+ newBest = true;
+ try { localStorage.setItem(BEST_KEY, String(Math.floor(best))); } catch (e) {}
+ }
+ }
+
+ // ---- rendering ----
+ function drawCar(x, y, w, h, color, isPlayer) {
+ ctx.save();
+ ctx.translate(x, y);
+ // glow
+ ctx.shadowColor = color;
+ ctx.shadowBlur = isPlayer ? 22 : 14;
+ // body
+ ctx.fillStyle = color;
+ roundRect(-w / 2, -h / 2, w, h, 10);
+ ctx.fill();
+ ctx.shadowBlur = 0;
+ // cockpit / windows
+ ctx.fillStyle = "rgba(6,10,20,0.85)";
+ roundRect(-w / 2 + 7, -h / 2 + 12, w - 14, h * 0.28, 6);
+ ctx.fill();
+ roundRect(-w / 2 + 7, h / 2 - h * 0.32, w - 14, h * 0.20, 6);
+ ctx.fill();
+ // center stripe
+ ctx.fillStyle = "rgba(255,255,255,0.18)";
+ ctx.fillRect(-2, -h / 2 + 6, 4, h - 12);
+ // headlights/taillights
+ if (isPlayer) {
+ ctx.fillStyle = "#fff6c8";
+ ctx.fillRect(-w / 2 + 5, -h / 2 + 2, 8, 5);
+ ctx.fillRect(w / 2 - 13, -h / 2 + 2, 8, 5);
+ } else {
+ ctx.fillStyle = "#ff3b3b";
+ ctx.shadowColor = "#ff3b3b"; ctx.shadowBlur = 8;
+ ctx.fillRect(-w / 2 + 5, h / 2 - 7, 8, 5);
+ ctx.fillRect(w / 2 - 13, h / 2 - 7, 8, 5);
+ ctx.shadowBlur = 0;
+ }
+ ctx.restore();
+ }
+
+ function roundRect(x, y, w, h, r) {
+ if (r > w / 2) r = w / 2;
+ if (r > h / 2) r = 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 draw() {
+ ctx.save();
+ // screen shake
+ if (shakeMag > 0) {
+ ctx.translate((Math.random() - 0.5) * shakeMag, (Math.random() - 0.5) * shakeMag);
+ }
+
+ // background
+ ctx.fillStyle = "#05070c";
+ ctx.fillRect(-40, -40, W + 80, H + 80);
+
+ // side "grass"/gradient synth glow bands
+ var g = ctx.createLinearGradient(0, 0, 0, H);
+ g.addColorStop(0, "#0c1226");
+ g.addColorStop(1, "#141a33");
+ ctx.fillStyle = g;
+ ctx.fillRect(-40, -40, W + 80, H + 80);
+
+ // road surface
+ var rg = ctx.createLinearGradient(0, 0, 0, H);
+ rg.addColorStop(0, "#12141f");
+ rg.addColorStop(1, "#1a1d2e");
+ ctx.fillStyle = rg;
+ ctx.fillRect(roadLeft, -40, roadW, H + 80);
+
+ // glowing road edges
+ drawGlowLine(roadLeft, 0, roadLeft, H, "#ff2fd0", 4);
+ drawGlowLine(roadRight, 0, roadRight, H, "#22e7ff", 4);
+
+ // moving dashed lane markers
+ ctx.save();
+ ctx.lineWidth = 5;
+ ctx.setLineDash([32, 48]);
+ ctx.lineDashOffset = -dashOffset;
+ ctx.strokeStyle = "rgba(180,200,255,0.55)";
+ ctx.shadowColor = "rgba(120,150,255,0.5)";
+ ctx.shadowBlur = 8;
+ for (var li = 1; li < LANES; li++) {
+ var lx = roadLeft + laneW * li;
+ ctx.beginPath();
+ ctx.moveTo(lx, -40);
+ ctx.lineTo(lx, H + 40);
+ ctx.stroke();
+ }
+ ctx.restore();
+
+ // horizon glow lines (perspective illusion) faint moving cross-lines
+ ctx.save();
+ ctx.strokeStyle = "rgba(122,60,255,0.10)";
+ ctx.lineWidth = 2;
+ for (var hy = 0; hy < H + 80; hy += 80) {
+ var yy = (hy - (dashOffset % 80));
+ ctx.beginPath();
+ ctx.moveTo(roadLeft, yy);
+ ctx.lineTo(roadRight, yy);
+ ctx.stroke();
+ }
+ ctx.restore();
+
+ // pickups
+ for (var i = 0; i < pickups.length; i++) {
+ var p = pickups[i];
+ var pulse = 1 + Math.sin(p.t * 8) * 0.12;
+ ctx.save();
+ ctx.translate(p.x, p.y);
+ ctx.scale(pulse, pulse);
+ if (p.type === "boost") {
+ ctx.shadowColor = "#22e7ff"; ctx.shadowBlur = 18;
+ ctx.fillStyle = "#22e7ff";
+ // lightning bolt
+ ctx.beginPath();
+ ctx.moveTo(-4, -14); ctx.lineTo(6, -2); ctx.lineTo(0, -2);
+ ctx.lineTo(4, 14); ctx.lineTo(-6, 0); ctx.lineTo(0, 0);
+ ctx.closePath();
+ ctx.fill();
+ } else {
+ ctx.shadowColor = "#ffd23f"; ctx.shadowBlur = 16;
+ ctx.fillStyle = "#ffd23f";
+ ctx.beginPath();
+ ctx.arc(0, 0, 12, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.fillStyle = "rgba(120,80,0,0.55)";
+ ctx.font = "bold 15px sans-serif";
+ ctx.textAlign = "center";
+ ctx.textBaseline = "middle";
+ ctx.fillText("$", 0, 1);
+ }
+ ctx.restore();
+ }
+
+ // traffic
+ for (var t = 0; t < traffic.length; t++) {
+ var c = traffic[t];
+ drawCar(c.x, c.y, c.w, c.h, c.color, false);
+ }
+
+ // particles
+ for (var pp = 0; pp < particles.length; pp++) {
+ var pt = particles[pp];
+ var a = 1 - pt.age / pt.life;
+ ctx.globalAlpha = a < 0 ? 0 : a;
+ ctx.fillStyle = pt.color;
+ ctx.fillRect(pt.x - pt.size / 2, pt.y - pt.size / 2, pt.size, pt.size);
+ }
+ ctx.globalAlpha = 1;
+
+ // player
+ if (state !== STATE_OVER || (Math.floor(elapsed * 10) % 2 === 0)) {
+ // boost aura
+ if (boost > 0) {
+ ctx.save();
+ ctx.globalAlpha = 0.5 + Math.sin(elapsed * 30) * 0.2;
+ ctx.shadowColor = "#22e7ff"; ctx.shadowBlur = 30;
+ ctx.fillStyle = "rgba(34,231,255,0.15)";
+ roundRect(player.x - player.w / 2 - 6, player.y - player.h / 2 - 6, player.w + 12, player.h + 12, 12);
+ ctx.fill();
+ ctx.restore();
+ }
+ drawCar(player.x, player.y, player.w, player.h, player.color, true);
+ }
+
+ ctx.restore(); // shake
+
+ // HUD
+ drawHUD();
+
+ // overlays
+ if (state === STATE_MENU) drawMenu();
+ else if (state === STATE_OVER) drawGameOver();
+ }
+
+ function drawGlowLine(x1, y1, x2, y2, color, width) {
+ ctx.save();
+ ctx.strokeStyle = color;
+ ctx.lineWidth = width;
+ ctx.shadowColor = color;
+ ctx.shadowBlur = 16;
+ ctx.beginPath();
+ ctx.moveTo(x1, y1);
+ ctx.lineTo(x2, y2);
+ ctx.stroke();
+ ctx.restore();
+ }
+
+ function drawHUD() {
+ ctx.save();
+ ctx.textBaseline = "top";
+ // distance (big)
+ ctx.textAlign = "left";
+ ctx.fillStyle = "rgba(180,220,255,0.55)";
+ ctx.font = "600 13px 'Segoe UI', sans-serif";
+ ctx.fillText("DISTANCE", 18, 18);
+ ctx.fillStyle = "#e8f6ff";
+ ctx.shadowColor = "#22e7ff"; ctx.shadowBlur = 12;
+ ctx.font = "800 30px 'Segoe UI', sans-serif";
+ ctx.fillText(Math.floor(distance) + " m", 18, 32);
+ ctx.shadowBlur = 0;
+
+ // score (right)
+ ctx.textAlign = "right";
+ ctx.fillStyle = "rgba(255,180,240,0.55)";
+ ctx.font = "600 13px 'Segoe UI', sans-serif";
+ ctx.fillText("SCORE", W - 18, 18);
+ ctx.fillStyle = "#ffe3f8";
+ ctx.shadowColor = "#ff2fd0"; ctx.shadowBlur = 12;
+ ctx.font = "800 30px 'Segoe UI', sans-serif";
+ ctx.fillText(String(Math.floor(score)), W - 18, 32);
+ ctx.shadowBlur = 0;
+
+ // best (top center)
+ ctx.textAlign = "center";
+ ctx.fillStyle = "rgba(255,210,63,0.75)";
+ ctx.font = "700 13px 'Segoe UI', sans-serif";
+ ctx.fillText("BEST " + Math.floor(best) + " m", W / 2, 22);
+
+ // speedometer bar
+ var spNorm = Math.min(1, (speed - 200) / 560);
+ var barW = 160, barH = 8, barX = W / 2 - barW / 2, barY = 44;
+ ctx.fillStyle = "rgba(255,255,255,0.08)";
+ roundRect(barX, barY, barW, barH, 4); ctx.fill();
+ var sg = ctx.createLinearGradient(barX, 0, barX + barW, 0);
+ sg.addColorStop(0, "#22e7ff");
+ sg.addColorStop(0.6, "#ff2fd0");
+ sg.addColorStop(1, "#ff5a3c");
+ ctx.fillStyle = sg;
+ roundRect(barX, barY, Math.max(6, barW * spNorm), barH, 4); ctx.fill();
+
+ if (boost > 0) {
+ ctx.textAlign = "center";
+ ctx.fillStyle = "#22e7ff";
+ ctx.shadowColor = "#22e7ff"; ctx.shadowBlur = 14;
+ ctx.font = "800 16px 'Segoe UI', sans-serif";
+ ctx.fillText("⚡ BOOST", W / 2, 58);
+ ctx.shadowBlur = 0;
+ }
+ ctx.restore();
+ }
+
+ function drawTitle(cx, cy) {
+ ctx.save();
+ ctx.textAlign = "center";
+ ctx.textBaseline = "middle";
+ // "NEON DRIFTER" uppercase, letter-spaced
+ var title = "N E O N D R I F T E R";
+ ctx.font = "800 34px 'Segoe UI', sans-serif";
+ ctx.fillStyle = "#22e7ff";
+ ctx.shadowColor = "#ff2fd0"; ctx.shadowBlur = 24;
+ ctx.fillText(title, cx, cy);
+ ctx.shadowColor = "#22e7ff"; ctx.shadowBlur = 18;
+ ctx.fillStyle = "#ff2fd0";
+ ctx.fillText(title, cx + 1.5, cy + 1.5);
+ ctx.restore();
+ }
+
+ function overlayPanel() {
+ ctx.save();
+ ctx.fillStyle = "rgba(5,7,12,0.72)";
+ ctx.fillRect(0, 0, W, H);
+ ctx.restore();
+ }
+
+ function drawMenu() {
+ overlayPanel();
+ drawTitle(W / 2, H * 0.32);
+ ctx.save();
+ ctx.textAlign = "center";
+ ctx.textBaseline = "middle";
+ ctx.fillStyle = "rgba(200,225,255,0.85)";
+ ctx.font = "600 17px 'Segoe UI', sans-serif";
+ ctx.fillText("Weave through neon traffic. Don't crash.", W / 2, H * 0.44);
+
+ var pulse = 0.6 + Math.sin(elapsed * 4) * 0.4;
+ ctx.globalAlpha = pulse;
+ ctx.fillStyle = "#ffd23f";
+ ctx.shadowColor = "#ffd23f"; ctx.shadowBlur = 14;
+ ctx.font = "800 22px 'Segoe UI', sans-serif";
+ ctx.fillText("TAP / PRESS ← → TO START", W / 2, H * 0.55);
+ ctx.globalAlpha = 1;
+ ctx.shadowBlur = 0;
+
+ ctx.fillStyle = "rgba(160,190,230,0.6)";
+ ctx.font = "500 14px 'Segoe UI', sans-serif";
+ ctx.fillText("Arrows / A-D steer • Up/Down throttle", W / 2, H * 0.63);
+ ctx.fillText("⚡ = boost $ = coin", W / 2, H * 0.67);
+ ctx.fillText("BEST " + Math.floor(best) + " m", W / 2, H * 0.73);
+ ctx.restore();
+ }
+
+ function drawGameOver() {
+ overlayPanel();
+ ctx.save();
+ ctx.textAlign = "center";
+ ctx.textBaseline = "middle";
+
+ ctx.fillStyle = "#ff5a3c";
+ ctx.shadowColor = "#ff2fd0"; ctx.shadowBlur = 22;
+ ctx.font = "800 44px 'Segoe UI', sans-serif";
+ ctx.fillText("CRASHED", W / 2, H * 0.30);
+ ctx.shadowBlur = 0;
+
+ if (newBest) {
+ ctx.fillStyle = "#ffd23f";
+ ctx.shadowColor = "#ffd23f"; ctx.shadowBlur = 16;
+ ctx.font = "800 22px 'Segoe UI', sans-serif";
+ ctx.fillText("★ NEW BEST ★", W / 2, H * 0.40);
+ ctx.shadowBlur = 0;
+ }
+
+ ctx.fillStyle = "#e8f6ff";
+ ctx.font = "700 26px 'Segoe UI', sans-serif";
+ ctx.fillText(Math.floor(distance) + " m", W / 2, H * 0.47);
+ ctx.fillStyle = "rgba(200,225,255,0.7)";
+ ctx.font = "600 16px 'Segoe UI', sans-serif";
+ ctx.fillText("SCORE " + Math.floor(score), W / 2, H * 0.52);
+ ctx.fillText("BEST " + Math.floor(best) + " m", W / 2, H * 0.56);
+
+ var pulse = 0.6 + Math.sin(elapsed * 5) * 0.4;
+ ctx.globalAlpha = pulse;
+ ctx.fillStyle = "#22e7ff";
+ ctx.shadowColor = "#22e7ff"; ctx.shadowBlur = 14;
+ ctx.font = "800 20px 'Segoe UI', sans-serif";
+ ctx.fillText("TAP / PRESS SPACE TO RACE AGAIN", W / 2, H * 0.66);
+ ctx.globalAlpha = 1;
+ ctx.restore();
+ }
+
+ // ---- main loop ----
+ var lastT = 0;
+ function frame(ts) {
+ if (!lastT) lastT = ts;
+ var dt = (ts - lastT) / 1000;
+ lastT = ts;
+ if (dt > 0.05) dt = 0.05; // clamp big gaps
+ if (dt < 0) dt = 0;
+
+ // on game over let elapsed keep advancing for pulsing text
+ if (state === STATE_OVER || state === STATE_MENU) elapsed += dt;
+
+ try {
+ update(dt);
+ draw();
+ } catch (e) {
+ // never throw out of RAF
+ }
+ requestAnimationFrame(frame);
+ }
+ requestAnimationFrame(frame);
+ }
+
+ if (document.readyState === "loading") {
+ document.addEventListener("DOMContentLoaded", boot);
+ } else {
+ boot();
+ }
+})();
+</script>
+</body>
+</html>
diff --git a/games/sokoban/index.html b/games/sokoban/index.html
new file mode 100644
index 0000000..88a4a2b
--- /dev/null
+++ b/games/sokoban/index.html
@@ -0,0 +1,712 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
+<title>NEON SOKOBAN</title>
+<style>
+ :root{
+ --bg:#0a0e14;
+ --panel:#0f1622;
+ --wall:#1b2a44;
+ --wall-glow:#2f6bff;
+ --floor:#0d131e;
+ --crate:#f0a500;
+ --crate-done:#2ee6a6;
+ --target:#ff3d81;
+ --player:#41d6ff;
+ --ink:#cfe3ff;
+ --ink-dim:#6f86a8;
+ }
+ *{box-sizing:border-box;-webkit-tap-highlight-color:transparent;}
+ html,body{margin:0;padding:0;height:100%;}
+ body{
+ background:var(--bg);
+ 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%;
+ touch-action:none;
+ overflow:hidden;
+ user-select:none;-webkit-user-select:none;
+ }
+ #app{
+ width:100%;max-width:640px;
+ display:flex;flex-direction:column;
+ height:100vh;height:100dvh;
+ padding:8px;
+ }
+ header{flex:0 0 auto;text-align:center;padding:2px 0 6px;}
+ h1{
+ margin:0;
+ font-size:clamp(16px,5vw,26px);
+ font-weight:800;
+ letter-spacing:0.42em;
+ text-transform:uppercase;
+ color:var(--player);
+ text-shadow:0 0 8px rgba(65,214,255,.55),0 0 22px rgba(65,214,255,.25);
+ padding-left:0.42em;
+ }
+ .bar{
+ display:flex;flex-wrap:wrap;gap:6px;align-items:center;justify-content:center;
+ flex:0 0 auto;margin:2px 0 6px;
+ }
+ select,button{
+ font-family:inherit;font-size:13px;
+ background:var(--panel);color:var(--ink);
+ border:1px solid #24344f;border-radius:8px;
+ padding:7px 10px;cursor:pointer;
+ transition:border-color .12s,background .12s,transform .05s;
+ }
+ button:hover,select:hover{border-color:var(--wall-glow);}
+ button:active{transform:translateY(1px);}
+ .stats{
+ display:flex;gap:14px;justify-content:center;flex-wrap:wrap;
+ font-size:12px;letter-spacing:.08em;color:var(--ink-dim);
+ text-transform:uppercase;flex:0 0 auto;margin-bottom:4px;
+ }
+ .stats b{color:var(--ink);font-weight:700;}
+ .stats .solved{color:var(--crate-done);}
+ #boardWrap{
+ flex:1 1 auto;
+ display:flex;align-items:center;justify-content:center;
+ min-height:0;position:relative;
+ }
+ canvas{
+ display:block;
+ image-rendering:auto;
+ border-radius:10px;
+ background:var(--floor);
+ box-shadow:0 0 0 1px #1a2740, 0 8px 30px rgba(0,0,0,.5);
+ touch-action:none;
+ }
+ #win{
+ position:absolute;inset:0;display:none;
+ align-items:center;justify-content:center;flex-direction:column;
+ background:rgba(6,10,16,.82);border-radius:10px;text-align:center;
+ gap:12px;padding:16px;
+ }
+ #win.show{display:flex;}
+ #win .big{
+ font-size:clamp(22px,7vw,40px);font-weight:800;letter-spacing:.3em;
+ color:var(--crate-done);text-shadow:0 0 16px rgba(46,230,166,.6);
+ text-transform:uppercase;padding-left:.3em;
+ }
+ #win .sub{font-size:13px;color:var(--ink);letter-spacing:.06em;}
+ #controls{
+ flex:0 0 auto;
+ display:flex;align-items:center;justify-content:space-between;
+ gap:10px;padding:8px 4px 12px;
+ }
+ #dpad{
+ display:grid;
+ grid-template-columns:repeat(3,54px);
+ grid-template-rows:repeat(3,54px);
+ gap:6px;
+ }
+ #dpad button{
+ font-size:22px;padding:0;border-radius:12px;
+ display:flex;align-items:center;justify-content:center;
+ color:var(--player);background:#101a2b;border-color:#22344f;
+ }
+ #dpad button:active{background:#16233a;box-shadow:0 0 12px rgba(65,214,255,.35) inset;}
+ .du{grid-column:2;grid-row:1;}
+ .dl{grid-column:1;grid-row:2;}
+ .dr{grid-column:3;grid-row:2;}
+ .dd{grid-column:2;grid-row:3;}
+ #dpad .ctr{grid-column:2;grid-row:2;visibility:hidden;}
+ #sideBtns{display:flex;flex-direction:column;gap:8px;}
+ #sideBtns button{min-width:88px;font-weight:700;letter-spacing:.08em;}
+ #btnUndo{color:var(--crate);border-color:#3a2f12;}
+ #btnReset{color:var(--target);border-color:#3a1424;}
+ .hint{font-size:10px;color:var(--ink-dim);letter-spacing:.06em;text-align:center;flex:0 0 auto;padding-bottom:4px;}
+ @media (max-height:620px){
+ #dpad{grid-template-columns:repeat(3,46px);grid-template-rows:repeat(3,46px);}
+ h1{font-size:16px;}
+ }
+</style>
+</head>
+<body>
+<div id="app">
+ <header><h1>Neon Sokoban</h1></header>
+ <div class="bar">
+ <select id="levelSelect" aria-label="Select level"></select>
+ <button id="btnPrev">◀ Prev</button>
+ <button id="btnNext">Next ▶</button>
+ </div>
+ <div class="stats">
+ <span>Level <b id="statLevel">1</b></span>
+ <span>Moves <b id="statMoves">0</b></span>
+ <span>Pushes <b id="statPushes">0</b></span>
+ <span>Best <b id="statBest">—</b></span>
+ <span class="solved" id="statSolved"></span>
+ </div>
+ <div id="boardWrap">
+ <canvas id="board"></canvas>
+ <div id="win">
+ <div class="big">Solved</div>
+ <div class="sub" id="winSub"></div>
+ <button id="btnWinNext">Next Level ▶</button>
+ </div>
+ </div>
+ <div id="controls">
+ <div id="dpad">
+ <button class="du" data-dir="up" aria-label="Up">▲</button>
+ <button class="dl" data-dir="left" aria-label="Left">◀</button>
+ <span class="ctr"></span>
+ <button class="dr" data-dir="right" aria-label="Right">▶</button>
+ <button class="dd" data-dir="down" aria-label="Down">▼</button>
+ </div>
+ <div id="sideBtns">
+ <button id="btnUndo">↶ UNDO</button>
+ <button id="btnReset">⟳ RESET</button>
+ </div>
+ </div>
+ <div class="hint">Arrows / WASD move · U undo · R reset</div>
+</div>
+<script>
+(function(){
+ "use strict";
+
+ // ---- Level data (ASCII). # wall, space floor, . target, $ crate, * crate-on-target, @ player, + player-on-target ----
+ var LEVELS = [
+ { name:"Warmup", grid:[
+ "#####",
+ "#@$.#",
+ "#####"
+ ]},
+ { name:"Corner", grid:[
+ "######",
+ "#. #",
+ "# $$ #",
+ "# $. #",
+ "#. @ #",
+ "######"
+ ]},
+ { name:"Twist", grid:[
+ " #####",
+ "### #",
+ "#.@$ #",
+ "### $.#",
+ "#.##$ #",
+ "# # . ##",
+ "#$ *$$.#",
+ "# . #",
+ "########"
+ ]},
+ { name:"Detour", grid:[
+ "########",
+ "# #",
+ "# .$. #",
+ "# $@$ #",
+ "# .$. #",
+ "# #",
+ "########"
+ ]},
+ { name:"Chamber", grid:[
+ "#######",
+ "# . #",
+ "# $#$ #",
+ "#. @ .#",
+ "# $#$ #",
+ "# . #",
+ "#######"
+ ]},
+ { name:"Zigzag", grid:[
+ "########",
+ "# #",
+ "# $ $ #",
+ "#.##.# #",
+ "# $ @ #",
+ "#.## # #",
+ "# #",
+ "########"
+ ]},
+ { name:"Lockbox", grid:[
+ "#########",
+ "# # #",
+ "# $ # . #",
+ "# $ #.. #",
+ "# @ $ #",
+ "# # #",
+ "#########"
+ ]},
+ { name:"Gauntlet", grid:[
+ "########",
+ "#. # #",
+ "#. $$@ #",
+ "#. $ $ #",
+ "#. #",
+ "# #",
+ "########"
+ ]},
+ { name:"Spiral", grid:[
+ "########",
+ "#. . .#",
+ "# $$$ #",
+ "# @ #",
+ "# $$$ #",
+ "#. . .#",
+ "########"
+ ]},
+ { name:"Vault", grid:[
+ "#########",
+ "# ... #",
+ "# $$$ #",
+ "# $@ #",
+ "# $$ #",
+ "# ... #",
+ "#########"
+ ]}
+ ];
+
+ var STORE_KEY = "abrams-sokoban";
+
+ // ---- Tile constants ----
+ var WALL=1, FLOOR=0, TARGET=2;
+
+ // ---- State ----
+ var state = null; // { statics, w, h, crates(Set of index), player, targets(Set), moves, pushes, history[] }
+ var current = 0;
+ var progress = loadProgress();
+
+ var canvas = document.getElementById("board");
+ var ctx = canvas.getContext("2d");
+ var cell = 32;
+
+ function loadProgress(){
+ try{
+ var raw = localStorage.getItem(STORE_KEY);
+ if(!raw) return {};
+ var p = JSON.parse(raw);
+ return (p && typeof p === "object") ? p : {};
+ }catch(e){ return {}; }
+ }
+ function saveProgress(){
+ try{ localStorage.setItem(STORE_KEY, JSON.stringify(progress)); }catch(e){}
+ }
+
+ function idx(x,y,w){ return y*w + x; }
+
+ // Parse a level into a fresh state object
+ function buildState(levelIndex){
+ var grid = LEVELS[levelIndex].grid;
+ var h = grid.length;
+ var w = 0;
+ for(var i=0;i<h;i++){ if(grid[i].length>w) w = grid[i].length; }
+ var statics = new Array(w*h); // WALL / FLOOR / TARGET
+ var crates = {}; // index -> true
+ var targets = {};
+ var player = 0;
+ for(var y=0;y<h;y++){
+ var row = grid[y];
+ for(var x=0;x<w;x++){
+ var ch = x < row.length ? row[x] : " ";
+ var index = idx(x,y,w);
+ var isTarget=false, isCrate=false, isPlayer=false, isWall=false;
+ switch(ch){
+ case "#": isWall=true; break;
+ case ".": isTarget=true; break;
+ case "$": isCrate=true; break;
+ case "*": isCrate=true; isTarget=true; break;
+ case "@": isPlayer=true; break;
+ case "+": isPlayer=true; isTarget=true; break;
+ default: break; // space => floor
+ }
+ statics[index] = isWall ? WALL : (isTarget ? TARGET : FLOOR);
+ if(isTarget) targets[index]=true;
+ if(isCrate) crates[index]=true;
+ if(isPlayer) player=index;
+ }
+ }
+ return {
+ w:w, h:h, statics:statics,
+ crates:crates, targets:targets,
+ player:player, moves:0, pushes:0,
+ history:[]
+ };
+ }
+
+ var DIRS = {
+ up: {dx:0, dy:-1},
+ down: {dx:0, dy:1},
+ left: {dx:-1,dy:0},
+ right:{dx:1, dy:0}
+ };
+
+ function isWall(s,index){ return s.statics[index] === WALL; }
+
+ function tryMove(dir){
+ if(!state) return;
+ var d = DIRS[dir];
+ if(!d) return;
+ var s = state;
+ var w = s.w;
+ var px = s.player % w, py = (s.player - (s.player%w))/w;
+ var nx = px + d.dx, ny = py + d.dy;
+ if(nx<0||ny<0||nx>=w||ny>=s.h) return;
+ var nIndex = idx(nx,ny,w);
+ if(isWall(s,nIndex)) return;
+
+ var pushed = false;
+ if(s.crates[nIndex]){
+ // crate ahead — check the cell beyond
+ var bx = nx + d.dx, by = ny + d.dy;
+ if(bx<0||by<0||bx>=w||by>=s.h) return;
+ var bIndex = idx(bx,by,w);
+ if(isWall(s,bIndex) || s.crates[bIndex]) return; // blocked
+ // move crate
+ delete s.crates[nIndex];
+ s.crates[bIndex] = true;
+ pushed = true;
+ }
+ // record history for undo
+ s.history.push({ from:s.player, to:nIndex, pushed:pushed });
+ s.player = nIndex;
+ s.moves++;
+ if(pushed) s.pushes++;
+ render();
+ checkWin();
+ }
+
+ function undo(){
+ if(!state || state.history.length===0) return;
+ var s = state;
+ var last = s.history.pop();
+ // player moves back
+ s.player = last.from;
+ if(s.moves>0) s.moves--;
+ if(last.pushed){
+ // crate was pushed from 'to' to beyond; revert it
+ var w = s.w;
+ var tx = last.to % w, ty = (last.to - (last.to%w))/w;
+ var dx = tx - (last.from % w);
+ var dy = ty - ((last.from - (last.from%w))/w);
+ var bIndex = idx(tx+dx, ty+dy, w);
+ delete s.crates[bIndex];
+ s.crates[last.to] = true;
+ if(s.pushes>0) s.pushes--;
+ }
+ hideWin();
+ render();
+ }
+
+ function reset(){
+ state = buildState(current);
+ hideWin();
+ layout();
+ render();
+ updateStats();
+ }
+
+ function isSolved(s){
+ for(var t in s.targets){
+ if(!s.crates[t]) return false;
+ }
+ return true;
+ }
+
+ function checkWin(){
+ updateStats();
+ if(state && isSolved(state)){
+ recordSolve();
+ showWin();
+ }
+ }
+
+ function recordSolve(){
+ var key = String(current);
+ var rec = progress[key] || {};
+ rec.solved = true;
+ if(typeof rec.best !== "number" || state.moves < rec.best){
+ rec.best = state.moves;
+ }
+ progress[key] = rec;
+ saveProgress();
+ buildLevelSelect();
+ updateStats();
+ }
+
+ // ---- Rendering ----
+ function layout(){
+ if(!state) return;
+ var wrap = document.getElementById("boardWrap");
+ var availW = wrap.clientWidth - 4;
+ var availH = wrap.clientHeight - 4;
+ if(availW<=0) availW = 300;
+ if(availH<=0) availH = 300;
+ var c = Math.floor(Math.min(availW/state.w, availH/state.h));
+ c = Math.max(14, Math.min(c, 64));
+ cell = c;
+ canvas.width = state.w * cell;
+ canvas.height = state.h * cell;
+ canvas.style.width = canvas.width + "px";
+ canvas.style.height = canvas.height + "px";
+ }
+
+ function render(){
+ if(!state) return;
+ var s = state, w=s.w, h=s.h;
+ ctx.clearRect(0,0,canvas.width,canvas.height);
+ // floor bg
+ ctx.fillStyle = "#0d131e";
+ ctx.fillRect(0,0,canvas.width,canvas.height);
+
+ for(var y=0;y<h;y++){
+ for(var x=0;x<w;x++){
+ var index = idx(x,y,w);
+ var st = s.statics[index];
+ var px = x*cell, py=y*cell;
+ if(st===WALL){
+ drawWall(px,py);
+ } else {
+ drawFloor(px,py);
+ if(st===TARGET){ drawTarget(px,py); }
+ }
+ }
+ }
+ // crates
+ for(var ci in s.crates){
+ var i = +ci;
+ var cx = (i % w)*cell, cy = ((i-(i%w))/w)*cell;
+ var onTarget = s.statics[i]===TARGET;
+ drawCrate(cx,cy,onTarget);
+ }
+ // player
+ var pi = s.player;
+ var ppx = (pi % w)*cell, ppy = ((pi-(pi%w))/w)*cell;
+ drawPlayer(ppx,ppy);
+ }
+
+ function drawFloor(px,py){
+ ctx.fillStyle = "#0d131e";
+ ctx.fillRect(px,py,cell,cell);
+ ctx.strokeStyle = "rgba(40,60,95,.28)";
+ ctx.lineWidth = 1;
+ ctx.strokeRect(px+0.5,py+0.5,cell-1,cell-1);
+ }
+ function drawWall(px,py){
+ var g = ctx.createLinearGradient(px,py,px,py+cell);
+ g.addColorStop(0,"#20335a");
+ g.addColorStop(1,"#152340");
+ ctx.fillStyle = g;
+ ctx.fillRect(px,py,cell,cell);
+ ctx.strokeStyle = "rgba(47,107,255,.55)";
+ ctx.lineWidth = Math.max(1, cell*0.06);
+ ctx.strokeRect(px+ctx.lineWidth/2, py+ctx.lineWidth/2, cell-ctx.lineWidth, cell-ctx.lineWidth);
+ }
+ function drawTarget(px,py){
+ var cx = px+cell/2, cy=py+cell/2;
+ var r = cell*0.18;
+ ctx.save();
+ ctx.shadowColor = "rgba(255,61,129,.85)";
+ ctx.shadowBlur = cell*0.4;
+ ctx.strokeStyle = "#ff3d81";
+ ctx.lineWidth = Math.max(1.5, cell*0.07);
+ ctx.beginPath();
+ ctx.arc(cx,cy,r,0,Math.PI*2);
+ ctx.stroke();
+ ctx.restore();
+ }
+ function drawCrate(px,py,onTarget){
+ var pad = cell*0.12;
+ var x=px+pad, y=py+pad, sz=cell-pad*2;
+ var col = onTarget ? "#2ee6a6" : "#f0a500";
+ var glow = onTarget ? "rgba(46,230,166,.75)" : "rgba(240,165,0,.55)";
+ ctx.save();
+ ctx.shadowColor = glow;
+ ctx.shadowBlur = onTarget ? cell*0.5 : cell*0.28;
+ var g = ctx.createLinearGradient(x,y,x,y+sz);
+ g.addColorStop(0, onTarget ? "#3af0b4" : "#ffbe2e");
+ g.addColorStop(1, col);
+ ctx.fillStyle = g;
+ roundRect(x,y,sz,sz,cell*0.12);
+ ctx.fill();
+ ctx.restore();
+ // inner cross detail
+ ctx.strokeStyle = onTarget ? "rgba(6,30,22,.55)" : "rgba(60,40,0,.5)";
+ ctx.lineWidth = Math.max(1, cell*0.04);
+ ctx.beginPath();
+ ctx.moveTo(x,y); ctx.lineTo(x+sz,y+sz);
+ ctx.moveTo(x+sz,y); ctx.lineTo(x,y+sz);
+ ctx.stroke();
+ }
+ function drawPlayer(px,py){
+ var cx=px+cell/2, cy=py+cell/2;
+ var r = cell*0.32;
+ ctx.save();
+ ctx.shadowColor = "rgba(65,214,255,.85)";
+ ctx.shadowBlur = cell*0.45;
+ var g = ctx.createRadialGradient(cx,cy-r*0.3,r*0.2,cx,cy,r);
+ g.addColorStop(0,"#bff2ff");
+ g.addColorStop(1,"#41d6ff");
+ ctx.fillStyle = g;
+ ctx.beginPath();
+ ctx.arc(cx,cy,r,0,Math.PI*2);
+ ctx.fill();
+ ctx.restore();
+ ctx.strokeStyle = "rgba(10,40,55,.6)";
+ ctx.lineWidth = Math.max(1, cell*0.04);
+ ctx.beginPath();
+ ctx.arc(cx,cy,r,0,Math.PI*2);
+ ctx.stroke();
+ }
+ 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();
+ }
+
+ // ---- UI ----
+ function updateStats(){
+ document.getElementById("statLevel").textContent = String(current+1);
+ document.getElementById("statMoves").textContent = state ? String(state.moves) : "0";
+ document.getElementById("statPushes").textContent = state ? String(state.pushes) : "0";
+ var rec = progress[String(current)];
+ document.getElementById("statBest").textContent = (rec && typeof rec.best==="number") ? String(rec.best) : "—";
+ var solvedCount = 0;
+ for(var k in progress){ if(progress[k] && progress[k].solved) solvedCount++; }
+ document.getElementById("statSolved").textContent = solvedCount>0 ? ("✓ " + solvedCount + "/" + LEVELS.length + " solved") : "";
+ }
+
+ function buildLevelSelect(){
+ var sel = document.getElementById("levelSelect");
+ var prev = sel.value;
+ sel.innerHTML = "";
+ for(var i=0;i<LEVELS.length;i++){
+ var opt = document.createElement("option");
+ opt.value = String(i);
+ var rec = progress[String(i)];
+ var tag = (rec && rec.solved) ? " ✓" : "";
+ var best = (rec && typeof rec.best==="number") ? (" · " + rec.best) : "";
+ opt.textContent = (i+1) + ". " + LEVELS[i].name + tag + best;
+ sel.appendChild(opt);
+ }
+ sel.value = String(current);
+ if(prev==="" ){ /* noop */ }
+ }
+
+ function loadLevel(i){
+ if(i<0) i=0;
+ if(i>=LEVELS.length) i=LEVELS.length-1;
+ current = i;
+ document.getElementById("levelSelect").value = String(current);
+ reset();
+ }
+
+ function showWin(){
+ var el = document.getElementById("win");
+ var sub = document.getElementById("winSub");
+ var rec = progress[String(current)] || {};
+ var bestTxt = (typeof rec.best==="number") ? (" · Best " + rec.best) : "";
+ sub.textContent = state.moves + " moves · " + state.pushes + " pushes" + bestTxt;
+ var nextBtn = document.getElementById("btnWinNext");
+ nextBtn.style.display = (current < LEVELS.length-1) ? "" : "none";
+ el.classList.add("show");
+ }
+ function hideWin(){ document.getElementById("win").classList.remove("show"); }
+
+ // ---- Input ----
+ var KEYMAP = {
+ "ArrowUp":"up","ArrowDown":"down","ArrowLeft":"left","ArrowRight":"right",
+ "w":"up","s":"down","a":"left","d":"right",
+ "W":"up","S":"down","A":"left","D":"right"
+ };
+ window.addEventListener("keydown", function(e){
+ var k = e.key;
+ if(KEYMAP[k]){
+ e.preventDefault();
+ tryMove(KEYMAP[k]);
+ return;
+ }
+ if(k==="u"||k==="U"){ e.preventDefault(); undo(); return; }
+ if(k==="r"||k==="R"){ e.preventDefault(); reset(); return; }
+ if(k==="n"||k==="N"){ e.preventDefault(); loadLevel(current+1); return; }
+ }, {passive:false});
+
+ // Prevent arrow/space page scroll fallback
+ window.addEventListener("keydown", function(e){
+ if(["ArrowUp","ArrowDown","ArrowLeft","ArrowRight"," "].indexOf(e.key) !== -1){
+ e.preventDefault();
+ }
+ }, {passive:false});
+
+ function bindTap(el, fn){
+ if(!el) return;
+ el.addEventListener("click", function(e){ e.preventDefault(); fn(); });
+ el.addEventListener("touchstart", function(e){ e.preventDefault(); fn(); }, {passive:false});
+ }
+
+ var dpadBtns = document.querySelectorAll("#dpad button[data-dir]");
+ for(var i=0;i<dpadBtns.length;i++){
+ (function(btn){
+ var dir = btn.getAttribute("data-dir");
+ btn.addEventListener("click", function(e){ e.preventDefault(); tryMove(dir); });
+ btn.addEventListener("touchstart", function(e){ e.preventDefault(); tryMove(dir); }, {passive:false});
+ })(dpadBtns[i]);
+ }
+
+ bindTap(document.getElementById("btnUndo"), undo);
+ bindTap(document.getElementById("btnReset"), reset);
+ bindTap(document.getElementById("btnPrev"), function(){ loadLevel(current-1); });
+ bindTap(document.getElementById("btnNext"), function(){ loadLevel(current+1); });
+ bindTap(document.getElementById("btnWinNext"), function(){ loadLevel(current+1); });
+
+ document.getElementById("levelSelect").addEventListener("change", function(e){
+ loadLevel(parseInt(e.target.value,10)||0);
+ });
+
+ // Swipe on canvas
+ var touchStart = null;
+ canvas.addEventListener("touchstart", function(e){
+ if(e.touches.length===1){
+ touchStart = {x:e.touches[0].clientX, y:e.touches[0].clientY};
+ }
+ }, {passive:true});
+ canvas.addEventListener("touchend", function(e){
+ if(!touchStart) return;
+ var t = e.changedTouches[0];
+ var dx = t.clientX - touchStart.x;
+ var dy = t.clientY - touchStart.y;
+ touchStart = null;
+ var ax = Math.abs(dx), ay = Math.abs(dy);
+ if(Math.max(ax,ay) < 22) return;
+ if(ax>ay){ tryMove(dx>0?"right":"left"); }
+ else{ tryMove(dy>0?"down":"up"); }
+ }, {passive:true});
+
+ var resizeTimer = null;
+ window.addEventListener("resize", function(){
+ if(resizeTimer) clearTimeout(resizeTimer);
+ resizeTimer = setTimeout(function(){ layout(); render(); }, 60);
+ });
+
+ // ---- Init (guarded until DOM ready) ----
+ function init(){
+ buildLevelSelect();
+ // start at first unsolved level, else level 0
+ var start = 0;
+ for(var i=0;i<LEVELS.length;i++){
+ var rec = progress[String(i)];
+ if(!rec || !rec.solved){ start = i; break; }
+ }
+ current = start;
+ loadLevel(current);
+ // second layout pass after fonts/flex settle
+ setTimeout(function(){ layout(); render(); }, 50);
+ }
+
+ if(document.readyState === "loading"){
+ document.addEventListener("DOMContentLoaded", init);
+ } else {
+ init();
+ }
+})();
+</script>
+</body>
+</html>
diff --git a/games/sudoku/index.html b/games/sudoku/index.html
new file mode 100644
index 0000000..8c0de66
--- /dev/null
+++ b/games/sudoku/index.html
@@ -0,0 +1,799 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
+<title>NEON SUDOKU</title>
+<style>
+ :root{
+ --bg:#0a0e14;
+ --panel:#0f151f;
+ --grid-line:#1c2a3a;
+ --grid-line-strong:#2fd2ff;
+ --given:#e6f4ff;
+ --entry:#38f0c0;
+ --note:#5f7a92;
+ --conflict:#ff3b5c;
+ --accent:#2fd2ff;
+ --sel:#12303f;
+ --sel-peer:#0e1f2b;
+ --sameval:#123448;
+ }
+ *{box-sizing:border-box;-webkit-tap-highlight-color:transparent;}
+ html,body{margin:0;padding:0;height:100%;}
+ body{
+ background:var(--bg);
+ color:#cfe6f5;
+ font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
+ display:flex;
+ flex-direction:column;
+ align-items:center;
+ justify-content:flex-start;
+ min-height:100%;
+ padding:12px 10px 24px;
+ touch-action:manipulation;
+ overflow-x:hidden;
+ }
+ h1{
+ margin:4px 0 10px;
+ font-size:clamp(18px,5vw,30px);
+ font-weight:800;
+ letter-spacing:.34em;
+ text-transform:uppercase;
+ color:#eaf7ff;
+ text-shadow:0 0 8px rgba(47,210,255,.6),0 0 22px rgba(47,210,255,.25);
+ text-align:center;
+ }
+ .hud{
+ display:flex;
+ flex-wrap:wrap;
+ gap:8px 14px;
+ align-items:center;
+ justify-content:center;
+ margin-bottom:12px;
+ width:100%;
+ max-width:560px;
+ }
+ .hud .item{
+ display:flex;
+ flex-direction:column;
+ align-items:center;
+ line-height:1.1;
+ }
+ .hud .label{
+ font-size:10px;
+ letter-spacing:.18em;
+ text-transform:uppercase;
+ color:#5f7a92;
+ }
+ .hud .val{
+ font-size:16px;
+ font-weight:700;
+ font-variant-numeric:tabular-nums;
+ color:var(--accent);
+ text-shadow:0 0 8px rgba(47,210,255,.4);
+ }
+ .controls{
+ display:flex;
+ flex-wrap:wrap;
+ gap:8px;
+ justify-content:center;
+ margin-bottom:14px;
+ width:100%;
+ max-width:560px;
+ }
+ select,button{
+ font-family:inherit;
+ background:var(--panel);
+ color:#cfe6f5;
+ border:1px solid var(--grid-line);
+ border-radius:8px;
+ padding:9px 12px;
+ font-size:13px;
+ letter-spacing:.05em;
+ cursor:pointer;
+ transition:border-color .15s,box-shadow .15s,background .15s;
+ }
+ select:focus,button:focus{outline:none;border-color:var(--accent);}
+ button:hover,select:hover{border-color:var(--accent);}
+ button:active{transform:translateY(1px);}
+ button.primary{
+ border-color:var(--accent);
+ color:#eaf7ff;
+ box-shadow:0 0 10px rgba(47,210,255,.25) inset;
+ }
+ button.toggled{
+ background:#123448;
+ border-color:var(--entry);
+ color:var(--entry);
+ box-shadow:0 0 10px rgba(56,240,192,.3) inset;
+ }
+ #boardWrap{
+ width:min(94vw,520px);
+ aspect-ratio:1/1;
+ max-height:60vh;
+ margin-bottom:16px;
+ }
+ #board{
+ width:100%;
+ height:100%;
+ display:grid;
+ grid-template-columns:repeat(9,1fr);
+ grid-template-rows:repeat(9,1fr);
+ background:var(--grid-line-strong);
+ border:2px solid var(--grid-line-strong);
+ border-radius:6px;
+ gap:1px;
+ box-shadow:0 0 22px rgba(47,210,255,.25);
+ overflow:hidden;
+ }
+ .cell{
+ position:relative;
+ background:var(--panel);
+ display:flex;
+ align-items:center;
+ justify-content:center;
+ font-size:clamp(16px,5vw,28px);
+ font-weight:600;
+ color:var(--entry);
+ cursor:pointer;
+ user-select:none;
+ font-variant-numeric:tabular-nums;
+ }
+ .cell.given{color:var(--given);font-weight:700;}
+ /* thick 3x3 box borders using margins */
+ .cell.br{margin-right:1px;box-shadow:2px 0 0 0 var(--grid-line-strong);}
+ .cell.bb{margin-bottom:1px;box-shadow:0 2px 0 0 var(--grid-line-strong);}
+ .cell.br.bb{box-shadow:2px 0 0 0 var(--grid-line-strong),0 2px 0 0 var(--grid-line-strong);}
+ .cell.peer{background:var(--sel-peer);}
+ .cell.sameval{background:var(--sameval);}
+ .cell.selected{background:var(--sel);box-shadow:inset 0 0 0 2px var(--accent);}
+ .cell.selected.br,.cell.selected.bb,.cell.selected.br.bb{box-shadow:inset 0 0 0 2px var(--accent);}
+ .cell.conflict{color:var(--conflict);text-shadow:0 0 10px rgba(255,59,92,.7);}
+ .cell.conflict.given{color:var(--conflict);}
+ .notes{
+ position:absolute;
+ inset:2px;
+ display:grid;
+ grid-template-columns:repeat(3,1fr);
+ grid-template-rows:repeat(3,1fr);
+ pointer-events:none;
+ }
+ .notes span{
+ display:flex;
+ align-items:center;
+ justify-content:center;
+ font-size:clamp(7px,2vw,11px);
+ font-weight:600;
+ color:var(--note);
+ line-height:1;
+ }
+ .pad{
+ display:grid;
+ grid-template-columns:repeat(5,1fr);
+ gap:8px;
+ width:min(94vw,520px);
+ margin-bottom:10px;
+ }
+ .pad button{
+ padding:14px 0;
+ font-size:20px;
+ font-weight:700;
+ border-radius:10px;
+ }
+ .pad .num{color:#eaf7ff;}
+ .pad .fn{font-size:12px;letter-spacing:.08em;}
+ .msg{
+ min-height:22px;
+ font-size:14px;
+ letter-spacing:.1em;
+ text-transform:uppercase;
+ color:var(--entry);
+ text-shadow:0 0 10px rgba(56,240,192,.5);
+ text-align:center;
+ font-weight:700;
+ }
+ .best{font-size:11px;color:#5f7a92;letter-spacing:.12em;text-transform:uppercase;text-align:center;margin-top:2px;}
+ @media (max-width:360px){
+ h1{letter-spacing:.2em;}
+ .pad button{padding:11px 0;font-size:17px;}
+ }
+</style>
+</head>
+<body>
+ <h1>Neon Sudoku</h1>
+
+ <div class="hud">
+ <div class="item"><span class="label">Time</span><span class="val" id="timer">00:00</span></div>
+ <div class="item"><span class="label">Difficulty</span><span class="val" id="diffLbl">Easy</span></div>
+ <div class="item"><span class="label">Mode</span><span class="val" id="modeLbl">Fill</span></div>
+ </div>
+
+ <div class="controls">
+ <select id="diffSel" aria-label="Difficulty">
+ <option value="easy">Easy</option>
+ <option value="medium">Medium</option>
+ <option value="hard">Hard</option>
+ </select>
+ <button class="primary" id="newBtn">New Puzzle</button>
+ <button id="notesBtn">Notes: Off</button>
+ </div>
+
+ <div id="boardWrap"><div id="board" aria-label="Sudoku board"></div></div>
+
+ <div class="pad" id="pad"></div>
+
+ <div class="msg" id="msg"></div>
+ <div class="best" id="best"></div>
+
+<script>
+(function(){
+ "use strict";
+
+ var STORE_KEY = "abrams-sudoku";
+ var SIZE = 9;
+
+ // ---------- utilities ----------
+ function shuffled(arr){
+ var a = arr.slice();
+ for (var i = a.length - 1; i > 0; i--){
+ var j = Math.floor(Math.random() * (i + 1));
+ var t = a[i]; a[i] = a[j]; a[j] = t;
+ }
+ return a;
+ }
+
+ // ---------- solution generator (bounded backtracking) ----------
+ // Returns a full valid 9x9 grid (array of 81 ints 1-9), or null on failure.
+ function generateSolution(){
+ var grid = new Array(81).fill(0);
+ var steps = { count: 0 };
+ var MAX_STEPS = 600000; // hard cap so we can never spin forever
+
+ function fits(idx, val){
+ var r = (idx / 9) | 0, c = idx % 9;
+ var br = r - r % 3, bc = c - c % 3;
+ for (var i = 0; i < 9; i++){
+ if (grid[r*9 + i] === val) return false;
+ if (grid[i*9 + c] === val) return false;
+ }
+ for (var rr = 0; rr < 3; rr++){
+ for (var cc = 0; cc < 3; cc++){
+ if (grid[(br+rr)*9 + (bc+cc)] === val) return false;
+ }
+ }
+ return true;
+ }
+
+ function solve(pos){
+ if (steps.count++ > MAX_STEPS) return false;
+ if (pos >= 81) return true;
+ if (grid[pos] !== 0) return solve(pos + 1);
+ var nums = shuffled([1,2,3,4,5,6,7,8,9]);
+ for (var k = 0; k < 9; k++){
+ var v = nums[k];
+ if (fits(pos, v)){
+ grid[pos] = v;
+ if (solve(pos + 1)) return true;
+ grid[pos] = 0;
+ }
+ if (steps.count > MAX_STEPS) return false;
+ }
+ return false;
+ }
+
+ if (solve(0)) return grid;
+ return null;
+ }
+
+ // ---------- solver: count solutions up to a limit (for uniqueness) ----------
+ function countSolutions(puzzle, limit){
+ var grid = puzzle.slice();
+ var steps = { count: 0 };
+ var MAX_STEPS = 400000;
+ var found = 0;
+
+ function fits(idx, val){
+ var r = (idx / 9) | 0, c = idx % 9;
+ var br = r - r % 3, bc = c - c % 3;
+ for (var i = 0; i < 9; i++){
+ if (grid[r*9 + i] === val) return false;
+ if (grid[i*9 + c] === val) return false;
+ }
+ for (var rr = 0; rr < 3; rr++){
+ for (var cc = 0; cc < 3; cc++){
+ if (grid[(br+rr)*9 + (bc+cc)] === val) return false;
+ }
+ }
+ return true;
+ }
+
+ // find the empty cell with fewest candidates (MRV) to keep it fast/bounded
+ function pickCell(){
+ var best = -1, bestCount = 10;
+ for (var idx = 0; idx < 81; idx++){
+ if (grid[idx] !== 0) continue;
+ var cnt = 0;
+ for (var v = 1; v <= 9; v++){ if (fits(idx, v)) cnt++; }
+ if (cnt < bestCount){ bestCount = cnt; best = idx; if (cnt <= 1) break; }
+ }
+ return best;
+ }
+
+ function solve(){
+ if (steps.count++ > MAX_STEPS) return true; // bail -> treat as "give up", stops recursion
+ var idx = pickCell();
+ if (idx === -1){ found++; return found >= limit; }
+ for (var v = 1; v <= 9; v++){
+ if (fits(idx, v)){
+ grid[idx] = v;
+ if (solve()) { grid[idx] = 0; return true; }
+ grid[idx] = 0;
+ }
+ if (steps.count > MAX_STEPS) return true;
+ }
+ return false;
+ }
+
+ solve();
+ return found;
+ }
+
+ // ---------- build a puzzle at difficulty ----------
+ var DIFF = {
+ easy: { clues: 40 },
+ medium: { clues: 32 },
+ hard: { clues: 26 }
+ };
+
+ // Make puzzle: start from full solution, remove cells (symmetric) while
+ // keeping a unique solution, bounded by attempts. Falls back gracefully.
+ function makePuzzle(difficulty){
+ var solution = null;
+ // bounded retries to obtain a solution
+ for (var g = 0; g < 8 && !solution; g++){
+ solution = generateSolution();
+ }
+ if (!solution){
+ // absolute fallback: a hard-coded valid solved grid
+ solution = FALLBACK_SOLUTION.slice();
+ }
+
+ var targetClues = (DIFF[difficulty] || DIFF.easy).clues;
+ var puzzle = solution.slice();
+
+ var order = shuffled((function(){ var a=[]; for(var i=0;i<81;i++) a.push(i); return a; })());
+ var removed = 0;
+ var toRemove = 81 - targetClues;
+ var attempts = 0;
+ var MAX_ATTEMPTS = 200; // hard cap on removal attempts
+
+ for (var oi = 0; oi < order.length && removed < toRemove && attempts < MAX_ATTEMPTS; oi++){
+ var idx = order[oi];
+ if (puzzle[idx] === 0) continue;
+ attempts++;
+ var saved = puzzle[idx];
+ puzzle[idx] = 0;
+ // keep unique: count solutions up to 2
+ var cnt = countSolutions(puzzle, 2);
+ if (cnt !== 1){
+ puzzle[idx] = saved; // revert, not removable
+ } else {
+ removed++;
+ }
+ }
+
+ return { puzzle: puzzle, solution: solution };
+ }
+
+ // A known-good completed grid used only if generation ever fails entirely.
+ var FALLBACK_SOLUTION = [
+ 5,3,4, 6,7,8, 9,1,2,
+ 6,7,2, 1,9,5, 3,4,8,
+ 1,9,8, 3,4,2, 5,6,7,
+ 8,5,9, 7,6,1, 4,2,3,
+ 4,2,6, 8,5,3, 7,9,1,
+ 7,1,3, 9,2,4, 8,5,6,
+ 9,6,1, 5,3,7, 2,8,4,
+ 2,8,7, 4,1,9, 6,3,5,
+ 3,4,5, 2,8,6, 1,7,9
+ ];
+
+ // ---------- game state ----------
+ var state = {
+ puzzle: [], // starting clues (0 = empty)
+ solution: [], // full solution
+ values: [], // current user values (includes givens)
+ notes: [], // array of Set-like objects per cell
+ given: [], // boolean per cell
+ selected: -1,
+ notesMode: false,
+ difficulty: "easy",
+ startTime: 0,
+ elapsed: 0,
+ running: false,
+ solved: false
+ };
+
+ var timerHandle = null;
+
+ // ---------- DOM refs ----------
+ var boardEl = document.getElementById("board");
+ var padEl = document.getElementById("pad");
+ var timerEl = document.getElementById("timer");
+ var diffLblEl = document.getElementById("diffLbl");
+ var modeLblEl = document.getElementById("modeLbl");
+ var diffSelEl = document.getElementById("diffSel");
+ var newBtn = document.getElementById("newBtn");
+ var notesBtn = document.getElementById("notesBtn");
+ var msgEl = document.getElementById("msg");
+ var bestEl = document.getElementById("best");
+
+ var cellEls = [];
+
+ function buildBoard(){
+ boardEl.innerHTML = "";
+ cellEls = [];
+ for (var idx = 0; idx < 81; idx++){
+ var r = (idx / 9) | 0, c = idx % 9;
+ var cell = document.createElement("div");
+ cell.className = "cell";
+ if (c === 2 || c === 5) cell.classList.add("br");
+ if (r === 2 || r === 5) cell.classList.add("bb");
+ cell.setAttribute("data-idx", String(idx));
+ (function(i){
+ cell.addEventListener("click", function(){ selectCell(i); });
+ })(idx);
+ var notes = document.createElement("div");
+ notes.className = "notes";
+ for (var n = 1; n <= 9; n++){
+ var sp = document.createElement("span");
+ sp.setAttribute("data-n", String(n));
+ notes.appendChild(sp);
+ }
+ cell.appendChild(notes);
+ boardEl.appendChild(cell);
+ cellEls.push(cell);
+ }
+ }
+
+ function buildPad(){
+ padEl.innerHTML = "";
+ for (var n = 1; n <= 9; n++){
+ var b = document.createElement("button");
+ b.className = "num";
+ b.textContent = String(n);
+ (function(v){ b.addEventListener("click", function(){ inputNumber(v); }); })(n);
+ padEl.appendChild(b);
+ }
+ var eraseB = document.createElement("button");
+ eraseB.className = "fn";
+ eraseB.textContent = "ERASE";
+ eraseB.addEventListener("click", function(){ inputNumber(0); });
+ padEl.appendChild(eraseB);
+ }
+
+ // ---------- timer ----------
+ function fmt(sec){
+ var m = Math.floor(sec / 60), s = sec % 60;
+ return (m < 10 ? "0" : "") + m + ":" + (s < 10 ? "0" : "") + s;
+ }
+ function startTimer(){
+ stopTimer();
+ state.startTime = Date.now() - state.elapsed * 1000;
+ state.running = true;
+ timerHandle = setInterval(function(){
+ if (!state.running) return;
+ state.elapsed = Math.floor((Date.now() - state.startTime) / 1000);
+ timerEl.textContent = fmt(state.elapsed);
+ }, 250);
+ }
+ function stopTimer(){
+ state.running = false;
+ if (timerHandle){ clearInterval(timerHandle); timerHandle = null; }
+ }
+
+ // ---------- best times ----------
+ function loadBest(){
+ try {
+ var raw = localStorage.getItem(STORE_KEY);
+ if (!raw) return {};
+ var obj = JSON.parse(raw);
+ return (obj && typeof obj === "object") ? obj : {};
+ } catch (e) { return {}; }
+ }
+ function saveBest(obj){
+ try { localStorage.setItem(STORE_KEY, JSON.stringify(obj)); } catch (e) {}
+ }
+ function showBest(){
+ var b = loadBest();
+ var t = b[state.difficulty];
+ if (typeof t === "number"){
+ bestEl.textContent = "Best (" + state.difficulty + "): " + fmt(t);
+ } else {
+ bestEl.textContent = "Best (" + state.difficulty + "): --:--";
+ }
+ }
+ function recordBest(){
+ var b = loadBest();
+ var cur = b[state.difficulty];
+ if (typeof cur !== "number" || state.elapsed < cur){
+ b[state.difficulty] = state.elapsed;
+ saveBest(b);
+ return true;
+ }
+ return false;
+ }
+
+ // ---------- conflict detection ----------
+ function conflictSet(){
+ var conflicts = {};
+ // rows, cols, boxes
+ function check(indices){
+ var seen = {};
+ for (var i = 0; i < indices.length; i++){
+ var v = state.values[indices[i]];
+ if (v === 0) continue;
+ if (seen[v] === undefined) seen[v] = [indices[i]];
+ else seen[v].push(indices[i]);
+ }
+ for (var val in seen){
+ if (seen[val].length > 1){
+ for (var j = 0; j < seen[val].length; j++){
+ conflicts[seen[val][j]] = true;
+ }
+ }
+ }
+ }
+ var r, c, i;
+ for (r = 0; r < 9; r++){
+ var row = [];
+ for (c = 0; c < 9; c++) row.push(r*9 + c);
+ check(row);
+ }
+ for (c = 0; c < 9; c++){
+ var col = [];
+ for (r = 0; r < 9; r++) col.push(r*9 + c);
+ check(col);
+ }
+ for (var br = 0; br < 3; br++){
+ for (var bc = 0; bc < 3; bc++){
+ var box = [];
+ for (var rr = 0; rr < 3; rr++)
+ for (var cc = 0; cc < 3; cc++)
+ box.push((br*3+rr)*9 + (bc*3+cc));
+ check(box);
+ }
+ }
+ return conflicts;
+ }
+
+ // ---------- rendering ----------
+ function render(){
+ var conflicts = conflictSet();
+ var selVal = state.selected >= 0 ? state.values[state.selected] : 0;
+ var selR = state.selected >= 0 ? (state.selected / 9) | 0 : -1;
+ var selC = state.selected >= 0 ? state.selected % 9 : -1;
+
+ for (var idx = 0; idx < 81; idx++){
+ var cell = cellEls[idx];
+ var v = state.values[idx];
+ // reset classes but keep border classes + base
+ cell.classList.remove("given","conflict","selected","peer","sameval");
+
+ if (state.given[idx]) cell.classList.add("given");
+
+ var notesEl = cell.querySelector(".notes");
+ var textNode = cell.childNodes[cell.childNodes.length - 1];
+ // We store text in a dedicated text node: rebuild it
+ // Clear existing value text node (first non-notes child)
+ // Simpler: set cell's own text via a data span
+ // Remove any prior value text nodes:
+ for (var k = cell.childNodes.length - 1; k >= 0; k--){
+ var ch = cell.childNodes[k];
+ if (ch.nodeType === 3) cell.removeChild(ch);
+ }
+
+ if (v !== 0){
+ notesEl.style.display = "none";
+ cell.insertBefore(document.createTextNode(String(v)), notesEl);
+ } else {
+ notesEl.style.display = "";
+ var set = state.notes[idx];
+ var spans = notesEl.querySelectorAll("span");
+ for (var s = 0; s < spans.length; s++){
+ var n = s + 1;
+ spans[s].textContent = (set && set[n]) ? String(n) : "";
+ }
+ }
+
+ if (conflicts[idx]) cell.classList.add("conflict");
+
+ // highlighting relative to selection
+ if (state.selected >= 0 && idx !== state.selected){
+ var r = (idx / 9) | 0, c = idx % 9;
+ var sameBox = ((r - r%3) === (selR - selR%3)) && ((c - c%3) === (selC - selC%3));
+ if (r === selR || c === selC || sameBox) cell.classList.add("peer");
+ if (selVal !== 0 && v === selVal) cell.classList.add("sameval");
+ }
+ }
+
+ if (state.selected >= 0){
+ cellEls[state.selected].classList.add("selected");
+ }
+ }
+
+ function selectCell(idx){
+ state.selected = idx;
+ render();
+ }
+
+ // ---------- input ----------
+ function inputNumber(v){
+ if (state.selected < 0 || state.solved) return;
+ var idx = state.selected;
+ if (state.given[idx]) return;
+
+ if (v === 0){
+ // erase
+ state.values[idx] = 0;
+ state.notes[idx] = {};
+ render();
+ return;
+ }
+
+ if (state.notesMode){
+ if (state.values[idx] !== 0) state.values[idx] = 0;
+ if (!state.notes[idx]) state.notes[idx] = {};
+ state.notes[idx][v] = !state.notes[idx][v];
+ render();
+ } else {
+ state.values[idx] = (state.values[idx] === v) ? 0 : v;
+ state.notes[idx] = {};
+ render();
+ checkSolved();
+ }
+ }
+
+ function checkSolved(){
+ // must be fully filled
+ for (var i = 0; i < 81; i++){
+ if (state.values[i] === 0) return;
+ }
+ // must match solution (guarantees valid & correct)
+ for (var j = 0; j < 81; j++){
+ if (state.values[j] !== state.solution[j]) return;
+ }
+ // solved!
+ state.solved = true;
+ stopTimer();
+ var isBest = recordBest();
+ showBest();
+ msgEl.textContent = isBest ? "Solved! New best time!" : "Solved!";
+ }
+
+ // ---------- new game ----------
+ function newGame(){
+ msgEl.textContent = "Generating...";
+ // defer so the "Generating..." paints before the (bounded) work
+ setTimeout(function(){
+ try {
+ var built = makePuzzle(state.difficulty);
+ state.puzzle = built.puzzle.slice();
+ state.solution = built.solution.slice();
+ state.values = built.puzzle.slice();
+ state.given = [];
+ state.notes = [];
+ for (var i = 0; i < 81; i++){
+ state.given.push(built.puzzle[i] !== 0);
+ state.notes.push({});
+ }
+ state.selected = -1;
+ state.solved = false;
+ state.elapsed = 0;
+ timerEl.textContent = "00:00";
+ msgEl.textContent = "";
+ render();
+ startTimer();
+ showBest();
+ } catch (e) {
+ // never throw to the page; fail to a trivial fallback
+ msgEl.textContent = "";
+ var sol = FALLBACK_SOLUTION.slice();
+ var pz = sol.slice();
+ // blank out a handful of cells deterministically
+ var blanks = [0,10,20,30,40,50,60,70,80];
+ for (var b = 0; b < blanks.length; b++) pz[blanks[b]] = 0;
+ state.puzzle = pz.slice();
+ state.solution = sol.slice();
+ state.values = pz.slice();
+ state.given = [];
+ state.notes = [];
+ for (var k = 0; k < 81; k++){ state.given.push(pz[k] !== 0); state.notes.push({}); }
+ state.selected = -1;
+ state.solved = false;
+ state.elapsed = 0;
+ timerEl.textContent = "00:00";
+ render();
+ startTimer();
+ showBest();
+ }
+ }, 20);
+ }
+
+ // ---------- controls ----------
+ function setNotesMode(on){
+ state.notesMode = on;
+ notesBtn.textContent = "Notes: " + (on ? "On" : "Off");
+ notesBtn.classList.toggle("toggled", on);
+ modeLblEl.textContent = on ? "Notes" : "Fill";
+ }
+
+ function moveSelection(dr, dc){
+ var idx = state.selected;
+ if (idx < 0){ selectCell(0); return; }
+ var r = (idx / 9) | 0, c = idx % 9;
+ r = Math.max(0, Math.min(8, r + dr));
+ c = Math.max(0, Math.min(8, c + dc));
+ selectCell(r*9 + c);
+ }
+
+ function onKey(e){
+ var key = e.key;
+ if (key >= "1" && key <= "9"){
+ inputNumber(parseInt(key, 10));
+ e.preventDefault();
+ return;
+ }
+ if (key === "0" || key === "Backspace" || key === "Delete"){
+ inputNumber(0);
+ e.preventDefault();
+ return;
+ }
+ if (key === "ArrowUp"){ moveSelection(-1, 0); e.preventDefault(); return; }
+ if (key === "ArrowDown"){ moveSelection(1, 0); e.preventDefault(); return; }
+ if (key === "ArrowLeft"){ moveSelection(0, -1); e.preventDefault(); return; }
+ if (key === "ArrowRight"){ moveSelection(0, 1); e.preventDefault(); return; }
+ if (key === "n" || key === "N"){ setNotesMode(!state.notesMode); e.preventDefault(); return; }
+ }
+
+ // ---------- init ----------
+ function init(){
+ buildBoard();
+ buildPad();
+
+ diffSelEl.addEventListener("change", function(){
+ state.difficulty = diffSelEl.value;
+ diffLblEl.textContent = diffSelEl.options[diffSelEl.selectedIndex].text;
+ newGame();
+ });
+ newBtn.addEventListener("click", function(){ newGame(); });
+ notesBtn.addEventListener("click", function(){ setNotesMode(!state.notesMode); });
+ document.addEventListener("keydown", onKey);
+
+ // pause timer when tab hidden, resume on return
+ document.addEventListener("visibilitychange", function(){
+ if (document.hidden){
+ state.running = false;
+ } else if (!state.solved && state.values.length){
+ state.startTime = Date.now() - state.elapsed * 1000;
+ state.running = true;
+ }
+ });
+
+ state.difficulty = diffSelEl.value;
+ diffLblEl.textContent = diffSelEl.options[diffSelEl.selectedIndex].text;
+ setNotesMode(false);
+ showBest();
+ newGame();
+ }
+
+ if (document.readyState === "loading"){
+ document.addEventListener("DOMContentLoaded", init);
+ } else {
+ init();
+ }
+})();
+</script>
+</body>
+</html>
diff --git a/games/tron/index.html b/games/tron/index.html
new file mode 100644
index 0000000..ad192f3
--- /dev/null
+++ b/games/tron/index.html
@@ -0,0 +1,454 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
+<title>Abrams Tron</title>
+<style>
+ :root {
+ --bg:#0a0e14; --arena:#0b1018; --grid:#122130; --gridGlow:#0e3a4a;
+ --p1:#22d3ee; --p1glow:#0891b2; --p2:#f59e0b; --p2glow:#b45309;
+ --ink:#e5e7eb; --dim:#8aa0b2;
+ }
+ * { box-sizing:border-box; margin:0; padding:0; }
+ html,body { height:100%; }
+ body {
+ background:radial-gradient(1200px 700px at 50% -12%, #10202e, var(--bg));
+ color:var(--ink); font:14px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;
+ display:flex; flex-direction:column; align-items:center; justify-content:center;
+ min-height:100%; gap:10px; padding:12px; overscroll-behavior:none; touch-action:none;
+ -webkit-user-select:none; user-select:none;
+ }
+ h1 {
+ font-size:15px; letter-spacing:.42em; text-transform:uppercase; font-weight:700;
+ color:#e0f7ff; text-shadow:0 0 12px rgba(34,211,238,.55), 0 0 30px rgba(34,211,238,.25);
+ padding-left:.42em;
+ }
+ .hud {
+ display:flex; gap:16px; align-items:center; font-variant-numeric:tabular-nums;
+ font-size:12px; color:var(--dim); flex-wrap:wrap; justify-content:center;
+ }
+ .hud .pill { display:flex; align-items:center; gap:6px; padding:3px 10px; border-radius:999px;
+ border:1px solid #1c2a38; background:rgba(12,18,26,.6); }
+ .hud b { color:var(--ink); font-weight:700; }
+ .dot { width:9px; height:9px; border-radius:50%; }
+ .dot.p1 { background:var(--p1); box-shadow:0 0 8px var(--p1); }
+ .dot.p2 { background:var(--p2); box-shadow:0 0 8px var(--p2); }
+ .streak b { color:#fde68a; }
+ #wrap { position:relative; line-height:0; }
+ canvas {
+ background:var(--arena); border-radius:14px; display:block;
+ box-shadow:0 0 0 1px #16354a, 0 0 40px rgba(34,211,238,.12), 0 14px 50px rgba(0,0,0,.6);
+ max-width:96vw; max-height:70vh; touch-action:none;
+ }
+ .overlay {
+ position:absolute; inset:0; display:flex; flex-direction:column; align-items:center; justify-content:center;
+ gap:10px; text-align:center; background:rgba(7,11,17,.78); border-radius:14px; backdrop-filter:blur(2px);
+ padding:16px;
+ }
+ .overlay.hide { display:none; }
+ .tag { font-size:11px; letter-spacing:.28em; text-transform:uppercase; color:var(--p1);
+ text-shadow:0 0 10px rgba(34,211,238,.5); }
+ .big { font-size:24px; font-weight:800; letter-spacing:.04em; }
+ .big.p1c { color:#67e8f9; text-shadow:0 0 16px rgba(34,211,238,.5); }
+ .big.p2c { color:#fbbf24; text-shadow:0 0 16px rgba(245,158,11,.5); }
+ .sub { font-size:12.5px; color:var(--dim); max-width:300px; }
+ button.play {
+ margin-top:4px; padding:11px 26px; border:0; border-radius:999px; cursor:pointer;
+ background:linear-gradient(180deg,#22d3ee,#0891b2); color:#031419; font-weight:800; font-size:14px;
+ letter-spacing:.06em; text-transform:uppercase; box-shadow:0 6px 20px rgba(34,211,238,.35);
+ }
+ .btns { position:absolute; top:8px; right:10px; display:flex; gap:6px; z-index:3; }
+ .btns button { width:32px; height:32px; border-radius:9px; border:1px solid #21384a;
+ background:rgba(15,24,34,.85); color:#bfe6f2; cursor:pointer; font-size:15px; }
+ .hint { font-size:11px; color:#5b6673; }
+ /* touch turn buttons */
+ .turns { display:none; gap:14px; margin-top:2px; }
+ .turns button {
+ width:74px; height:52px; border-radius:14px; border:1px solid #21384a;
+ background:rgba(15,24,34,.9); color:var(--p1); font-size:24px; cursor:pointer;
+ box-shadow:0 0 14px rgba(34,211,238,.15);
+ }
+ .turns button:active { background:rgba(34,211,238,.18); }
+ body.touch .turns { display:flex; }
+ body.touch .hint { display:none; }
+</style>
+</head>
+<body>
+ <h1>Abrams Tron</h1>
+ <div class="hud">
+ <span class="pill"><i class="dot p1"></i>YOU <b id="s1">0</b></span>
+ <span class="pill"><i class="dot p2"></i>CPU <b id="s2">0</b></span>
+ <span class="pill">Round <b id="round">1</b>/<b id="rounds">5</b></span>
+ <span class="pill streak">Streak <b id="streak">0</b> · Best <b id="best">0</b></span>
+ </div>
+ <div id="wrap">
+ <canvas id="c" width="480" height="480"></canvas>
+ <div class="btns">
+ <button id="pauseBtn" title="Pause (P)">⏸</button>
+ </div>
+ <div class="overlay" id="ov">
+ <div class="tag" id="ovTag">Ready</div>
+ <div class="big" id="ovTitle">Abrams Tron</div>
+ <div class="sub" id="ovSub">Ride the cyan cycle. Steer to box in the CPU and avoid every trail. First to 3 rounds wins the match.</div>
+ <button class="play" id="playBtn">Play</button>
+ <div class="hint">Arrows / WASD to turn · P pause</div>
+ </div>
+ </div>
+ <div class="turns">
+ <button id="tLeft" aria-label="Turn left">↺</button>
+ <button id="tRight" aria-label="Turn right">↻</button>
+ </div>
+
+<script>
+(function () {
+ "use strict";
+ function boot() {
+ var cv = document.getElementById('c');
+ if (!cv || !cv.getContext) return;
+ var ctx = cv.getContext('2d');
+
+ // ---- config ----
+ var COLS = 60, ROWS = 60; // logical grid
+ var CELL = cv.width / COLS; // pixel size per cell
+ var WIN_ROUNDS = 3; // first to N rounds wins match
+ var MAX_ROUNDS = 5;
+ var TICK_MS = 55; // movement cadence
+
+ var DIRS = { up:{x:0,y:-1}, down:{x:0,y:1}, left:{x:-1,y:0}, right:{x:1,y:0} };
+ var OPP = { up:'down', down:'up', left:'right', right:'left' };
+
+ var best = 0;
+ try { best = parseInt(localStorage.getItem('abrams-tron-best') || '0', 10) || 0; } catch (e) {}
+
+ // ---- state ----
+ var grid; // occupancy: 0 empty, 1 player, 2 ai
+ var player, ai;
+ var score1 = 0, score2 = 0, roundNo = 1, streak = 0;
+ var state = 'ready'; // ready | play | paused | roundover | matchover
+ var raf = null, acc = 0, last = 0;
+ var flash = 0, flashOwner = 0;
+
+ var $ = function (id) { return document.getElementById(id); };
+ var ov = $('ov');
+
+ function newCycle(col, row, dir, id) {
+ return { x:col, y:row, dir:dir, next:dir, id:id, alive:true, trail:[{x:col,y:row}] };
+ }
+
+ function resetRound() {
+ grid = new Array(COLS * ROWS);
+ for (var i = 0; i < grid.length; i++) grid[i] = 0;
+ // player left, heading right; ai right, heading left
+ var py = Math.floor(ROWS / 2);
+ player = newCycle(6, py, 'right', 1);
+ ai = newCycle(COLS - 7, py, 'left', 2);
+ grid[idx(player.x, player.y)] = 1;
+ grid[idx(ai.x, ai.y)] = 2;
+ acc = 0; flash = 0;
+ }
+
+ function idx(x, y) { return y * COLS + x; }
+ function inb(x, y) { return x >= 0 && y >= 0 && x < COLS && y < ROWS; }
+ function free(x, y) { return inb(x, y) && grid[idx(x, y)] === 0; }
+
+ // queue a turn (ignore reversals)
+ function turn(dir) {
+ if (state !== 'play') return;
+ if (dir === OPP[player.dir]) return;
+ player.next = dir;
+ }
+ // relative left/right turn for touch
+ function turnRel(rel) {
+ var order = ['up','right','down','left'];
+ var i = order.indexOf(player.dir);
+ var ni = (i + (rel > 0 ? 1 : 3)) % 4;
+ turn(order[ni]);
+ }
+
+ // ---- AI ----
+ // count how many free cells reachable within `limit` steps (flood) from a start cell,
+ // treating a hypothetical occupied set. Cheap space estimate to avoid trapping self.
+ function floodSpace(sx, sy, limit) {
+ if (!free(sx, sy)) return 0;
+ var seen = {};
+ var q = [[sx, sy]];
+ seen[idx(sx, sy)] = 1;
+ var count = 0, head = 0;
+ while (head < q.length && count < limit) {
+ var c = q[head++]; count++;
+ var nx, ny, dd = [[1,0],[-1,0],[0,1],[0,-1]];
+ for (var k = 0; k < 4; k++) {
+ nx = c[0] + dd[k][0]; ny = c[1] + dd[k][1];
+ var id2 = idx(nx, ny);
+ if (free(nx, ny) && !seen[id2]) { seen[id2] = 1; q.push([nx, ny]); }
+ }
+ }
+ return count;
+ }
+
+ function aiChoose() {
+ var options = ['up','down','left','right'];
+ var cur = ai.dir;
+ var best2 = null, bestScore = -1e9;
+ for (var i = 0; i < options.length; i++) {
+ var d = options[i];
+ if (d === OPP[cur]) continue; // no reversing
+ var nx = ai.x + DIRS[d].x, ny = ai.y + DIRS[d].y;
+ if (!free(nx, ny)) continue; // immediate death
+ // lookahead: space available after this move
+ grid[idx(nx, ny)] = 2; // tentatively occupy
+ var space = floodSpace(nx, ny, 260);
+ grid[idx(nx, ny)] = 0; // undo
+
+ var sc = space;
+ // prefer keeping straight a bit (smoother), small bonus
+ if (d === cur) sc += 6;
+ // aggression: bias toward cutting the player off — move that reduces
+ // player's open space is favored, but not at cost of our own survival.
+ var pdist = Math.abs(nx - player.x) + Math.abs(ny - player.y);
+ if (space > 40) sc += (30 - Math.min(pdist, 30)) * 0.6; // close in when safe
+ // tiny randomness to avoid deterministic loops
+ sc += Math.random() * 2;
+
+ if (sc > bestScore) { bestScore = sc; best2 = d; }
+ }
+ if (best2) ai.next = best2;
+ // if nothing safe, keep current dir (will crash — fair)
+ }
+
+ // ---- step ----
+ function step() {
+ aiChoose();
+ player.dir = player.next;
+ ai.dir = ai.next;
+
+ var p1x = player.x + DIRS[player.dir].x, p1y = player.y + DIRS[player.dir].y;
+ var a1x = ai.x + DIRS[ai.dir].x, a1y = ai.y + DIRS[ai.dir].y;
+
+ var pDead = !free(p1x, p1y);
+ var aDead = !free(a1x, a1y);
+ // head-on into same cell = both dead
+ if (p1x === a1x && p1y === a1y) { pDead = true; aDead = true; }
+
+ if (!pDead) { grid[idx(p1x, p1y)] = 1; player.x = p1x; player.y = p1y; player.trail.push({x:p1x,y:p1y}); }
+ if (!aDead) { grid[idx(a1x, a1y)] = 2; ai.x = a1x; ai.y = a1y; ai.trail.push({x:a1x,y:a1y}); }
+
+ if (pDead || aDead) {
+ player.alive = !pDead; ai.alive = !aDead;
+ endRound(pDead, aDead);
+ }
+ }
+
+ function endRound(pDead, aDead) {
+ var winner; // 1 player, 2 ai, 0 draw
+ if (pDead && aDead) winner = 0;
+ else if (pDead) winner = 2;
+ else winner = 1;
+
+ if (winner === 1) { score1++; streak++; if (streak > best) { best = streak; saveBest(); } }
+ else if (winner === 2) { score2++; streak = 0; }
+ // draw: no score, no streak change
+
+ flash = 1; flashOwner = winner;
+ render();
+
+ var matchDone = score1 >= WIN_ROUNDS || score2 >= WIN_ROUNDS || roundNo >= MAX_ROUNDS;
+ if (matchDone) {
+ state = 'matchover';
+ var youWin = score1 > score2;
+ showOverlay(
+ youWin ? 'Match Won' : (score1 === score2 ? 'Match Draw' : 'Match Lost'),
+ youWin ? 'DERES.' : (score1 === score2 ? 'DEADLOCK.' : 'GAME OVER'),
+ 'Final ' + score1 + ' – ' + score2 + '. Best win streak ' + best + '.',
+ 'Play Again', youWin ? 'p1c' : 'p2c'
+ );
+ } else {
+ state = 'roundover';
+ var msg = winner === 1 ? 'You boxed in the CPU.' : winner === 2 ? 'The CPU cut you off.' : 'Both cycles crashed.';
+ showOverlay(
+ 'Round ' + roundNo + ' — ' + (winner === 1 ? 'You' : winner === 2 ? 'CPU' : 'Draw'),
+ winner === 1 ? 'ROUND WON' : winner === 2 ? 'ROUND LOST' : 'DRAW',
+ msg + ' ' + score1 + ' – ' + score2,
+ 'Next Round', winner === 1 ? 'p1c' : winner === 2 ? 'p2c' : ''
+ );
+ }
+ updateHud();
+ }
+
+ function saveBest() { try { localStorage.setItem('abrams-tron-best', String(best)); } catch (e) {} }
+
+ function nextRound() {
+ roundNo++;
+ resetRound();
+ state = 'play';
+ hideOverlay();
+ updateHud();
+ }
+
+ function restartMatch() {
+ score1 = 0; score2 = 0; roundNo = 1; streak = 0;
+ resetRound();
+ state = 'play';
+ hideOverlay();
+ updateHud();
+ }
+
+ // ---- rendering ----
+ function drawGrid() {
+ ctx.fillStyle = '#0b1018';
+ ctx.fillRect(0, 0, cv.width, cv.height);
+ ctx.strokeStyle = 'rgba(20,52,72,.55)';
+ ctx.lineWidth = 1;
+ ctx.beginPath();
+ for (var x = 0; x <= COLS; x += 3) {
+ var px = Math.round(x * CELL) + 0.5;
+ ctx.moveTo(px, 0); ctx.lineTo(px, cv.height);
+ }
+ for (var y = 0; y <= ROWS; y += 3) {
+ var py = Math.round(y * CELL) + 0.5;
+ ctx.moveTo(0, py); ctx.lineTo(cv.width, py);
+ }
+ ctx.stroke();
+ // border glow
+ ctx.strokeStyle = 'rgba(34,211,238,.45)';
+ ctx.lineWidth = 2;
+ ctx.strokeRect(1, 1, cv.width - 2, cv.height - 2);
+ }
+
+ function drawTrail(cyc, color, glow) {
+ var t = cyc.trail;
+ ctx.save();
+ ctx.shadowColor = glow; ctx.shadowBlur = 8;
+ ctx.fillStyle = color;
+ for (var i = 0; i < t.length; i++) {
+ ctx.fillRect(t[i].x * CELL, t[i].y * CELL, CELL, CELL);
+ }
+ ctx.restore();
+ // head brighter
+ var h = t[t.length - 1];
+ ctx.save();
+ ctx.shadowColor = glow; ctx.shadowBlur = 16;
+ ctx.fillStyle = '#ffffff';
+ ctx.globalAlpha = 0.9;
+ ctx.fillRect(h.x * CELL, h.y * CELL, CELL, CELL);
+ ctx.restore();
+ }
+
+ function render() {
+ drawGrid();
+ drawTrail(ai, '#f59e0b', '#b45309');
+ drawTrail(player, '#22d3ee', '#0891b2');
+ if (flash > 0) {
+ var c = flashOwner === 1 ? 'rgba(34,211,238,' : flashOwner === 2 ? 'rgba(245,158,11,' : 'rgba(200,210,220,';
+ ctx.fillStyle = c + (0.18 * flash) + ')';
+ ctx.fillRect(0, 0, cv.width, cv.height);
+ }
+ }
+
+ // ---- loop ----
+ function frame(ts) {
+ raf = requestAnimationFrame(frame);
+ if (!last) last = ts;
+ var dt = ts - last; last = ts;
+ if (flash > 0) flash = Math.max(0, flash - dt / 400);
+
+ if (state === 'play') {
+ acc += dt;
+ while (acc >= TICK_MS && state === 'play') {
+ acc -= TICK_MS;
+ step();
+ }
+ render();
+ } else if (flash > 0) {
+ render();
+ }
+ }
+
+ // ---- overlay / hud ----
+ function showOverlay(tag, title, sub, btn, cls) {
+ $('ovTag').textContent = tag;
+ var t = $('ovTitle');
+ t.textContent = title;
+ t.className = 'big' + (cls ? ' ' + cls : '');
+ $('ovSub').textContent = sub;
+ $('playBtn').textContent = btn;
+ ov.classList.remove('hide');
+ }
+ function hideOverlay() { ov.classList.add('hide'); }
+
+ function updateHud() {
+ $('s1').textContent = score1;
+ $('s2').textContent = score2;
+ $('round').textContent = roundNo;
+ $('rounds').textContent = MAX_ROUNDS;
+ $('streak').textContent = streak;
+ $('best').textContent = best;
+ }
+
+ // ---- input ----
+ function onPlayBtn() {
+ if (state === 'ready' || state === 'matchover') restartMatch();
+ else if (state === 'roundover') nextRound();
+ }
+
+ var keyMap = {
+ ArrowUp:'up', ArrowDown:'down', ArrowLeft:'left', ArrowRight:'right',
+ w:'up', s:'down', a:'left', d:'right', W:'up', S:'down', A:'left', D:'right'
+ };
+ window.addEventListener('keydown', function (e) {
+ if (e.key === 'p' || e.key === 'P') { togglePause(); return; }
+ if (e.key === 'Enter' || e.key === ' ') {
+ if (state === 'ready' || state === 'roundover' || state === 'matchover') { e.preventDefault(); onPlayBtn(); }
+ return;
+ }
+ var d = keyMap[e.key];
+ if (d) {
+ // prevent page scroll for arrows/space
+ if (e.key.indexOf('Arrow') === 0) e.preventDefault();
+ turn(d);
+ }
+ }, { passive:false });
+
+ function togglePause() {
+ if (state === 'play') { state = 'paused'; showOverlay('Paused', 'PAUSED', 'Press P or Play to resume.', 'Resume', ''); }
+ else if (state === 'paused') { state = 'play'; hideOverlay(); }
+ }
+
+ $('playBtn').addEventListener('click', function () {
+ if (state === 'paused') { state = 'play'; hideOverlay(); return; }
+ onPlayBtn();
+ });
+ $('pauseBtn').addEventListener('click', togglePause);
+
+ // touch turn buttons
+ $('tLeft').addEventListener('click', function () { turnRel(-1); });
+ $('tRight').addEventListener('click', function () { turnRel(1); });
+
+ // tap-left-half / tap-right-half on canvas for touch
+ cv.addEventListener('pointerdown', function (e) {
+ if (e.pointerType === 'mouse') return; // mouse uses buttons/keys
+ var r = cv.getBoundingClientRect();
+ var rel = (e.clientX - r.left) < r.width / 2 ? -1 : 1;
+ turnRel(rel);
+ e.preventDefault();
+ }, { passive:false });
+
+ // detect touch to reveal on-screen controls
+ function markTouch() { document.body.classList.add('touch'); window.removeEventListener('touchstart', markTouch); }
+ window.addEventListener('touchstart', markTouch, { passive:true });
+ if (('ontouchstart' in window) || (navigator.maxTouchPoints > 0)) document.body.classList.add('touch');
+
+ // ---- init ----
+ resetRound();
+ updateHud();
+ render();
+ raf = requestAnimationFrame(frame);
+ }
+
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot);
+ else boot();
+})();
+</script>
+</body>
+</html>
diff --git a/games/wordle/index.html b/games/wordle/index.html
new file mode 100644
index 0000000..7044fe6
--- /dev/null
+++ b/games/wordle/index.html
@@ -0,0 +1,477 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
+<title>Abrams Wordle</title>
+<style>
+ :root {
+ --bg:#0a0e14; --ink:#e5e7eb; --muted:#8b97a7;
+ --tile:#141b25; --tile-border:#2a3441;
+ --green:#2fbf71; --yellow:#e0b23a; --gray:#3a4655;
+ --key:#28323f; --key-ink:#dbe3ec;
+ --neon:#7dd3fc;
+ }
+ * { box-sizing:border-box; margin:0; padding:0; -webkit-tap-highlight-color:transparent; }
+ html,body { height:100%; }
+ body {
+ background:radial-gradient(1200px 600px at 50% -10%, #16202e, var(--bg));
+ color:var(--ink); font:15px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;
+ display:flex; flex-direction:column; align-items:center;
+ min-height:100%; gap:10px; padding:12px 10px; overscroll-behavior:none;
+ touch-action:manipulation; user-select:none; -webkit-user-select:none;
+ }
+ h1 {
+ font-size:15px; letter-spacing:.34em; text-transform:uppercase;
+ color:var(--neon); font-weight:700; text-align:center;
+ text-shadow:0 0 12px rgba(125,211,252,.35);
+ }
+ .hud { display:flex; gap:8px; align-items:stretch; flex-wrap:wrap; justify-content:center; }
+ .stat { background:var(--tile); border:1px solid var(--tile-border); border-radius:10px; padding:5px 10px; text-align:center; min-width:58px; }
+ .stat .k { font-size:9px; letter-spacing:.12em; text-transform:uppercase; color:var(--muted); }
+ .stat .v { font-size:17px; font-weight:700; font-variant-numeric:tabular-nums; }
+ .btn {
+ padding:7px 14px; border-radius:10px; border:1px solid var(--tile-border);
+ background:var(--tile); color:#cbd5e1; cursor:pointer; font-size:13px; font-weight:600;
+ }
+ .btn:active { transform:scale(.96); }
+
+ #board { display:grid; grid-template-rows:repeat(6,1fr); gap:6px; margin-top:2px; }
+ .brow { display:grid; grid-template-columns:repeat(5,1fr); gap:6px; }
+ .tile {
+ width:52px; height:52px; border:2px solid var(--tile-border);
+ border-radius:8px; display:flex; align-items:center; justify-content:center;
+ font-weight:800; font-size:26px; text-transform:uppercase; color:var(--ink);
+ background:transparent; transition:transform .08s ease, border-color .12s ease;
+ }
+ .tile.filled { border-color:#4a586b; }
+ .tile.pop { animation:pop .12s ease; }
+ @keyframes pop { 0%{transform:scale(1)} 50%{transform:scale(1.12)} 100%{transform:scale(1)} }
+ .tile.flip { animation:flip .5s ease forwards; }
+ @keyframes flip {
+ 0% { transform:rotateX(0); }
+ 45% { transform:rotateX(90deg); }
+ 55% { transform:rotateX(90deg); }
+ 100% { transform:rotateX(0); }
+ }
+ .tile.g { background:var(--green); border-color:var(--green); color:#062015; box-shadow:0 0 14px rgba(47,191,113,.4); }
+ .tile.y { background:var(--yellow); border-color:var(--yellow); color:#241a03; box-shadow:0 0 14px rgba(224,178,58,.35); }
+ .tile.x { background:var(--gray); border-color:var(--gray); color:#cdd6e0; }
+ .brow.shake { animation:shake .4s ease; }
+ @keyframes shake {
+ 0%,100%{transform:translateX(0)} 20%{transform:translateX(-7px)}
+ 40%{transform:translateX(7px)} 60%{transform:translateX(-5px)} 80%{transform:translateX(5px)}
+ }
+
+ #msg { height:20px; font-size:13px; font-weight:600; color:var(--neon); letter-spacing:.02em; text-align:center; }
+
+ #kb { display:flex; flex-direction:column; gap:6px; width:100%; max-width:500px; margin-top:2px; }
+ .krow { display:flex; gap:5px; justify-content:center; }
+ .key {
+ flex:1 1 auto; min-width:0; height:50px; border-radius:7px; border:0;
+ background:var(--key); color:var(--key-ink); font-weight:700; font-size:14px;
+ text-transform:uppercase; cursor:pointer; display:flex; align-items:center; justify-content:center;
+ transition:transform .06s ease, background .12s ease;
+ }
+ .key.wide { flex:1.5 1 auto; font-size:11px; letter-spacing:.04em; }
+ .key:active { transform:scale(.93); }
+ .key.g { background:var(--green); color:#062015; }
+ .key.y { background:var(--yellow); color:#241a03; }
+ .key.x { background:var(--gray); color:#9aa6b3; }
+
+ .overlay {
+ position:fixed; inset:0; display:flex; flex-direction:column; align-items:center; justify-content:center;
+ gap:10px; text-align:center; background:rgba(6,10,16,.86); backdrop-filter:blur(3px); z-index:10; padding:20px;
+ }
+ .overlay.hide { display:none; }
+ .tag { font-size:11px; color:var(--neon); letter-spacing:.24em; text-transform:uppercase; }
+ .big { font-size:26px; font-weight:800; }
+ .word { font-size:20px; font-weight:800; letter-spacing:.18em; text-transform:uppercase; color:var(--green); }
+ .sub { font-size:13px; color:var(--muted); max-width:280px; }
+ button.play { margin-top:6px; padding:11px 26px; border:0; border-radius:999px; cursor:pointer;
+ background:linear-gradient(180deg,#7dd3fc,#38bdf8); color:#03202e; font-weight:800; font-size:14px; }
+ .ostats { display:flex; gap:14px; margin-top:6px; }
+ .ostats .k { font-size:9px; letter-spacing:.1em; text-transform:uppercase; color:var(--muted); }
+ .ostats .v { font-size:20px; font-weight:800; }
+
+ @media (max-width:380px){
+ .tile{ width:46px; height:46px; font-size:22px; }
+ .key{ height:46px; }
+ h1{ letter-spacing:.24em; }
+ }
+</style>
+</head>
+<body>
+ <h1>Abrams Wordle</h1>
+ <div class="hud">
+ <div class="stat"><div class="k">Played</div><div class="v" id="sPlayed">0</div></div>
+ <div class="stat"><div class="k">Win %</div><div class="v" id="sWin">0</div></div>
+ <div class="stat"><div class="k">Streak</div><div class="v" id="sStreak">0</div></div>
+ <div class="stat"><div class="k">Max</div><div class="v" id="sMax">0</div></div>
+ <button class="btn" id="newBtn">New</button>
+ </div>
+ <div id="board"></div>
+ <div id="msg"></div>
+ <div id="kb"></div>
+
+ <div class="overlay hide" id="ov">
+ <div class="tag" id="ovTag">Result</div>
+ <div class="big" id="ovTitle"></div>
+ <div class="word" id="ovWord"></div>
+ <div class="sub" id="ovSub"></div>
+ <div class="ostats">
+ <div><div class="k">Played</div><div class="v" id="oPlayed">0</div></div>
+ <div><div class="k">Win %</div><div class="v" id="oWin">0</div></div>
+ <div><div class="k">Streak</div><div class="v" id="oStreak">0</div></div>
+ <div><div class="k">Max</div><div class="v" id="oMax">0</div></div>
+ </div>
+ <button class="play" id="playBtn">New game</button>
+ </div>
+
+<script>
+(function () {
+ "use strict";
+
+ // ---- Answer words (common 5-letter solutions) ----
+ var ANSWERS = ["about","above","abuse","actor","acute","admit","adopt","adult","after","again","agent","agree","ahead","alarm","album","alert","alike","alive","allow","alone","along","alter","among","anger","angle","angry","apart","apple","apply","arena","argue","arise","array","aside","asset","audio","audit","avoid","award","aware","badly","baker","bases","basic","beach","began","begin","being","below","bench","billy","birth","black","blame","blank","blast","blind","block","blood","board","boost","booth","bound","brain","brand","brass","brave","bread","break","breed","brief","bring","broad","broke","brown","brush","build","built","buyer","cable","calif","carry","catch","cause","chain","chair","chaos","charm","chart","chase","cheap","check","chest","chief","child","china","chose","civil","claim","class","clean","clear","click","climb","clock","close","cloud","coach","coast","could","count","court","cover","craft","crash","crazy","cream","crime","cross","crowd","crown","crude","curve","cycle","daily","dance","dated","dealt","death","debut","delay","depth","doing","doubt","dozen","draft","drama","drank","drawn","dream","dress","drill","drink","drive","drove","dying","eager","early","earth","eight","elite","empty","enemy","enjoy","enter","entry","equal","error","event","every","exact","exist","extra","faith","false","fault","favor","fence","fever","fewer","field","fifth","fifty","fight","final","first","fixed","flame","flash","fleet","floor","fluid","focus","force","forth","forty","forum","found","frame","frank","fraud","fresh","front","fruit","fully","funny","giant","given","glass","globe","going","grace","grade","grain","grand","grant","grass","grave","great","green","gross","group","grown","guard","guess","guest","guide","happy","harsh","heart","heavy","hence","henry","horse","hotel","house","human","ideal","image","index","inner","input","issue","japan","jimmy","joint","jones","judge","known","label","large","laser","later","laugh","layer","learn","lease","least","leave","legal","level","lewis","light","limit","links","lives","local","logic","loose","lower","lucky","lunch","lying","magic","major","maker","march","maria","match","maybe","mayor","meant","medal","media","metal","meter","might","minor","minus","mixed","model","money","month","moral","motor","mount","mouse","mouth","moved","movie","music","needs","never","newly","night","noise","north","noted","novel","nurse","occur","ocean","offer","often","order","other","ought","paint","panel","paper","party","peace","penny","peter","phase","phone","photo","piano","piece","pilot","pitch","place","plain","plane","plant","plate","point","pound","power","press","price","pride","prime","print","prior","prize","proof","proud","prove","queen","quick","quiet","quite","radio","raise","range","rapid","ratio","reach","ready","realm","rebel","refer","relax","reply","right","rigid","rival","river","robin","roger","roman","rough","round","route","royal","rural","scale","scene","scope","score","sense","serve","seven","shall","shape","share","sharp","sheet","shelf","shell","shift","shine","shirt","shock","shoot","short","shown","sight","silly","since","sixth","sixty","sized","skill","sleep","slide","small","smart","smile","smith","smoke","snake","snowy","solid","solve","sorry","sound","south","space","spare","speak","speed","spend","spent","split","spoke","sport","staff","stage","stake","stand","start","state","steam","steel","steep","steer","stern","stick","still","stock","stone","stood","store","storm","story","strip","stuck","study","stuff","style","sugar","suite","super","sweet","table","taken","taste","taxes","teach","teeth","terry","texas","thank","theft","their","theme","there","these","thick","thing","think","third","those","three","threw","throw","thumb","tiger","tight","timer","title","today","topic","total","touch","tough","tower","track","trade","trail","train","treat","trend","trial","tribe","trick","tried","tries","truck","truly","trunk","trust","truth","twice","under","undue","union","unity","until","upper","upset","urban","usage","usual","valid","value","video","virus","visit","vital","voice","waste","watch","water","wheel","where","which","while","white","whole","whose","woman","women","world","worry","worse","worst","worth","would","wound","write","wrong","wrote","yield","young","youth","zebra","zesty","chalk","chart","chord","churn","cider","cliff","cloak","clone","clown","coral","couch","cough","cramp","crank","crate","crept","crisp","crook","daisy","dandy","dodge","donor","drift","drown","dwarf","ebony","elbow","ember","evoke","exile","fable","fairy","fancy","feast","ferry","fetch","fiber","fjord","flask","flint","float","flock","flora","flour","flung","folly","forge","frost","gauge","gecko","ghost","gland","gleam","glide","glint","gloom","glory","gloss","grasp","grill","grind","groan","grove","gruff","habit","haste","hazel","hedge","hinge","hoard","hobby","honey","humid","hunch","hutch","icing","igloo","ivory","jelly","jewel","jolly","joust","kiosk","kneel","knock","lapse","larva","latch","lemon","liver","llama","lobby","lodge","loyal","lyric","manor","maple","marsh","medic","melon","mercy","merge","merit","mirth","mocha","moist","mound","mourn","mucky","mummy","nasty","navel","nerve","niche","noble","nudge","oaken","olive","onset","opera","orbit","organ","otter","ozone","pansy","parka","pasta","patch","pause","peach","pearl","pecan","perch","petal","piety","pixel","plaid","pluck","plumb","plume","plush","poise","polar","porch","posse","pouch","prank","prawn","preen","prism","probe","prong","prune","pupil","quail","quake","qualm","quart","quash","quell","query","quest","quill","quilt","quota","quote","rabid","ranch","raven","razor","realm","rerun","resin","rhino","ridge","rinse","risky","roast","robot","rogue","rowdy","ruddy","rugby","salsa","salty","sandy","sassy","sauna","savor","scald","scalp","scarf","scoff","scold","scoop","scorn","scout","scowl","scrap","scrub","seize","serum","sever","sewer","shack","shade","shady","shaft","shale","shame","shard","shawl","shear","sheen","sheep","sheer","shied","shiny","shoal","shone","shore","shorn","showy","shrub","shrug","shuck","siege","sieve","skate","skimp","skunk","slack","slain","slang","slant","slash","slate","sleek","sleet","slept","slick","slime","sling","slink","slope","slosh","sloth","slump","slung","slunk","slush","smash","smear","smelt","smirk","snack","snail","snare","sneak","sneer","snort","snout","snowy","soggy","solar","sonic","sooty","sound","spade","spank","spawn","spear","speck","spell","spice","spicy","spied","spike","spiky","spine","spiny","spire","spite","splat","spoof","spool","spoon","spore","sprig","spurn","spurt","squad","squat","squid","stack","stage","staid","stain","stair","stalk","stall","stamp","stank","stare","stash","steak","stead","steed","stink","stint","stirs","stoke","stole","stomp","stony","stool","stoop","stork","stout","stove","stown","strap","straw","stray","strum","strut","stump","stung","stunk","stunt","suave","surge","swamp","swash","swath","swear","sweat","swell","swept","swift","swine","swing","swirl","swoon","swoop","sword","swore","sworn","swung","syrup","tabby","tacit","taffy","tally","talon","tango","tardy","tarot","taunt","tawny","tease","tepid","thick","thigh","thong","thorn","those","throb","throw","thump","tibia","tidal","tiger","tilde","timid","tipsy","toast","today","toddy","token","tonal","tonic","topaz","torch","torso","total","touch","towel","tower","toxic","toxin","trace","tract","trail","tramp","trash","trawl","tread","treat","tress","triad","trill","tripe","troll","troop","trout","trove","truce","tulip","tumor","tunic","turbo","tutor","tweak","tweed","twine","twirl","twist","udder","ulcer","ultra","umbra","uncle","undid","unfit","unify","unlit","unmet","untie","upend","upper","usher","utter","vague","valor","vapor","vault","vegan","venom","verse","verve","vigor","villa","viper","vivid","vixen","vodka","vogue","vouch","vowel","wafer","wager","wagon","waist","waltz","waste","weary","weave","wedge","weedy","weigh","weird","whale","wharf","wheat","which","whiff","whine","whirl","whisk","white","whoop","whorl","widow","wield","wince","windy","wiser","witch","wooly","woozy","world","wrath","wreak","wreck","wrest","wring","wrist","wrote","yacht","yearn","yeast","yield","yodel","yokel","young","yummy","zebra","zesty","zonal"];
+
+ // ---- Additional allowed guesses (common valid words, not usually answers) ----
+ var EXTRA = ["aback","abbey","abbot","abhor","abide","abled","abode","aboil","abort","about","abyss","ached","aches","acids","acres","acrid","addle","adept","adieu","adobe","affix","afire","afoot","afoul","agape","agile","aging","agora","ahold","aisle","alarm","alder","alert","algae","alias","alibi","alien","align","alkyl","allay","alley","allot","aloft","aloof","aloud","altar","amass","amaze","amber","amble","amend","amiss","amity","ample","amply","amuse","angel","anime","anion","ankle","annex","annoy","antic","antsy","aorta","aphid","aptly","arbor","ardor","areas","arena","argon","aroma","arose","arrow","arson","ascot","ashen","ashes","aspen","aspic","assay","aster","astir","atlas","atoll","atone","attic","augur","aunts","avail","avast","avert","avian","await","awake","aware","awash","awful","awoke","axial","axiom","axion","azure","bacon","badge","badly","bagel","baggy","baked","baker","baler","balls","balmy","banal","banjo","barge","baron","basil","basin","baste","bathe","baton","batty","bawdy","bayou","beach","beady","beard","beast","befit","began","begat","beget","begin","begun","being","belch","belie","belle","belly","below","bench","beret","berry","berth","beset","betel","bevel","bezel","bicep","biddy","bigot","bilge","billy","binge","biome","birch","birth","bison","bitty","black","blade","blame","bland","blank","blare","blast","blaze","bleak","bleat","bleed","bleep","blend","bless","blimp","blind","blink","bliss","blitz","bloat","block","bloke","blond","blood","bloom","blown","bluff","blunt","blurb","blurt","blush","board","boast","bobby","boded","bogey","boggy","bongo","bonus","booby","boost","booth","booty","booze","borax","borne","bosom","bossy","botch","bough","boule","bound","bowel","boxer","brace","braid","brain","brake","brand","brash","brass","brave","bravo","brawl","brawn","bread","break","breed","briar","bribe","brick","bride","brine","bring","brink","briny","brisk","broad","broil","broke","brood","brook","broom","broth","brown","brunt","brush","brute","buddy","budge","buggy","bugle","build","built","bulge","bulky","bully","bunch","bunny","burly","burnt","burst","bushy","butte","buxom","buyer","bylaw","cabal","cabby","cabin","cable","cacao","cache","cacti","caddy","cadet","cagey","cairn","camel","cameo","canal","candy","canny","canoe","canon","caper","caput","carat","cargo","carol","carry","carve","caste","catch","cater","catty","caulk","cause","cavil","cease","cedar","cello","chafe","chaff","chain","chair","chalk","champ","chant","chaos","chard","charm","chart","chase","chasm","cheap","cheat","check","cheek","cheer","chess","chest","chick","chide","chief","child","chili","chill","chime","china","chirp","chock","choir","choke","chord","chore","chose","chuck","chump","chunk","churn","chute","cider","cigar","cinch","circa","civet","civic","civil","clack","claim","clamp","clang","clank","clash","clasp","class","clean","clear","cleat","cleft","clerk","click","cliff","climb","cling","clink","cloak","clock","clone","close","cloth","cloud","clout","clove","clown","cluck","clued","clump","clung","coach","coast","cobra","cocoa","colon","color","comet","comfy","comic","comma","conch","condo","conic","copse","coral","corny","couch","cough","could","count","coupe","court","coven","cover","covet","covey","cower","coyly","crack","craft","cramp","crane","crank","crash","crass","crate","crave","crawl","craze","crazy","creak","cream","credo","creed","creek","creep","creme","crepe","crept","cress","crest","crick","cried","crier","crime","crimp","crisp","croak","crock","crony","crook","croon","cross","croup","crowd","crown","crude","cruel","crumb","crump","crush","crust","crypt","cubic","cumin","curio","curly","curry","curse","curve","curvy","cutie","cyber","cycle","cynic","daddy","daily","dairy","daisy","dally","dance","dandy","datum","daunt","dealt","death","debit","debug","debut","decal","decay","decor","decoy","decry","defer","deign","deity","delta","delve","demon","demur","denim","dense","depot","depth","derby","deter","detox","devil","diary","dicey","digit","dilly","dimly","diner","dingo","dingy","diode","dirge","dirty","disco","ditch","ditto","ditty","diver","dizzy","dodge","dodgy","dogma","doing","dolly","donor","donut","dopey","doubt","dough","dowdy","dowel","downy","dowry","dozen","draft","drain","drake","drama","drank","drape","drawl","drawn","dread","dream","dress","dried","drier","drift","drill","drink","drive","droit","droll","drone","drool","droop","dross","drove","drown","druid","drunk","dryer","dryly","duchy","dully","dummy","dumpy","dunce","dusky","dusty","dutch","duvet","dwarf","dwell","dwelt","dying","eager","eagle","early","earth","easel","eaten","eater","ebony","eclat","edict","edify","eerie","egret","eight","eject","eking","elate","elbow","elder","elect","elegy","elfin","elide","elite","elope","elude","email","embed","ember","emcee","emote","empty","enact","endow","enema","enemy","enjoy","ennui","ensue","enter","entry","envoy","epoch","epoxy","equal","equip","erase","erect","erode","error","erupt","essay","ester","ether","ethic","ethos","etude","evade","event","every","evict","evoke","exact","exalt","excel","exert","exile","exist","expel","extol","extra","exult","fable","facet","faint","fairy","faith","false","fancy","farce","fatal","fatty","fault","fauna","favor","feast","fecal","feign","fella","felon","femur","fence","feral","ferry","fetal","fetch","fetid","fever","fewer","fiber","fibre","ficus","field","fiend","fiery","fifth","fifty","fight","filet","filly","filmy","filth","final","finch","finer","first","fishy","fixer","fizzy","fjord","flack","flail","flair","flake","flaky","flame","flank","flare","flash","flask","fleck","fleet","flesh","flick","flier","fling","flint","flirt","float","flock","flood","floor","flora","floss","flour","flout","flown","fluff","fluid","fluke","flume","flung","flush","flute","foamy","focal","focus","foggy","foist","folio","folly","foray","force","forge","forgo","forte","forth","forty","forum","found","foyer","frail","frame","frank","fraud","freak","freed","freer","fresh","friar","fried","frill","frisk","fritz","frock","frond","front","frost","froth","frown","froze","fruit","fudge","fugue","fully","fumes","fungi","funky","funny","furor","furry","fussy","fuzzy","gaffe","gaily","gamer","gamma","gamut","gassy","gaudy","gauge","gaunt","gauze","gavel","gawky","gayer","gayly","gazer","gecko","geeky","geese","genie","genre","ghost","ghoul","giant","giddy","gipsy","girly","girth","given","giver","gizmo","glade","gland","glare","glass","glaze","gleam","glean","glide","glint","gloat","globe","gloom","glory","gloss","glove","glyph","gnash","gnome","godly","going","golem","golly","gonad","goner","goody","gooey","goofy","goose","gorge","gouge","gourd","grace","grade","graft","grail","grain","grand","grant","grape","graph","grasp","grass","grate","grave","gravy","graze","great","greed","green","greet","grief","grill","grime","grimy","grind","gripe","groan","groin","groom","grope","gross","group","grout","grove","growl","grown","gruel","gruff","grunt","guard","guava","guess","guest","guide","guild","guile","guilt","guise","gulch","gully","gumbo","gummy","guppy","gusto","gusty","gypsy","habit","hairy","halve","handy","happy","hardy","harem","harpy","harsh","haste","hasty","hatch","hater","haunt","haute","haven","havoc","hazel","heady","heard","heart","heath","heave","heavy","hedge","hefty","heist","helix","hello","hence","heron","hilly","hinge","hippo","hippy","hitch","hoard","hobby","hoist","holly","homer","honey","honor","horde","horny","horse","hotel","hotly","hound","house","hovel","hover","howdy","human","humid","humor","humph","humus","hunch","hunky","hurry","husky","hussy","hutch","hydro","hyena","hymen","hyper","icily","icing","ideal","idiom","idiot","idler","idyll","igloo","iliac","image","imbue","impel","imply","inane","inbox","incur","index","inept","inert","infer","ingot","inlay","inlet","inner","input","inter","intro","ionic","irate","irony","islet","issue","itchy","ivory","jaunt","jazzy","jelly","jerky","jetty","jewel","jiffy","joint","joist","joker","jolly","joust","judge","juice","juicy","jumbo","jumpy","junta","junto","juror","kappa","karma","kayak","kebab","khaki","kinky","kiosk","kitty","knack","knave","knead","kneed","kneel","knelt","knife","knock","knoll","known","koala","krill","label","labor","laden","ladle","lager","lance","lanky","lapel","lapse","larva","laser","lasso","latch","later","lathe","laugh","layer","leach","leafy","leaky","leant","leapt","learn","lease","leash","least","leave","ledge","leech","leery","lefty","legal","leggy","lemon","lemur","leper","level","lever","libel","liege","light","liken","lilac","limbo","limit","linen","liner","lingo","lipid","lithe","liver","livid","llama","loamy","loath","lobby","local","locus","lodge","lofty","logic","login","loopy","loose","lorry","loser","louse","lousy","lover","lower","lowly","loyal","lucid","lucky","lumen","lumpy","lunar","lunch","lunge","lupus","lurch","lurid","lusty","lying","lymph","lynch","lyric","macaw","macho","macro","madam","madly","mafia","magic","magma","maize","major","maker","mambo","mamma","mammy","manga","mange","mango","mangy","mania","manic","manly","manor","maple","march","marge","marry","marsh","mason","masse","match","matey","mauve","maxim","maybe","mayor","mealy","meant","meaty","mecca","medal","media","medic","melee","melon","mercy","merge","merit","merry","metal","meter","metro","micro","midge","midst","might","milky","mimic","mince","miner","minim","minor","minty","minus","mirth","miser","missy","mocha","modal","model","modem","mogul","moist","molar","moldy","money","month","moody","moose","moral","moron","morph","mossy","motel","motif","motor","motto","moult","mound","mount","mourn","mouse","mousy","mouth","moved","mover","movie","mower","mucky","mucus","muddy","mulch","mummy","munch","mural","murky","mushy","music","musky","musty","myrrh","nadir","naive","nanny","nasal","nasty","natal","naval","navel","needy","neigh","nerdy","nerve","never","newer","newly","nicer","niche","niece","night","ninja","ninny","ninth","noble","nobly","noise","noisy","nomad","noose","north","nosey","notch","noted","novel","nudge","nurse","nutty","nylon","nymph","oaken","obese","occur","ocean","octal","octet","odder","oddly","offal","offer","often","olden","older","olive","ombre","omega","onion","onset","opera","opine","opium","optic","orbit","order","organ","other","otter","ought","ounce","outdo","outer","outgo","ovary","ovate","overt","ovine","ovoid","owing","owner","oxide","ozone","paddy","pagan","paint","paler","palsy","panel","panic","pansy","paper","parer","parka","parry","parse","party","pasta","paste","pasty","patch","patio","patsy","patty","pause","payee","payer","peace","peach","pearl","pecan","pedal","penal","pence","penne","penny","perch","peril","perky","pesky","pesto","petal","petty","phase","phone","phony","photo","piano","picky","piece","piety","piggy","pilot","pinch","piney","pinky","pinto","piper","pique","pitch","pithy","pivot","pixel","pixie","pizza","place","plaid","plain","plait","plane","plank","plant","plate","plaza","plead","pleat","plied","plier","pluck","plumb","plume","plump","plunk","plush","poesy","point","poise","poker","polar","polka","polyp","pooch","poppy","porch","poser","posit","posse","pouch","pound","pouty","power","prank","prawn","preen","press","preen","price","prick","pride","pried","prime","primo","primp","print","prior","prism","privy","prize","probe","prone","prong","proof","prose","proud","prove","prowl","proxy","prude","prune","psalm","pubic","pudgy","puffy","pulpy","pulse","punch","pupal","pupil","puppy","puree","purer","purge","purse","pushy","putty","pygmy","quack","quail","quake","qualm","quark","quart","quash","quasi","queen","queer","quell","query","quest","queue","quick","quiet","quill","quilt","quirk","quite","quota","quote","quoth","rabbi","rabid","racer","radar","radii","radio","rainy","raise","rajah","rally","ralph","ramen","ranch","randy","range","rapid","rarer","raspy","ratio","ratty","raven","rayon","razor","reach","react","ready","realm","rearm","rebar","rebel","rebus","rebut","recap","recur","recut","reedy","refer","refit","regal","rehab","reign","relax","relay","relic","remit","renal","renew","repay","repel","reply","rerun","reset","resin","retch","retro","retry","reuse","revel","revue","rhino","rhyme","rider","ridge","rifle","right","rigid","rigor","rinse","ripen","riper","risen","riser","risky","rival","river","rivet","roach","roast","robin","robot","rocky","rodeo","roger","rogue","roomy","roost","rotor","rouge","rough","round","rouse","route","rowdy","royal","ruddy","ruder","rugby","ruler","rumba","rumor","rupee","rural","rusty","sadly","safer","saint","salad","sally","salon","salsa","salty","salve","salvo","sandy","saner","sappy","sassy","satin","satyr","sauce","saucy","sauna","saute","savor","savoy","savvy","scald","scale","scalp","scaly","scamp","scant","scare","scarf","scary","scene","scent","scion","scoff","scold","scone","scoop","scope","score","scorn","scour","scout","scowl","scram","scrap","scree","screw","scrub","scrum","scuba","sedan","seedy","segue","seize","semen","sense","sepia","serif","serum","serve","setup","seven","sever","sewer","shack","shade","shady","shaft","shake","shaky","shale","shall","shalt","shame","shank","shape","shard","share","shark","sharp","shave","shawl","shear","sheen","sheep","sheer","sheet","sheik","shelf","shell","shied","shift","shine","shiny","shire","shirk","shirt","shoal","shock","shone","shook","shoot","shore","shorn","short","shout","shove","shown","showy","shrew","shrub","shrug","shuck","shunt","shush","shyly","siege","sieve","sight","sigma","silky","silly","since","sinew","singe","siren","sissy","sixth","sixty","skate","skier","skiff","skill","skimp","skirt","skulk","skull","skunk","slack","slain","slang","slant","slash","slate","slave","sleek","sleep","sleet","slept","slice","slick","slide","slime","slimy","sling","slink","sloop","slope","slosh","sloth","slump","slung","slunk","slurp","slush","slyly","smack","small","smart","smash","smear","smell","smelt","smile","smirk","smite","smith","smock","smoke","smoky","smote","snack","snail","snake","snaky","snare","snarl","sneak","sneer","snide","sniff","snipe","snoop","snore","snort","snout","snowy","snuck","snuff","soapy","sober","soggy","solar","solid","solve","sonar","sonic","sooth","sooty","sorry","sound","south","sowed","space","spade","spank","spare","spark","spasm","spawn","speak","spear","speck","speed","spell","spelt","spend","spent","sperm","spice","spicy","spied","spiel","spike","spiky","spill","spilt","spine","spiny","spire","spite","splat","split","spoil","spoke","spoof","spook","spool","spoon","spore","sport","spout","spray","spree","sprig","spume","spunk","spurn","spurt","squad","squat","squib","squid","stack","staff","stage","staid","stain","stair","stake","stale","stalk","stall","stamp","stand","stank","stare","stark","start","stash","state","stave","stead","steak","steal","steam","steed","steel","steep","steer","stein","stern","stick","stiff","still","stilt","sting","stink","stint","stock","stoic","stoke","stole","stomp","stone","stony","stood","stool","stoop","store","stork","storm","story","stout","stove","strap","straw","stray","strip","strut","stuck","study","stuff","stump","stung","stunk","stunt","style","suave","sugar","suing","suite","sulky","sully","sumac","sunny","super","surer","surge","surly","sushi","swami","swamp","swash","swath","swear","sweat","sweep","sweet","swell","swept","swift","swill","swine","swing","swirl","swish","swoon","swoop","sword","swore","sworn","swung","synod","syrup","tabby","table","taboo","tacit","tacky","taffy","taint","taken","taker","tally","talon","tamer","tango","tangy","taper","tapir","tardy","tarot","taste","tasty","tatty","taunt","tawny","teach","teary","tease","teddy","teeth","tempo","tenet","tenor","tense","tenth","tepee","tepid","terra","terse","testy","thank","theft","their","theme","there","these","theta","thick","thief","thigh","thing","think","third","thong","thorn","those","three","threw","throb","throw","thrum","thumb","thump","thyme","tiara","tibia","tidal","tiger","tight","tilde","timer","timid","tipsy","titan","tithe","title","toast","today","toddy","token","tonal","tonga","tonic","tooth","topaz","topic","torch","torso","torus","total","totem","touch","tough","towel","tower","toxic","toxin","trace","track","tract","trade","trail","train","trait","tramp","trash","trawl","tread","treat","trend","triad","trial","tribe","trice","trick","tried","tripe","trite","troll","troop","trope","trout","trove","truce","truck","truer","truly","trump","trunk","truss","trust","truth","tryst","tubal","tuber","tulip","tulle","tumor","tunic","turbo","tutor","twang","tweak","tweed","tweet","twice","twine","twirl","twist","twixt","tying","udder","ulcer","ultra","umbra","uncle","uncut","under","undid","undue","unfed","unfit","unify","union","unite","unity","unlit","unmet","unset","untie","until","unwed","unzip","upend","upper","upset","urban","urine","usage","usher","using","usual","usurp","utile","utter","vague","valet","valid","valor","value","valve","vapid","vapor","vault","vaunt","vegan","venom","venue","verge","verse","verso","verve","vicar","video","vigil","vigor","villa","vinyl","viola","viper","viral","virus","visit","visor","vista","vital","vivid","vixen","vocal","vodka","vogue","voice","voila","vomit","voter","vouch","vowel","vying","wacky","wafer","wager","wagon","waist","waive","waltz","warty","waste","watch","water","waver","waxen","weary","weave","wedge","weedy","weigh","weird","welch","welsh","whack","whale","wharf","wheat","wheel","whelp","where","which","whiff","while","whine","whiny","whirl","whisk","white","whole","whoop","whorl","whose","widen","wider","widow","width","wield","wight","willy","wimpy","wince","winch","windy","wiser","wispy","witch","witty","woken","woman","women","woody","wooer","wooly","woozy","wordy","world","worry","worse","worst","worth","would","wound","woven","wrack","wrath","wreak","wreck","wrest","wring","wrist","write","wrong","wrote","wrung","wryly","yacht","yearn","yeast","yield","yodel","yokel","young","youth","zebra","zesty","zonal"];
+
+ // Build validity set (deduped, 5-letter, lowercase)
+ var VALID = {};
+ var i, w;
+ var ans = [];
+ var seen = {};
+ for (i = 0; i < ANSWERS.length; i++) {
+ w = String(ANSWERS[i]).toLowerCase();
+ if (w.length === 5 && /^[a-z]{5}$/.test(w)) {
+ VALID[w] = 1;
+ if (!seen[w]) { seen[w] = 1; ans.push(w); }
+ }
+ }
+ for (i = 0; i < EXTRA.length; i++) {
+ w = String(EXTRA[i]).toLowerCase();
+ if (w.length === 5 && /^[a-z]{5}$/.test(w)) VALID[w] = 1;
+ }
+ var ANSWER_POOL = ans;
+
+ // ---- State ----
+ var ROWS = 6, COLS = 5;
+ var STORE_KEY = "abrams-wordle-stats";
+ var target = "", row = 0, col = 0, cur = "", over = false, busy = false;
+ var boardEl, msgEl, kbEl;
+ var tiles = []; // tiles[r][c]
+ var keyEls = {}; // letter -> key element
+ var keyState = {}; // letter -> best status 0 none, 1 x, 2 y, 3 g
+
+ function loadStats() {
+ var s = { played:0, wins:0, streak:0, max:0 };
+ try {
+ var raw = localStorage.getItem(STORE_KEY);
+ if (raw) {
+ var p = JSON.parse(raw);
+ if (p && typeof p === "object") {
+ s.played = p.played|0; s.wins = p.wins|0;
+ s.streak = p.streak|0; s.max = p.max|0;
+ }
+ }
+ } catch (e) {}
+ return s;
+ }
+ function saveStats(s) {
+ try { localStorage.setItem(STORE_KEY, JSON.stringify(s)); } catch (e) {}
+ }
+ var stats = loadStats();
+
+ function renderStats() {
+ var pct = stats.played ? Math.round(stats.wins / stats.played * 100) : 0;
+ setText("sPlayed", stats.played);
+ setText("sWin", pct);
+ setText("sStreak", stats.streak);
+ setText("sMax", stats.max);
+ setText("oPlayed", stats.played);
+ setText("oWin", pct);
+ setText("oStreak", stats.streak);
+ setText("oMax", stats.max);
+ }
+ function setText(id, v) {
+ var el = document.getElementById(id);
+ if (el) el.textContent = String(v);
+ }
+
+ function buildBoard() {
+ boardEl.innerHTML = "";
+ tiles = [];
+ for (var r = 0; r < ROWS; r++) {
+ var rowEl = document.createElement("div");
+ rowEl.className = "brow";
+ var rowTiles = [];
+ for (var c = 0; c < COLS; c++) {
+ var t = document.createElement("div");
+ t.className = "tile";
+ rowEl.appendChild(t);
+ rowTiles.push(t);
+ }
+ boardEl.appendChild(rowEl);
+ tiles.push(rowTiles);
+ }
+ }
+
+ var KB_ROWS = ["qwertyuiop", "asdfghjkl", "zxcvbnm"];
+ function buildKeyboard() {
+ kbEl.innerHTML = "";
+ keyEls = {};
+ for (var r = 0; r < KB_ROWS.length; r++) {
+ var rowEl = document.createElement("div");
+ rowEl.className = "krow";
+ if (r === 2) {
+ rowEl.appendChild(makeKey("ENTER", "enter", true));
+ }
+ var letters = KB_ROWS[r];
+ for (var c = 0; c < letters.length; c++) {
+ var ch = letters.charAt(c);
+ var k = makeKey(ch, ch, false);
+ keyEls[ch] = k;
+ rowEl.appendChild(k);
+ }
+ if (r === 2) {
+ rowEl.appendChild(makeKey("DEL", "backspace", true));
+ }
+ kbEl.appendChild(rowEl);
+ }
+ }
+ function makeKey(label, action, wide) {
+ var b = document.createElement("button");
+ b.className = "key" + (wide ? " wide" : "");
+ b.textContent = label;
+ b.setAttribute("data-action", action);
+ b.setAttribute("type", "button");
+ return b;
+ }
+
+ function newGame() {
+ target = ANSWER_POOL[Math.floor(Math.random() * ANSWER_POOL.length)];
+ row = 0; col = 0; cur = ""; over = false; busy = false;
+ keyState = {};
+ buildBoard();
+ for (var k in keyEls) {
+ if (keyEls.hasOwnProperty(k)) keyEls[k].className = "key";
+ }
+ setMsg("");
+ hideOverlay();
+ }
+
+ function setMsg(m) { if (msgEl) msgEl.textContent = m || ""; }
+
+ function flashMsg(m) {
+ setMsg(m);
+ if (flashMsg._t) clearTimeout(flashMsg._t);
+ flashMsg._t = setTimeout(function () { setMsg(""); }, 1400);
+ }
+
+ function addLetter(ch) {
+ if (over || busy) return;
+ if (cur.length >= COLS) return;
+ var t = tiles[row][cur.length];
+ t.textContent = ch;
+ t.classList.add("filled", "pop");
+ (function (tt) { setTimeout(function () { tt.classList.remove("pop"); }, 130); })(t);
+ cur += ch;
+ }
+
+ function delLetter() {
+ if (over || busy) return;
+ if (cur.length === 0) return;
+ cur = cur.slice(0, -1);
+ var t = tiles[row][cur.length];
+ t.textContent = "";
+ t.classList.remove("filled");
+ }
+
+ function shakeRow() {
+ var rowEl = boardEl.children[row];
+ if (!rowEl) return;
+ rowEl.classList.add("shake");
+ setTimeout(function () { rowEl.classList.remove("shake"); }, 420);
+ }
+
+ function scoreGuess(guess, answer) {
+ // returns array of "g"/"y"/"x"
+ var res = ["x","x","x","x","x"];
+ var counts = {};
+ var a, gch;
+ for (var j = 0; j < COLS; j++) {
+ a = answer.charAt(j);
+ counts[a] = (counts[a] || 0) + 1;
+ }
+ for (j = 0; j < COLS; j++) {
+ if (guess.charAt(j) === answer.charAt(j)) {
+ res[j] = "g";
+ counts[guess.charAt(j)]--;
+ }
+ }
+ for (j = 0; j < COLS; j++) {
+ if (res[j] === "g") continue;
+ gch = guess.charAt(j);
+ if (counts[gch] > 0) {
+ res[j] = "y";
+ counts[gch]--;
+ }
+ }
+ return res;
+ }
+
+ var RANK = { "x":1, "y":2, "g":3 };
+ function updateKey(ch, st) {
+ var prev = keyState[ch] || 0;
+ if (RANK[st] > prev) {
+ keyState[ch] = RANK[st];
+ var el = keyEls[ch];
+ if (el) {
+ el.classList.remove("g", "y", "x");
+ el.classList.add(st);
+ }
+ }
+ }
+
+ function submit() {
+ if (over || busy) return;
+ if (cur.length < COLS) { flashMsg("Not enough letters"); shakeRow(); return; }
+ var guess = cur;
+ if (!VALID[guess]) { flashMsg("Not in word list"); shakeRow(); return; }
+
+ busy = true;
+ var res = scoreGuess(guess, target);
+ var rowTiles = tiles[row];
+ var thisRow = row;
+
+ for (var c = 0; c < COLS; c++) {
+ (function (c) {
+ setTimeout(function () {
+ var t = rowTiles[c];
+ t.classList.add("flip");
+ setTimeout(function () {
+ t.classList.add(res[c]);
+ updateKey(guess.charAt(c), res[c]);
+ }, 250);
+ setTimeout(function () { t.classList.remove("flip"); }, 500);
+ }, c * 180);
+ })(c);
+ }
+
+ var total = (COLS - 1) * 180 + 520;
+ setTimeout(function () {
+ finishRow(guess, res, thisRow);
+ }, total);
+ }
+
+ function finishRow(guess, res, guessRow) {
+ var won = true;
+ for (var c = 0; c < COLS; c++) { if (res[c] !== "g") { won = false; break; } }
+
+ if (won) {
+ over = true; busy = false;
+ recordResult(true);
+ showOverlay(true, guessRow + 1);
+ return;
+ }
+
+ row++;
+ cur = "";
+ if (row >= ROWS) {
+ over = true; busy = false;
+ recordResult(false);
+ showOverlay(false, 0);
+ } else {
+ busy = false;
+ }
+ }
+
+ function recordResult(won) {
+ stats.played++;
+ if (won) {
+ stats.wins++;
+ stats.streak++;
+ if (stats.streak > stats.max) stats.max = stats.streak;
+ } else {
+ stats.streak = 0;
+ }
+ saveStats(stats);
+ renderStats();
+ }
+
+ var WIN_MSGS = ["Genius","Magnificent","Impressive","Splendid","Great","Phew"];
+ function showOverlay(won, guessNum) {
+ var ov = document.getElementById("ov");
+ setText("ovTag", won ? "You win" : "Out of guesses");
+ setText("ovTitle", won ? (WIN_MSGS[Math.min(guessNum - 1, WIN_MSGS.length - 1)] || "Nice") : "So close");
+ setText("ovWord", target);
+ setText("ovSub", won
+ ? ("Solved in " + guessNum + (guessNum === 1 ? " guess" : " guesses"))
+ : "Better luck next round");
+ renderStats();
+ ov.classList.remove("hide");
+ }
+ function hideOverlay() {
+ var ov = document.getElementById("ov");
+ ov.classList.add("hide");
+ }
+
+ // ---- Input ----
+ function handleAction(action) {
+ if (action === "enter") submit();
+ else if (action === "backspace") delLetter();
+ else if (action.length === 1 && action >= "a" && action <= "z") addLetter(action);
+ }
+
+ function onKeydown(e) {
+ if (e.ctrlKey || e.metaKey || e.altKey) return;
+ var key = e.key;
+ if (key === "Enter") { e.preventDefault(); handleAction("enter"); }
+ else if (key === "Backspace") { e.preventDefault(); handleAction("backspace"); }
+ else if (key && key.length === 1) {
+ var lc = key.toLowerCase();
+ if (lc >= "a" && lc <= "z") { e.preventDefault(); handleAction(lc); }
+ }
+ }
+
+ function onKbClick(e) {
+ var el = e.target;
+ while (el && el !== kbEl && !el.getAttribute) el = el.parentNode;
+ if (!el || el === kbEl) return;
+ var action = el.getAttribute && el.getAttribute("data-action");
+ if (action) {
+ e.preventDefault();
+ handleAction(action);
+ }
+ }
+
+ function init() {
+ boardEl = document.getElementById("board");
+ msgEl = document.getElementById("msg");
+ kbEl = document.getElementById("kb");
+
+ buildBoard();
+ buildKeyboard();
+ renderStats();
+ newGame();
+
+ document.addEventListener("keydown", onKeydown);
+ kbEl.addEventListener("click", onKbClick);
+
+ var newBtn = document.getElementById("newBtn");
+ if (newBtn) newBtn.addEventListener("click", function () { newGame(); });
+ var playBtn = document.getElementById("playBtn");
+ if (playBtn) playBtn.addEventListener("click", function () { newGame(); });
+ }
+
+ if (document.readyState === "loading") {
+ document.addEventListener("DOMContentLoaded", init);
+ } else {
+ init();
+ }
+})();
+</script>
+</body>
+</html>
← f4e6a87 Fix stale e2e: decouple cube checks from list order (deep-li
·
back to Games Agentabrams
·
games.aa: upgrade Emoji Memory to the richer free-roster bui 36c7d11 →