← Memory Match history

feat(examples): implement five standalone browser games and apps

Actual project-only Git commit. Email addresses are redacted; source bytes and recorded timestamps are preserved. This is a code record, not human acceptance.

commit 44f29346d660a4e1469eab331763628124a112d2
Author:     0xLeif <redacted@invalid>
AuthorDate: Sun Nov 8 19:35:16 2026 -0700
Commit:     0xLeif <redacted@invalid>
CommitDate: Sun Nov 8 19:35:16 2026 -0700

    feat(examples): implement five standalone browser games and apps

diff --git a/apps/memory-match/README.md b/apps/memory-match/README.md
new file mode 100644
index 0000000..4cab380
--- /dev/null
+++ b/apps/memory-match/README.md
@@ -0,0 +1,29 @@
+# Memory Match
+
+A standalone, local browser example. Choose two cards. Find all six pairs at your own pace.
+
+## Run from this directory
+
+Prerequisites: Bun 1.4.0 (recorded version). TypeScript 5.9.3 is pinned as a development-only checker. There are no runtime dependencies. The initial install may need registry access; the running app uses no network APIs.
+
+```sh
+bun install --frozen-lockfile
+bun run test
+bun run typecheck
+bun run build
+bun run start
+```
+
+Open http://127.0.0.1:4352/. Stop the server with Ctrl+C. PORT can select another local port. The build emits dist/index.html, styles.css and ui.js; relative asset links also support /examples/memory-match/play/. No dependency on another app or the gallery source is required.
+
+## Controls and limits
+
+Use Tab or Shift+Tab to reach a card and Enter or Space to reveal it. Matching pairs stay revealed. A mismatch stays visible for one second; a third card cannot open until it turns back. Card names reveal the symbol only after opening. New game clears all progress. Nothing is saved.
+
+Native buttons, visible focus and a separate polite status region support keyboard use. Countdown digits do not announce every tick. No external artwork, accounts, analytics, persistence, audio, import or automatic downloads. Reduced-motion styling disables decoration movement. Time-based code uses a monotonic browser clock while the page is open; no cross-reload or cross-device timer promise is made.
+
+## Source and checks
+
+src/model.ts contains pure immutable transitions with injected time/randomness. src/ui.ts connects them to native controls. tests/model.test.ts preserves the committed first-red assertions. Bun test validates models; TypeScript checks src only in strict ES2022/DOM mode. Build/serve scripts use Bun and are outside that browser-source typecheck. A green model suite does not establish browser accessibility or human acceptance; root owns rendered verification and actual build-story evidence.
+
+Newly authored source is MIT licensed; see LICENSE. No external art is included. Generated dist, node_modules and private/raw execution evidence are not needed as source inputs. Source ZIP packaging and exact fresh-extraction qualification are handled by the gallery owner.
diff --git a/apps/memory-match/build.ts b/apps/memory-match/build.ts
new file mode 100644
index 0000000..2a9f2c7
--- /dev/null
+++ b/apps/memory-match/build.ts
@@ -0,0 +1,4 @@
+const result = await Bun.build({ entrypoints: [new URL("./src/ui.ts", import.meta.url).pathname], outdir: new URL("./dist", import.meta.url).pathname, target: "browser", minify: false });
+if (!result.success) { for (const log of result.logs) console.error(log); throw new Error("Browser build failed"); }
+for (const name of ["index.html", "styles.css"]) await Bun.write(new URL(`./dist/${name}`, import.meta.url), Bun.file(new URL(`./${name}`, import.meta.url)));
+console.log("Built standalone dist/index.html, dist/styles.css and dist/ui.js");
diff --git a/apps/memory-match/bun.lock b/apps/memory-match/bun.lock
new file mode 100644
index 0000000..4b650a1
--- /dev/null
+++ b/apps/memory-match/bun.lock
@@ -0,0 +1,15 @@
+{
+  "lockfileVersion": 2,
+  "configVersion": 1,
+  "workspaces": {
+    "": {
+      "name": "memory-match",
+      "devDependencies": {
+        "typescript": "5.9.3",
+      },
+    },
+  },
+  "packages": {
+    "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
+  }
+}
diff --git a/apps/memory-match/index.html b/apps/memory-match/index.html
new file mode 100644
index 0000000..8c55fd2
--- /dev/null
+++ b/apps/memory-match/index.html
@@ -0,0 +1,2 @@
+<!doctype html>
+<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Memory Match · chough example</title><link rel="stylesheet" href="./styles.css"><script type="module" src="./ui.js"></script></head><body><main><header><h1>Memory Match</h1><p>chough / small browser examples</p></header><p>Choose two cards. Find all six pairs at your own pace.</p><noscript><p>This example needs JavaScript to play or run. The instructions below remain available. Run the source with Bun using its README.</p></noscript><div class="frame"><div class="rail"><span class="dots" aria-hidden="true"><i></i><i></i><i></i></span><span>Memory Match</span></div><div class="memory-top"><p>Look. Remember.<br>Find the pair.</p><p><strong id="pairs">0</strong> / 6 pairs</p></div><div id="board" class="memory-board" role="group" aria-label="Twelve memory cards"></div></div><div class="controls"><button id="reset" type="button" disabled>New game</button></div><p class="status" id="status" role="status" aria-live="polite">Choose two cards. Find all six pairs at your own pace.</p><p id="error" class="error" role="alert" hidden></p><details><summary>How to use this example</summary><p>Use Tab or Shift+Tab to reach a card and Enter or Space to reveal it. Matching pairs stay revealed. A mismatch stays visible for one second; a third card cannot open until it turns back. Card names reveal the symbol only after opening. New game clears all progress. Nothing is saved.</p><p>Local example. No accounts, analytics, storage or network APIs. Interface and code are newly authored under the MIT license. Human implementation acceptance is separate from running it.</p></details><footer>Built as a small chough example. Your coding agent writes code; people review the result.</footer></main></body></html>
diff --git a/apps/memory-match/serve.ts b/apps/memory-match/serve.ts
new file mode 100644
index 0000000..2c7202d
--- /dev/null
+++ b/apps/memory-match/serve.ts
@@ -0,0 +1,15 @@
+const port = Number(Bun.env.PORT ?? 4352);
+if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("PORT must be between 1 and 65535");
+const server = Bun.serve({
+    hostname: "127.0.0.1", port,
+    async fetch(request) {
+        if (request.method !== "GET" && request.method !== "HEAD") return new Response("Method not allowed", { status: 405, headers: { Allow: "GET, HEAD" } });
+        const path = new URL(request.url).pathname;
+        const name = path.endsWith("/") ? "index.html" : path.split("/").pop();
+        if (!name || !["index.html", "styles.css", "ui.js"].includes(name)) return new Response("Not found", { status: 404 });
+        const file = Bun.file(new URL(`./dist/${name}`, import.meta.url));
+        if (!await file.exists()) return new Response("Build the app first with bun run build", { status: 503 });
+        return new Response(request.method === "HEAD" ? null : file, { headers: { "Content-Type": file.type, "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff" } });
+    }
+});
+console.log(`Memory Match: ${server.url}`);
diff --git a/apps/memory-match/src/model.ts b/apps/memory-match/src/model.ts
index c2f07b7..3624b21 100644
--- a/apps/memory-match/src/model.ts
+++ b/apps/memory-match/src/model.ts
@@ -1,4 +1,4 @@
-// Red-phase API stubs. Root commits these failure inputs before implementation.
+// Pure immutable domain model. Time and randomness are supplied by the caller.
 export const PAIR_COUNT = 6;
 export const MISMATCH_DELAY_MS = 1_000;
 export type RandomSource = () => number;
