diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 20753fa..c783565 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -21,5 +21,5 @@ jobs:
version: 3.x
repo-token: ${{ secrets.GITHUB_TOKEN }}
- - name: Syntax-check JS
- run: task check
+ - name: Test JS
+ run: task test
diff --git a/Taskfile.yml b/Taskfile.yml
index e213f20..4c6c84b 100644
--- a/Taskfile.yml
+++ b/Taskfile.yml
@@ -24,6 +24,12 @@ tasks:
done < <(find scripts pages -name "*.js" | sort)
$all_ok && echo "All files OK" || exit 1
+ test:
+ desc: Run unit tests
+ deps: [check]
+ cmds:
+ - node --test
+
serve:
desc: Start local HTTP server (required as ES modules don't work over file://)
cmds:
@@ -32,6 +38,6 @@ tasks:
dev:
desc: Serve and open in browser
- deps: [check]
+ deps: [test]
cmds:
- task: serve
diff --git a/pages/runner/game.js b/pages/runner/game.js
new file mode 100644
index 0000000..8e5d52b
--- /dev/null
+++ b/pages/runner/game.js
@@ -0,0 +1,203 @@
+import { aabb, speedForScore, bestScore } from './mechanics.js';
+
+const HERO_W = 17; // assets/cursors/default.png natural size
+const HERO_H = 25;
+const HERO_X = 44; // fixed horizontal position of the hero
+const GROUND_Y = 150; // y of the ground line (hero stands on it)
+const GRAVITY = 0.7; // downward accel per 60fps frame
+const JUMP_V = -11; // upward velocity applied on a jump
+const OB_SIZE = 28; // drawn obstacle size (icons are 32x32)
+const SCORE_RATE = 0.25; // score gained per 60fps frame while running
+const MIN_GAP = 60; // min frames between obstacle spawns
+const MAX_GAP = 130; // max frames between obstacle spawns
+const HS_KEY = 'runner.highScore';
+
+const HERO_SRC = 'assets/cursors/default.png';
+const OBSTACLE_SRCS = [
+ 'assets/icons/folder.png',
+ 'assets/icons/computer.png',
+ 'assets/icons/desktop.png',
+ 'assets/icons/terminal.png',
+ 'assets/icons/wordpad.png',
+ 'assets/icons/mediaplayer.png',
+ 'assets/icons/ie.png',
+];
+
+function loadImage(src) {
+ const img = new Image();
+ img.src = src;
+ return img;
+}
+
+function readHighScore() {
+ try {
+ return Number(localStorage.getItem(HS_KEY)) || 0;
+ } catch {
+ return 0; // storage unavailable (private mode); session-only high score
+ }
+}
+
+function writeHighScore(value) {
+ try {
+ localStorage.setItem(HS_KEY, String(value));
+ } catch {
+ /* storage unavailable; keep session-only */
+ }
+}
+
+export class Game {
+ constructor(canvas, windowEl) {
+ this.canvas = canvas;
+ this.windowEl = windowEl;
+ this.ctx = canvas.getContext('2d');
+ if (this.ctx) this.ctx.imageSmoothingEnabled = false;
+
+ this.hero = loadImage(HERO_SRC);
+ this.obstacleImgs = OBSTACLE_SRCS.map(loadImage);
+
+ this.high = readHighScore();
+ this.lastTs = 0;
+ this.reset();
+
+ this._onKey = this._onKey.bind(this);
+ this._onPointer = this._onPointer.bind(this);
+ this._frame = this._frame.bind(this);
+ }
+
+ reset() {
+ this.state = 'ready'; // 'ready' | 'running' | 'over'
+ this.heroY = GROUND_Y - HERO_H;
+ this.heroVy = 0;
+ this.onGround = true;
+ this.obstacles = [];
+ this.spawnTimer = MAX_GAP;
+ this.score = 0;
+ }
+
+ start() {
+ document.addEventListener('keydown', this._onKey);
+ this.canvas.addEventListener('pointerdown', this._onPointer);
+ requestAnimationFrame(this._frame);
+ }
+
+ // Jump / start / restart depending on current state.
+ _action() {
+ if (this.state === 'ready') {
+ this.state = 'running';
+ } else if (this.state === 'over') {
+ this.reset();
+ this.state = 'running';
+ } else if (this.onGround) {
+ this.heroVy = JUMP_V;
+ this.onGround = false;
+ }
+ }
+
+ _onKey(e) {
+ if (e.key !== ' ' && e.key !== 'ArrowUp') return;
+ // Only react when the runner window is focused, so we never steal keys from
+ // other windows (e.g. the terminal).
+ if (!this.windowEl.classList.contains('focused')) return;
+ e.preventDefault();
+ this._action();
+ }
+
+ _onPointer(e) {
+ e.preventDefault();
+ this._action();
+ }
+
+ _frame(ts) {
+ requestAnimationFrame(this._frame);
+
+ // Pause cleanly when the window is closed/hidden/minimized or the tab is
+ // backgrounded. The module runs only once, so this is how reopening the
+ // window via the Konami code resumes the same loop.
+ if (this.canvas.offsetParent === null || document.hidden) {
+ this.lastTs = ts;
+ return;
+ }
+
+ const dt = this.lastTs ? ts - this.lastTs : 1000 / 60;
+ this.lastTs = ts;
+ const f = Math.min(dt / (1000 / 60), 2); // frame factor, capped vs tunneling
+
+ if (this.state === 'running') this._update(f);
+ this._draw();
+ }
+
+ _update(f) {
+ // Hero physics
+ this.heroVy += GRAVITY * f;
+ this.heroY += this.heroVy * f;
+ const floor = GROUND_Y - HERO_H;
+ if (this.heroY >= floor) {
+ this.heroY = floor;
+ this.heroVy = 0;
+ this.onGround = true;
+ }
+
+ // Obstacles: spawn, move, cull
+ const speed = speedForScore(this.score);
+ this.spawnTimer -= f;
+ if (this.spawnTimer <= 0) {
+ const img = this.obstacleImgs[Math.floor(Math.random() * this.obstacleImgs.length)];
+ this.obstacles.push({ x: this.canvas.width, y: GROUND_Y - OB_SIZE, w: OB_SIZE, h: OB_SIZE, img });
+ this.spawnTimer = MIN_GAP + Math.random() * (MAX_GAP - MIN_GAP);
+ }
+ for (const o of this.obstacles) o.x -= speed * f;
+ this.obstacles = this.obstacles.filter((o) => o.x + o.w > 0);
+
+ // Score (incremented before the collision check so the value saved as the
+ // high score matches what is displayed on the death frame)
+ this.score += SCORE_RATE * f;
+
+ // Collision -> game over
+ const heroBox = { x: HERO_X, y: this.heroY, w: HERO_W, h: HERO_H };
+ if (this.obstacles.some((o) => aabb(heroBox, o))) {
+ this.state = 'over';
+ this.high = bestScore(this.high, Math.floor(this.score));
+ writeHighScore(this.high);
+ }
+ }
+
+ _draw() {
+ const { ctx, canvas } = this;
+ if (!ctx) return;
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
+
+ // Ground line
+ ctx.strokeStyle = '#2b2b2b';
+ ctx.lineWidth = 2;
+ ctx.beginPath();
+ ctx.moveTo(0, GROUND_Y);
+ ctx.lineTo(canvas.width, GROUND_Y);
+ ctx.stroke();
+
+ // Obstacles
+ for (const o of this.obstacles) {
+ if (o.img.complete) ctx.drawImage(o.img, Math.round(o.x), o.y, o.w, o.h);
+ }
+
+ // Hero
+ if (this.hero.complete) ctx.drawImage(this.hero, HERO_X, Math.round(this.heroY), HERO_W, HERO_H);
+
+ // Score / high score (top-right)
+ ctx.fillStyle = '#2b2b2b';
+ ctx.font = '14px "MononokiNerdFont", monospace';
+ ctx.textAlign = 'right';
+ const hi = String(this.high).padStart(5, '0');
+ const cur = String(Math.floor(this.score)).padStart(5, '0');
+ ctx.fillText(`HI ${hi} ${cur}`, canvas.width - 8, 22);
+
+ // Ready / over overlay
+ if (this.state !== 'running') {
+ ctx.textAlign = 'center';
+ const msg = this.state === 'ready'
+ ? 'Press Space to start'
+ : 'Game Over - Space to retry';
+ ctx.fillText(msg, canvas.width / 2, GROUND_Y / 2);
+ ctx.textAlign = 'right';
+ }
+ }
+}
diff --git a/pages/runner/index.html b/pages/runner/index.html
new file mode 100644
index 0000000..2d7c3c6
--- /dev/null
+++ b/pages/runner/index.html
@@ -0,0 +1,2 @@
+
+
diff --git a/pages/runner/mechanics.js b/pages/runner/mechanics.js
new file mode 100644
index 0000000..3892458
--- /dev/null
+++ b/pages/runner/mechanics.js
@@ -0,0 +1,21 @@
+// Pure game mechanics with no DOM dependency, unit-tested under `node --test`.
+
+// Axis-aligned bounding-box overlap. Boxes are { x, y, w, h } with x,y at the
+// top-left. Edge-only contact does not count as a collision.
+export function aabb(a, b) {
+ return a.x < b.x + b.w &&
+ a.x + a.w > b.x &&
+ a.y < b.y + b.h &&
+ a.y + a.h > b.y;
+}
+
+// World scroll speed (pixels per 60fps frame) as a function of score: starts at
+// `base`, grows linearly, capped at `max` so the game stays playable.
+export function speedForScore(score, base = 4, perPoint = 0.0025, max = 12) {
+ return Math.min(max, base + score * perPoint);
+}
+
+// The larger of two scores; used to track the high score.
+export function bestScore(a, b) {
+ return Math.max(a, b);
+}
diff --git a/pages/runner/runner.css b/pages/runner/runner.css
new file mode 100644
index 0000000..9fc67e1
--- /dev/null
+++ b/pages/runner/runner.css
@@ -0,0 +1,36 @@
+.runner-canvas {
+ display: block;
+ width: 100%;
+ height: auto;
+ image-rendering: pixelated;
+ background: #6ba4ff; /* retro sky */
+ touch-action: manipulation; /* avoid double-tap-zoom delaying tap-to-jump */
+}
+
+/* Mobile portrait: the runner window is fullscreen and taller than it is wide,
+ which squashes the 600x180 landscape game into a thin strip. Rotate the
+ canvas 90deg so the game runs along the long (vertical) screen axis. The game
+ reads no pointer coordinates (tap anywhere = jump; keyboard is
+ document-level), so this visual-only rotation needs no input remapping. The
+ 640px breakpoint matches the site's mobile boundary in scripts/viewport.js. */
+@media (max-width: 640px) and (orientation: portrait) {
+ /* Become the positioning context and clip the rotated canvas's (unrotated)
+ bounding box so it cannot introduce scrollbars. */
+ .win98-content:has(> canvas.runner-canvas) {
+ position: relative;
+ overflow: hidden;
+ }
+
+ .runner-canvas {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ /* Pre-rotation width becomes the on-screen height: fill the content height
+ (viewport minus the ~48px mobile taskbar, ~28px title bar, and chrome).
+ The 300vw cap keeps the on-screen width (= 0.3 x this) under ~90vw on
+ very narrow screens. height:auto preserves the 10:3 aspect ratio. */
+ width: min(calc(100vh - 88px), 300vw);
+ height: auto;
+ transform: translate(-50%, -50%) rotate(90deg);
+ }
+}
diff --git a/pages/runner/runner.js b/pages/runner/runner.js
new file mode 100644
index 0000000..2e025c3
--- /dev/null
+++ b/pages/runner/runner.js
@@ -0,0 +1,17 @@
+import { Game } from './game.js';
+
+const root = document.querySelector('.win98-window[data-window-id="runner"]');
+const content = root?.querySelector('.win98-content');
+
+if (content) {
+ const canvas = document.createElement('canvas');
+ canvas.className = 'runner-canvas';
+ canvas.width = 600;
+ canvas.height = 180;
+ content.appendChild(canvas);
+
+ if (canvas.getContext('2d')) {
+ const game = new Game(canvas, root);
+ game.start();
+ }
+}
diff --git a/scripts/konami.js b/scripts/konami.js
new file mode 100644
index 0000000..51a0513
--- /dev/null
+++ b/scripts/konami.js
@@ -0,0 +1,36 @@
+// Konami code detector. The pure matcher (createKonamiMatcher) carries no DOM
+// dependency so it is unit-testable under `node --test`; initKonami is the thin
+// browser wrapper that feeds real keydown events into it.
+
+export const KONAMI_SEQUENCE = [
+ 'ArrowUp', 'ArrowUp', 'ArrowDown', 'ArrowDown',
+ 'ArrowLeft', 'ArrowRight', 'ArrowLeft', 'ArrowRight', 'b', 'a',
+];
+
+// Single-character keys compare case-insensitively; named keys (ArrowUp, ...)
+// are left as-is.
+export function normalizeKey(key) {
+ return key.length === 1 ? key.toLowerCase() : key;
+}
+
+// Returns a push(key) function tracking a rolling buffer of the last
+// sequence.length keys; calls onMatch() whenever the buffer equals the sequence.
+export function createKonamiMatcher(onMatch, sequence = KONAMI_SEQUENCE) {
+ const buf = [];
+ return function push(key) {
+ buf.push(normalizeKey(key));
+ if (buf.length > sequence.length) buf.shift();
+ if (buf.length === sequence.length && sequence.every((k, i) => k === buf[i])) {
+ buf.length = 0;
+ onMatch();
+ }
+ };
+}
+
+// Attaches a single passive, bubble-phase keydown listener. Passive => it never
+// calls preventDefault, so normal typing (including the terminal's ArrowUp
+// history) is untouched; it only observes.
+export function initKonami(onTrigger) {
+ const push = createKonamiMatcher(onTrigger);
+ document.addEventListener('keydown', (e) => push(e.key), { passive: true });
+}
diff --git a/scripts/main.js b/scripts/main.js
index 8ec9d07..1b3ba0e 100644
--- a/scripts/main.js
+++ b/scripts/main.js
@@ -1,6 +1,7 @@
import { WINDOWS } from './windows.js';
import { extractAssets } from './dom.js';
import { isMobile, onViewportChange } from './viewport.js';
+import { initKonami } from './konami.js';
// Exposed globally so page scripts (e.g. terminal.js) can call windowManager.open()
window.windowManager = null;
@@ -14,6 +15,15 @@ document.addEventListener('DOMContentLoaded', () => {
iconGrid.render();
taskbar.render();
clock.start();
+
+ initKonami(() => window.windowManager.openDynamic({
+ id: 'runner',
+ title: 'Cursor Runner',
+ icon: 'assets/cursors/default.png',
+ width: 640,
+ height: 280,
+ src: 'pages/runner/index.html',
+ }));
});
// === WindowManager ============================================================
diff --git a/tests/konami.test.mjs b/tests/konami.test.mjs
new file mode 100644
index 0000000..5bda51d
--- /dev/null
+++ b/tests/konami.test.mjs
@@ -0,0 +1,57 @@
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { createKonamiMatcher, normalizeKey, KONAMI_SEQUENCE } from '../scripts/konami.js';
+
+test('normalizeKey lowercases single characters and preserves named keys', () => {
+ assert.equal(normalizeKey('B'), 'b');
+ assert.equal(normalizeKey('a'), 'a');
+ assert.equal(normalizeKey('ArrowUp'), 'ArrowUp');
+});
+
+test('fires onMatch exactly once for the full sequence', () => {
+ let count = 0;
+ const push = createKonamiMatcher(() => { count += 1; });
+ for (const key of KONAMI_SEQUENCE) push(key);
+ assert.equal(count, 1);
+});
+
+test('accepts uppercase B/A via normalization', () => {
+ let count = 0;
+ const push = createKonamiMatcher(() => { count += 1; });
+ const keys = ['ArrowUp', 'ArrowUp', 'ArrowDown', 'ArrowDown',
+ 'ArrowLeft', 'ArrowRight', 'ArrowLeft', 'ArrowRight', 'B', 'A'];
+ for (const key of keys) push(key);
+ assert.equal(count, 1);
+});
+
+test('does not fire on a wrong/incomplete sequence', () => {
+ let count = 0;
+ const push = createKonamiMatcher(() => { count += 1; });
+ for (const key of ['ArrowUp', 'ArrowUp', 'ArrowDown', 'ArrowDown', 'b', 'a']) push(key);
+ assert.equal(count, 0);
+});
+
+test('matches when preceded by noise (rolling buffer)', () => {
+ let count = 0;
+ const push = createKonamiMatcher(() => { count += 1; });
+ for (const key of ['x', 'ArrowDown', 'ArrowUp']) push(key);
+ for (const key of KONAMI_SEQUENCE) push(key);
+ assert.equal(count, 1);
+});
+
+test('does not fire on a near-miss (one pair swapped)', () => {
+ let count = 0;
+ const push = createKonamiMatcher(() => { count += 1; });
+ const wrong = ['ArrowUp', 'ArrowUp', 'ArrowDown', 'ArrowDown',
+ 'ArrowRight', 'ArrowLeft', 'ArrowRight', 'ArrowLeft', 'b', 'a'];
+ for (const key of wrong) push(key);
+ assert.equal(count, 0);
+});
+
+test('fires again when the sequence is entered a second time', () => {
+ let count = 0;
+ const push = createKonamiMatcher(() => { count += 1; });
+ for (const key of KONAMI_SEQUENCE) push(key);
+ for (const key of KONAMI_SEQUENCE) push(key);
+ assert.equal(count, 2);
+});
diff --git a/tests/runner-mechanics.test.mjs b/tests/runner-mechanics.test.mjs
new file mode 100644
index 0000000..884e170
--- /dev/null
+++ b/tests/runner-mechanics.test.mjs
@@ -0,0 +1,32 @@
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { aabb, speedForScore, bestScore } from '../pages/runner/mechanics.js';
+
+test('aabb detects overlapping boxes', () => {
+ assert.equal(aabb({ x: 0, y: 0, w: 10, h: 10 }, { x: 5, y: 5, w: 10, h: 10 }), true);
+});
+
+test('aabb returns false for separated boxes', () => {
+ assert.equal(aabb({ x: 0, y: 0, w: 10, h: 10 }, { x: 20, y: 0, w: 10, h: 10 }), false);
+});
+
+test('aabb treats edge-only contact as no collision', () => {
+ assert.equal(aabb({ x: 0, y: 0, w: 10, h: 10 }, { x: 10, y: 0, w: 10, h: 10 }), false);
+});
+
+test('speedForScore returns the base speed at score 0', () => {
+ assert.equal(speedForScore(0), 4);
+});
+
+test('speedForScore increases with score but is capped', () => {
+ assert.ok(speedForScore(1000) > speedForScore(0));
+ assert.equal(speedForScore(400), 5); // 4 + 400 * 0.0025 = 5 (exact slope)
+ assert.ok(speedForScore(3199) < 12); // just below the cap crossover
+ assert.equal(speedForScore(3200), 12); // cap reached exactly at crossover
+ assert.equal(speedForScore(1_000_000), 12); // far past the cap
+});
+
+test('bestScore returns the larger value', () => {
+ assert.equal(bestScore(3, 7), 7);
+ assert.equal(bestScore(9, 2), 9);
+});