@@ -15,15 +15,34 @@ export interface MemoryState {
     readonly firstCardId: number | null;
     readonly mismatchUntilMs: number | null;
 }
-export function createGame(random: RandomSource): MemoryState {
-    throw new Error("Not implemented: createGame");
+
+function validTime(nowMs: number): void {
+    if (!Number.isFinite(nowMs) || nowMs < 0) throw new RangeError("Time must be finite and nonnegative");
 }
-export function revealCard(state: MemoryState, cardId: number, nowMs: number): MemoryState {
-    throw new Error("Not implemented: revealCard");
+
+export function createGame(random: RandomSource): MemoryState {
+    const cards: MemoryCard[] = Array.from({ length: PAIR_COUNT * 2 }, (_, id) => ({ id, pairId: Math.floor(id / 2), face: "down" }));
+    for (let index = cards.length - 1; index > 0; index -= 1) {
+        const value = random();
+        if (!Number.isFinite(value) || value < 0 || value >= 1) throw new RangeError("Random sample must be in [0, 1)");
+        const other = Math.floor(value * (index + 1));
+        [cards[index], cards[other]] = [cards[other], cards[index]];
+    }
+    return { phase: "playing", cards, firstCardId: null, mismatchUntilMs: null };
 }
 export function advanceGame(state: MemoryState, nowMs: number): MemoryState {
-    throw new Error("Not implemented: advanceGame");
+    validTime(nowMs);
+    if (state.mismatchUntilMs === null || nowMs < state.mismatchUntilMs) return state;
+    return { ...state, cards: state.cards.map(card => card.face === "up" ? { ...card, face: "down" } : card), mismatchUntilMs: null, firstCardId: null };
 }
-export function resetGame(random: RandomSource): MemoryState {
-    throw new Error("Not implemented: resetGame");
+export function revealCard(state: MemoryState, cardId: number, nowMs: number): MemoryState {
+    const current = advanceGame(state, nowMs);
+    const selected = current.cards.find(card => card.id === cardId);
+    if (current.phase === "complete" || current.mismatchUntilMs !== null || !selected || selected.face !== "down") return current;
+    const first = current.cards.find(card => card.id === current.firstCardId);
+    if (!first) return { ...current, firstCardId: cardId, cards: current.cards.map(card => card.id === cardId ? { ...card, face: "up" } : card) };
+    const match = first.pairId === selected.pairId;
+    const cards = current.cards.map(card => card.id === cardId || card.id === first.id ? { ...card, face: match ? "matched" as const : "up" as const } : card);
+    return { phase: cards.every(card => card.face === "matched") ? "complete" : "playing", cards, firstCardId: null, mismatchUntilMs: match ? null : nowMs + MISMATCH_DELAY_MS };
 }
+export function resetGame(random: RandomSource): MemoryState { return createGame(random); }
diff --git a/apps/memory-match/src/ui.ts b/apps/memory-match/src/ui.ts
new file mode 100644
index 0000000..564f21a
--- /dev/null
+++ b/apps/memory-match/src/ui.ts
@@ -0,0 +1,39 @@
+import { createGame, revealCard, advanceGame, resetGame } from "./model";
+function get<Value extends HTMLElement>(selector: string): Value { const node = document.querySelector<Value>(selector); if (!node) throw new Error(`Missing ${selector}`); return node; }
+function boot(): void {
+    const board = get<HTMLDivElement>("#board"), reset = get<HTMLButtonElement>("#reset"), status = get<HTMLElement>("#status"), count = get<HTMLElement>("#pairs");
+    const shapes = ["●", "◆", "▲", "■", "✳", "+"], names = ["circle", "diamond", "triangle", "square", "star", "plus"];
+    let state = createGame(Math.random); let timeout: number | undefined;
+    const buttons = new Map<number, HTMLButtonElement>();
+    function render(): void {
+        count.textContent = String(state.cards.filter(card => card.face === "matched").length / 2);
+        state.cards.forEach((card, index) => {
+            const button = buttons.get(card.id); if (!button) return;
+            button.textContent = card.face === "down" ? "+" : shapes[card.pairId];
+            button.dataset.face = card.face;
+            button.setAttribute("aria-label", `Card ${index + 1}, ${card.face === "down" ? "face down" : `${names[card.pairId]}, ${card.face === "matched" ? "matched" : "revealed"}`}`);
+            button.setAttribute("aria-disabled", String(card.face !== "down" || state.mismatchUntilMs !== null || state.phase === "complete"));
+        });
+        window.clearTimeout(timeout);
+        if (state.mismatchUntilMs !== null) timeout = window.setTimeout(() => { const old = state; state = advanceGame(state, performance.now()); render(); if (state !== old) status.textContent = "Cards turned back. Choose another pair."; }, Math.max(1, state.mismatchUntilMs - performance.now()));
+    }
+    function mount(): void {
+        board.replaceChildren(); buttons.clear();
+        state.cards.forEach(card => {
+            const button = document.createElement("button"); button.type = "button"; button.className = "tile";
+            button.addEventListener("click", () => {
+                const before = state; state = revealCard(state, card.id, performance.now()); render();
+                if (state === before) return;
+                const selected = state.cards.find(item => item.id === card.id);
+                if (state.phase === "complete") { status.textContent = "All six pairs matched. Well done. Start a new game when ready."; reset.focus(); }
+                else if (state.mismatchUntilMs !== null) status.textContent = "No match. Both cards stay visible for one second. Please wait.";
+                else if (selected?.face === "matched") status.textContent = `A pair matched. ${count.textContent} of six pairs found.`;
+                else if (selected) status.textContent = `${names[selected.pairId]} revealed. Choose a second card.`;
+            });
+            buttons.set(card.id, button); board.append(button);
+        }); render();
+    }
+    reset.addEventListener("click", () => { window.clearTimeout(timeout); state = resetGame(Math.random); mount(); status.textContent = "New game. Six pairs to find."; board.querySelector<HTMLButtonElement>("button")?.focus(); });
+    reset.disabled = false; mount();
+}
+try { boot(); } catch (error: unknown) { const node = document.getElementById("error"); if (node) { node.hidden = false; node.textContent = error instanceof Error ? error.message : "The game could not start. Reload to try again."; } }
diff --git a/apps/memory-match/styles.css b/apps/memory-match/styles.css
new file mode 100644
index 0000000..b344ea9
--- /dev/null
+++ b/apps/memory-match/styles.css
@@ -0,0 +1,2 @@
+*{box-sizing:border-box} :root{color-scheme:dark;font-family:"Schibsted Grotesk","Helvetica Neue",system-ui,sans-serif;background:#131619;color:#f4f3ef}body{margin:0;padding:clamp(16px,4vw,48px)}main{max-width:800px;margin:auto}header{display:flex;justify-content:space-between;align-items:center;gap:16px;margin-bottom:24px}header p{font-size:12px;color:#c8c6be}h1{font-size:clamp(30px,6vw,48px);letter-spacing:-.05em;line-height:1.05;margin:0}p{line-height:1.7}button{font:inherit;cursor:pointer;min-height:48px;padding:10px 20px;border:1px solid #a3a199;border-radius:6px;background:#272c31;color:#f4f3ef}button:disabled{cursor:default;opacity:.55}button:focus-visible,summary:focus-visible,a:focus-visible{outline:3px solid #63d9c7;outline-offset:5px}button[aria-disabled=true]{cursor:default}.frame{border:1px solid #45494c;border-radius:12px;overflow:hidden}.rail{display:flex;align-items:center;gap:12px;padding:10px 16px;background:#1b1f23;color:#c8c6be;font:12px ui-monospace,monospace}.dots{display:flex;gap:5px}.dots i{display:block;width:7px;height:7px;border-radius:50%;background:#a3a199}.controls{display:flex;flex-wrap:wrap;gap:12px;margin-block:20px}.status{min-height:3.4em;color:#c8c6be;margin-block:12px}details{border-top:1px solid #45494c;padding-block:12px}summary{cursor:pointer;min-height:44px;padding-block:10px}details p{color:#c8c6be;font-size:14px}footer{font-size:12px;color:#a3a199;margin-top:24px}.error{padding:16px;border:1px solid #f08a7d;color:#f08a7d}[hidden]{display:none!important}noscript p{padding:16px;border:1px solid #e0b05a} @media(max-width:420px){header{align-items:flex-start;flex-direction:column;gap:8px}.controls button{flex:1}body{padding:16px}}@media(prefers-reduced-motion:reduce){*,*::before,*::after{animation:none!important;transition:none!important;scroll-behavior:auto!important}}
+.memory-top{display:flex;align-items:center;justify-content:space-between;gap:20px;padding:24px;background:#6bb6e0;color:#15181b}.memory-top p{margin:0;font-size:clamp(18px,4vw,28px);line-height:1.15;letter-spacing:-.035em}.memory-top p:last-child{font-size:16px}.memory-board{display:grid;grid-template-columns:repeat(6,minmax(0,1fr));gap:12px;background:#6bb6e0;padding:0 24px 28px}.tile{min-width:0;min-height:90px;background:#569bc3;color:#15181b;border:1px solid #275779;border-radius:8px;font-size:36px;padding:8px}.tile[data-face=up]{background:#faf9f6}.tile[data-face=matched]{background:#c6e4d7;border:2px solid #265443}.tile:focus-visible{outline:3px solid #15181b;outline-offset:3px}@media(max-width:600px){.memory-board{grid-template-columns:repeat(4,minmax(0,1fr));gap:10px;padding:0 16px 24px}.memory-top{padding:24px 16px}.tile{min-height:68px}}