diff --git a/README.md b/README.md
index 9021305..5c7b42d 100644
--- a/README.md
+++ b/README.md
@@ -50,6 +50,7 @@ or miss one that does. A test fails the build when it drifts.
| `moshcode doh` | hosting | run the DNS-over-HTTPS resolver |
| `moshcode site`
`serve` | hosting | install web-server config for a Moshpit name |
| `moshcode template`
`templates` | hosting | scaffold a stack for a Moshpit-hosted service |
+| `moshcode games`
`game` `arcade` | arcade | the moshcode arcade — six games, no menus |
| `moshcode pwd`
`where` | system | show the current directory and git context |
| `moshcode engines` | engines | list engines and installation status |
| `moshcode tools` | tools | list workflow tools and installation status |
@@ -581,6 +582,47 @@ event, and publishes it to the displayed relays. Both flows leave the final
confirmation in the browser. If the pit is remote or headless, `/post` prints
the composer URL instead.
+## The arcade (`/games`)
+
+Six games, in the pit or straight from a shell. There are no menus, no options
+screens and no difficulty prompts — `/games tetris` is already playing.
+
+```sh
+moshcode games # the cabinet (pit: /games)
+moshcode games tetris # play one (pit: /games tetris)
+moshcode games --json # the roster, for a machine
+```
+
+```
+ TETRIS score 1200 · lines 12
+ ┌────────────────────────────────┐
+ │ · · · · ████· · · · NEXT │
+ │ · · · · ████· · · · │
+ │ · · · · · · · · · · ████████ │
+ │ · · · · · · · · · · │
+ │ · · · ████· · · · · LVL 2 │
+ │ ████████████· ██████ │
+ └────────────────────────────────┘
+ ← → move · ↑ rotate · ↓ drop one · space slam · q quit
+```
+
+| game | |
+|---|---|
+| `tetris` | stack the bricks, clear the lines, outrun gravity |
+| `snake` | eat, grow, and try not to eat yourself |
+| `pacman` | eat the dots, dodge the ghosts, `✳` makes them edible |
+| `tictactoe` | three in a row against an opponent that cannot be beaten |
+| `chess` | full rules — castling, en passant, promotion — and it plays back |
+| `hangman` | six wrong letters and you are done for |
+
+Every one of them works the same way: arrows move, `q` quits, `r` starts
+another, and the controls are written along the bottom of the game itself. Each
+draws in place rather than on the alternate screen, so the board you finished on
+stays in your scrollback.
+
+Playing needs a real terminal, because they read single keypresses — `moshcode
+games` on its own lists them anywhere, including a pipe.
+
## Settings sync (`/save` and `/load`)
Your pit becomes yours by accretion — a dozen aliases, herd rules you tuned until
diff --git a/bin/moshcode.mjs b/bin/moshcode.mjs
index f4f48e4..456721b 100755
--- a/bin/moshcode.mjs
+++ b/bin/moshcode.mjs
@@ -22,6 +22,7 @@ import { describeUninstall, uninstallPlan } from "../src/uninstall.mjs";
import { mcpCommand, pluginCommand, skillCommand } from "../src/integrations.mjs";
import { stocksCommand } from "../src/advisor.mjs";
import { cryptoCommand } from "../src/crypto.mjs";
+import { gamesCommand } from "../src/games.mjs";
import { canOpenBrowser, openBrowser } from "../src/open-url.mjs";
import { locate, tilde } from "../src/pwd.mjs";
import { createPrd, listPrds, authoringPrompt } from "../src/prd.mjs";
@@ -398,6 +399,11 @@ async function main() {
if (code) process.exitCode = code;
return;
}
+ if (cmd === "games" || cmd === "game" || cmd === "arcade") {
+ const code = await gamesCommand(rest);
+ if (code) process.exitCode = code;
+ return;
+ }
if (cmd === "console") {
const code = await consoleCommand(rest);
if (code) process.exitCode = code;
diff --git a/src/cli-schema.mjs b/src/cli-schema.mjs
index 81d612f..72ec2b1 100644
--- a/src/cli-schema.mjs
+++ b/src/cli-schema.mjs
@@ -24,6 +24,7 @@ export const COMMAND_GROUPS = [
{ key: "tools", title: "tools" },
{ key: "extend", title: "extend" },
{ key: "script", title: "script" },
+ { key: "arcade", title: "arcade" },
{ key: "account", title: "account" },
{ key: "hosting", title: "hosting" },
{ key: "system", title: "system" },
@@ -392,6 +393,27 @@ export const CORE_CLI_COMMANDS = [
seeAlso: ["site"],
},
{ name: "templates", aliasOf: "template", description: "alias for template" },
+ {
+ name: "games",
+ group: "arcade",
+ description: "the moshcode arcade — six games, no menus",
+ synopsis: [
+ ["moshcode games", "the cabinet, and what each one is"],
+ ["moshcode games ", "play it, right here in the terminal"],
+ ],
+ flags: [["--json", "the roster, machine-readable", ""]],
+ examples: [
+ ["moshcode games", "what is in the arcade"],
+ ["moshcode games tetris", ""],
+ ["moshcode games chess", "real rules, and it plays back"],
+ ["moshcode games pacman", "dots, ghosts, three lives"],
+ ],
+ seeAlso: ["help"],
+ note: "every game works the same way: arrows move, q quits, r starts another. "
+ + "Playing needs a real terminal because they read single keypresses — `moshcode games` on its own lists them anywhere.",
+ },
+ { name: "game", aliasOf: "games", description: "alias for games" },
+ { name: "arcade", aliasOf: "games", description: "alias for games" },
{
name: "pwd",
group: "system",
@@ -907,6 +929,8 @@ export const PIT_COMMANDS = [
description: "crypto market data from advis0r.com" },
{ name: "plugin", aliases: ["plugins"], args: " [name]", cli: "plugin",
description: "install moshcode's slash commands into Claude Code" },
+ { name: "games", aliases: ["game", "arcade", "play"], args: "[game]", cli: "games",
+ description: "the arcade — tetris, snake, pac-man, tic-tac-toe, chess, hangman" },
{ name: "socials", aliases: ["social"], pitOnly: true,
description: "list social networks available for posting" },
{ name: "post", args: ' "message"', pitOnly: true,
diff --git a/src/games-chess.mjs b/src/games-chess.mjs
new file mode 100644
index 0000000..45a27da
--- /dev/null
+++ b/src/games-chess.mjs
@@ -0,0 +1,376 @@
+// Chess. Real rules — castling, en passant, promotion, check, checkmate,
+// stalemate — against an alpha-beta search that is about as strong as a friend
+// who plays sometimes. You are white; the machine answers immediately.
+//
+// The board is 64 squares of FEN letters: uppercase white, lowercase black,
+// null empty, index 0 = a8 and index 63 = h1. Everything below is pure, so a
+// position can be set up in a test and asked what it thinks.
+import { acid, amber, ash, bone, danger, dim, rgb } from "./ui.mjs";
+
+export const START = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR";
+
+const VALUE = { p: 100, n: 320, b: 330, r: 500, q: 900, k: 20000 };
+const KNIGHT_STEPS = [[1, 2], [2, 1], [-1, 2], [-2, 1], [1, -2], [2, -1], [-1, -2], [-2, -1]];
+const DIAGONALS = [[1, 1], [1, -1], [-1, 1], [-1, -1]];
+const STRAIGHTS = [[1, 0], [-1, 0], [0, 1], [0, -1]];
+const ROYAL = [...DIAGONALS, ...STRAIGHTS];
+
+export const isWhite = (piece) => Boolean(piece) && piece === piece.toUpperCase();
+const friendly = (a, b) => Boolean(a) && Boolean(b) && isWhite(a) === isWhite(b);
+const fileOf = (i) => i % 8;
+const rankOf = (i) => Math.floor(i / 8);
+const square = (x, y) => y * 8 + x;
+const inside = (x, y) => x >= 0 && x < 8 && y >= 0 && y < 8;
+/** The piece on a square, `null` if empty and `undefined` if off the board. */
+const at = (board, x, y) => (inside(x, y) ? board[square(x, y)] : undefined);
+
+/** Board rows of a FEN position (the placement field only). */
+export function parseBoard(fen = START) {
+ const board = Array.from({ length: 64 }, () => null);
+ let i = 0;
+ for (const char of fen.split(" ")[0]) {
+ if (char === "/") continue;
+ if (/\d/.test(char)) { i += Number(char); continue; }
+ board[i++] = char;
+ }
+ return board;
+}
+
+/** Algebraic name of a square — for the move list down the side. */
+export const name = (i) => "abcdefgh"[fileOf(i)] + (8 - rankOf(i));
+
+/**
+ * Every move the piece on `from` could make if the king's safety were somebody
+ * else's problem. `attacksOnly` drops pawn pushes and castling, because a pawn
+ * does not attack the square in front of it and a rook cannot be taken by a
+ * castle — that distinction is what makes check detection correct.
+ */
+export function pseudoMoves(state, from, { attacksOnly = false } = {}) {
+ const { board } = state;
+ const piece = board[from];
+ if (!piece) return [];
+ const white = isWhite(piece);
+ const type = piece.toLowerCase();
+ const x = fileOf(from);
+ const y = rankOf(from);
+ const moves = [];
+ const add = (to, extra = {}) => moves.push({ from, to, ...extra });
+
+ const slide = (dirs) => {
+ for (const [dx, dy] of dirs) {
+ for (let step = 1; step < 8; step++) {
+ const nx = x + dx * step;
+ const ny = y + dy * step;
+ const target = at(board, nx, ny);
+ if (target === undefined) break;
+ if (target === null) { add(square(nx, ny)); continue; }
+ if (!friendly(piece, target)) add(square(nx, ny), { capture: true });
+ break;
+ }
+ }
+ };
+ const hop = (steps) => {
+ for (const [dx, dy] of steps) {
+ const nx = x + dx;
+ const ny = y + dy;
+ const target = at(board, nx, ny);
+ if (target === undefined) continue;
+ if (target === null) add(square(nx, ny));
+ else if (!friendly(piece, target)) add(square(nx, ny), { capture: true });
+ }
+ };
+
+ if (type === "p") {
+ const dir = white ? -1 : 1;
+ const start = white ? 6 : 1;
+ const last = white ? 0 : 7;
+ if (!attacksOnly) {
+ if (at(board, x, y + dir) === null) {
+ add(square(x, y + dir), y + dir === last ? { promotion: true } : {});
+ if (y === start && at(board, x, y + 2 * dir) === null) {
+ add(square(x, y + 2 * dir), { double: true });
+ }
+ }
+ }
+ for (const dx of [-1, 1]) {
+ const nx = x + dx;
+ const ny = y + dir;
+ const target = at(board, nx, ny);
+ if (target === undefined) continue;
+ if (attacksOnly) { add(square(nx, ny)); continue; }
+ if (target && !friendly(piece, target)) {
+ add(square(nx, ny), ny === last ? { capture: true, promotion: true } : { capture: true });
+ } else if (target === null && state.ep === square(nx, ny)) {
+ add(square(nx, ny), { capture: true, enPassant: true });
+ }
+ }
+ return moves;
+ }
+ if (type === "n") hop(KNIGHT_STEPS);
+ else if (type === "b") slide(DIAGONALS);
+ else if (type === "r") slide(STRAIGHTS);
+ else if (type === "q") slide(ROYAL);
+ else if (type === "k") {
+ hop(ROYAL);
+ if (!attacksOnly) {
+ const home = white ? 60 : 4;
+ const rights = state.castling || {};
+ const empty = (...squares) => squares.every((s) => board[s] === null);
+ const safe = (...squares) => squares.every((s) => !attacked(state, s, !white));
+ if (from === home && !attacked(state, home, !white)) {
+ if (rights[white ? "K" : "k"] && empty(home + 1, home + 2) && safe(home + 1, home + 2)) {
+ add(home + 2, { castle: "K" });
+ }
+ if (rights[white ? "Q" : "q"] && empty(home - 1, home - 2, home - 3) && safe(home - 1, home - 2)) {
+ add(home - 2, { castle: "Q" });
+ }
+ }
+ }
+ }
+ return moves;
+}
+
+/** Is `target` attacked by the side `byWhite`? */
+export function attacked(state, target, byWhite) {
+ for (let i = 0; i < 64; i++) {
+ const piece = state.board[i];
+ if (!piece || isWhite(piece) !== byWhite) continue;
+ for (const move of pseudoMoves(state, i, { attacksOnly: true })) {
+ if (move.to === target) return true;
+ }
+ }
+ return false;
+}
+
+export const findKing = (board, white) => board.indexOf(white ? "K" : "k");
+
+export function inCheck(state, white) {
+ const king = findKing(state.board, white);
+ return king >= 0 && attacked(state, king, !white);
+}
+
+/** A new position with the move played. Never mutates what it was given. */
+export function apply(state, move) {
+ const board = state.board.slice();
+ const piece = board[move.from];
+ const white = isWhite(piece);
+ const castling = { ...state.castling };
+
+ board[move.from] = null;
+ board[move.to] = move.promotion ? (white ? "Q" : "q") : piece;
+ if (move.enPassant) board[square(fileOf(move.to), rankOf(move.from))] = null;
+ if (move.castle) {
+ const home = white ? 60 : 4;
+ const [rookFrom, rookTo] = move.castle === "K" ? [home + 3, home + 1] : [home - 4, home - 1];
+ board[rookTo] = board[rookFrom];
+ board[rookFrom] = null;
+ }
+
+ // Rights are lost by moving the king or a rook, and by capturing a rook on
+ // the square it started on — the last one is the case everybody forgets.
+ if (piece === "K") { castling.K = false; castling.Q = false; }
+ if (piece === "k") { castling.k = false; castling.q = false; }
+ for (const [corner, right] of [[63, "K"], [56, "Q"], [7, "k"], [0, "q"]]) {
+ if (move.from === corner || move.to === corner) castling[right] = false;
+ }
+
+ return {
+ ...state,
+ board,
+ castling,
+ ep: move.double ? square(fileOf(move.from), (rankOf(move.from) + rankOf(move.to)) / 2) : null,
+ turn: white ? "b" : "w",
+ };
+}
+
+/** Pseudo-legal minus everything that walks into check. */
+export function legalMoves(state, from) {
+ const piece = state.board[from];
+ if (!piece) return [];
+ const white = isWhite(piece);
+ return pseudoMoves(state, from).filter((move) => !inCheck(apply(state, move), white));
+}
+
+export function allMoves(state, white = state.turn === "w") {
+ const moves = [];
+ for (let i = 0; i < 64; i++) {
+ if (state.board[i] && isWhite(state.board[i]) === white) moves.push(...legalMoves(state, i));
+ }
+ return moves;
+}
+
+/** "checkmate", "stalemate", or null. */
+export function outcome(state) {
+ if (allMoves(state).length) return null;
+ return inCheck(state, state.turn === "w") ? "checkmate" : "stalemate";
+}
+
+/* --------------------------------------------------------------------- ai */
+
+// Material, plus a nudge toward the middle. Enough to make it take free pieces
+// and develop rather than shuffle a rook, which is all this needs to be.
+const CENTER = [0, 1, 2, 3, 3, 2, 1, 0];
+
+export function evaluate(state) {
+ let total = 0;
+ for (let i = 0; i < 64; i++) {
+ const piece = state.board[i];
+ if (!piece) continue;
+ const worth = VALUE[piece.toLowerCase()] + CENTER[fileOf(i)] + CENTER[rankOf(i)];
+ total += isWhite(piece) ? worth : -worth;
+ }
+ return state.turn === "w" ? total : -total;
+}
+
+function negamax(state, depth, alpha, beta) {
+ if (depth === 0) return evaluate(state);
+ const moves = allMoves(state);
+ if (!moves.length) return inCheck(state, state.turn === "w") ? -90000 - depth : 0;
+ // Captures first: cheap ordering, and it is most of what alpha-beta needs to
+ // prune a depth-3 search down to something that answers instantly.
+ moves.sort((a, b) => Number(Boolean(b.capture)) - Number(Boolean(a.capture)));
+ let best = -Infinity;
+ for (const move of moves) {
+ const value = -negamax(apply(state, move), depth - 1, -beta, -alpha);
+ if (value > best) best = value;
+ if (best > alpha) alpha = best;
+ if (alpha >= beta) break;
+ }
+ return best;
+}
+
+/** The machine's reply. Ties broken at random so it is not the same game twice. */
+export function chooseMove(state, { depth = 3, rng = Math.random } = {}) {
+ const moves = allMoves(state);
+ if (!moves.length) return null;
+ let best = -Infinity;
+ let picks = [];
+ for (const move of moves) {
+ const value = -negamax(apply(state, move), depth - 1, -Infinity, Infinity);
+ if (value > best) { best = value; picks = [move]; }
+ else if (value === best) picks.push(move);
+ }
+ return picks[Math.floor(rng() * picks.length) % picks.length];
+}
+
+/* ------------------------------------------------------------------- game */
+
+// White is upper case and black is lower, the way a FEN reads — so the board is
+// still playable with NO_COLOR set, where the two colours are the same colour.
+const GLYPH = (piece) => (isWhite(piece) ? piece.toUpperCase() : piece.toLowerCase());
+const WHITE_PIECE = bone;
+const BLACK_PIECE = rgb(255, 120, 180);
+
+function settle(state) {
+ const done = outcome(state);
+ if (!done) {
+ state.note = inCheck(state, state.turn === "w") ? "check" : "";
+ return state;
+ }
+ state.over = done === "stalemate"
+ ? "stalemate — nobody wins"
+ : state.turn === "w" ? "checkmate — the machine takes it" : "checkmate — you win 🤘";
+ return state;
+}
+
+export const CHESS = {
+ key: "chess",
+ aliases: ["ches", "kasparov"],
+ title: "CHESS",
+ blurb: "full rules, real opponent, pawns auto-queen",
+ keys: "← ↑ ↓ → move · enter pick then place · r new game · q quit",
+ // The search blocks for a few hundred milliseconds in an open position, so it
+ // runs on the clock rather than inside the keypress: your own move is on the
+ // board and drawn before the machine starts thinking about it. The idle beat
+ // is slow because nothing happens on it — the driver skips a redraw that
+ // would change nothing.
+ tickMs: (state) => (state.turn === "b" ? 80 : 400),
+
+ create() {
+ return {
+ board: parseBoard(START),
+ turn: "w",
+ castling: { K: true, Q: true, k: true, q: true },
+ ep: null,
+ cursor: 52, // e2, where most games start
+ selected: null,
+ targets: [],
+ played: [],
+ note: "",
+ over: null,
+ };
+ },
+
+ onKey(state, pressed, { rng = Math.random } = {}) {
+ const x = fileOf(state.cursor);
+ const y = rankOf(state.cursor);
+ if (pressed === "left") state.cursor = square((x + 7) % 8, y);
+ else if (pressed === "right") state.cursor = square((x + 1) % 8, y);
+ else if (pressed === "up") state.cursor = square(x, (y + 7) % 8);
+ else if (pressed === "down") state.cursor = square(x, (y + 1) % 8);
+ else if (pressed === "enter" || pressed === "space") {
+ if (state.selected === null) {
+ const piece = state.board[state.cursor];
+ if (!piece || !isWhite(piece)) return state;
+ state.selected = state.cursor;
+ state.targets = legalMoves(state, state.cursor);
+ return state;
+ }
+ // Enter on the piece again (or on a square it cannot reach) puts it back
+ // down. No cancel key to learn, and no way to get stuck holding a rook.
+ const move = state.targets.find((m) => m.to === state.cursor);
+ state.selected = null;
+ state.targets = [];
+ if (!move) return state;
+
+ Object.assign(state, apply(state, move));
+ state.played.push(`${name(move.from)}${move.capture ? "x" : "-"}${name(move.to)}`);
+ settle(state);
+ }
+ return state;
+ },
+
+ /** The machine's turn, one move per beat. Idle while white is thinking. */
+ tick(state, { rng = Math.random } = {}) {
+ if (state.turn !== "b" || state.over) return state;
+ const reply = chooseMove(state, { rng });
+ if (!reply) return settle(state);
+ Object.assign(state, apply(state, reply));
+ state.played.push(`${name(reply.from)}${reply.capture ? "x" : "-"}${name(reply.to)}`);
+ return settle(state);
+ },
+
+ status(state) {
+ if (state.over) return state.over;
+ if (state.turn === "b") return `${ash("black is thinking…")}`;
+ const last = state.played.slice(-1)[0];
+ return `you ${bone("white")}${last ? ash(` · last ${last}`) : ""}${state.note ? ` · ${danger(state.note.toUpperCase())}` : ""}`;
+ },
+
+ render(state) {
+ const targets = new Set(state.targets.map((m) => m.to));
+ const rows = [];
+ for (let y = 0; y < 8; y++) {
+ let row = `${ash(String(8 - y))} `;
+ for (let x = 0; x < 8; x++) {
+ const i = square(x, y);
+ const piece = state.board[i];
+ const dark = (x + y) % 2 === 1;
+ let glyph = piece
+ ? (isWhite(piece) ? WHITE_PIECE : BLACK_PIECE)(GLYPH(piece))
+ : targets.has(i) ? acid("◦") : dark ? dim("·") : " ";
+ // A piece you could take is lit up rather than dotted — the dot would
+ // be hidden underneath it.
+ if (targets.has(i) && piece) glyph = amber(GLYPH(piece));
+ if (i === state.cursor) row += `${acid("[")}${glyph}${acid("]")}`;
+ else if (i === state.selected) row += `${amber("‹")}${glyph}${amber("›")}`;
+ else row += ` ${glyph} `;
+ }
+ rows.push(row);
+ }
+ rows.push(` ${ash(" a b c d e f g h ")}`);
+ rows.push("");
+ rows.push(` ${dim(state.selected === null ? "enter picks a piece up" : "enter puts it down")}`);
+ return rows;
+ },
+};
diff --git a/src/games-hangman.mjs b/src/games-hangman.mjs
new file mode 100644
index 0000000..d2363da
--- /dev/null
+++ b/src/games-hangman.mjs
@@ -0,0 +1,97 @@
+// Hangman. Type a letter; the gallows does the rest.
+import { acid, amber, ash, bone, danger, dim } from "./ui.mjs";
+
+/** Six wrong guesses, and the gallows is finished. */
+export const GALLOWS = [
+ [" ┌────┐", " │ ", " │ ", " │ ", " │ ", " ═╧══════"],
+ [" ┌────┐", " │ ○", " │ ", " │ ", " │ ", " ═╧══════"],
+ [" ┌────┐", " │ ○", " │ │", " │ ", " │ ", " ═╧══════"],
+ [" ┌────┐", " │ ○", " │ ╱│", " │ ", " │ ", " ═╧══════"],
+ [" ┌────┐", " │ ○", " │ ╱│╲", " │ ", " │ ", " ═╧══════"],
+ [" ┌────┐", " │ ○", " │ ╱│╲", " │ │", " │ ╱ ", " ═╧══════"],
+ [" ┌────┐", " │ ☹", " │ ╱│╲", " │ │", " │ ╱ ╲", " ═╧══════"],
+];
+
+export const MISSES_ALLOWED = GALLOWS.length - 1;
+
+/** Words a moshcoder might actually shout. Nothing needing a hyphen. */
+export const WORDS = [
+ "moshcode", "distortion", "compiler", "kernel", "segfault", "refactor",
+ "closure", "promise", "daemon", "binary", "pointer", "monorepo", "terminal",
+ "runtime", "abstraction", "recursion", "interface", "payload", "protocol",
+ "semaphore", "mutex", "stacktrace", "breakpoint", "heuristic", "idempotent",
+ "bytecode", "checksum", "firewall", "namespace", "regression", "sandbox",
+ "throughput", "waveform", "amplifier", "feedback", "headbang", "overdrive",
+ "fretboard", "downbeat", "crowdsurf", "backline", "encryption", "quantum",
+];
+
+const LETTERS = "abcdefghijklmnopqrstuvwxyz";
+
+/** The word with everything unguessed still hidden. */
+export function mask(state) {
+ return state.word.split("").map((c) => (state.guessed.has(c) ? c.toUpperCase() : "_"));
+}
+
+/**
+ * Apply one letter. Repeats are free — guessing `e` twice costs nothing but
+ * also tells you nothing, which is the same deal every hangman has ever run.
+ */
+export function guess(state, letter) {
+ const c = String(letter).toLowerCase();
+ if (!LETTERS.includes(c) || state.guessed.has(c) || state.missed.includes(c)) return state;
+ if (state.word.includes(c)) {
+ state.guessed.add(c);
+ if (state.word.split("").every((ch) => state.guessed.has(ch))) state.over = "got it 🤘";
+ return state;
+ }
+ state.missed.push(c);
+ if (state.missed.length >= MISSES_ALLOWED) state.over = `hanged — it was ${state.word.toUpperCase()}`;
+ return state;
+}
+
+export const HANGMAN = {
+ key: "hangman",
+ aliases: ["hang", "gallows"],
+ title: "HANGMAN",
+ blurb: "six wrong letters and you are done for",
+ keys: "a–z guess · r new word · q quit",
+
+ create({ rng = Math.random } = {}) {
+ return {
+ word: WORDS[Math.floor(rng() * WORDS.length) % WORDS.length],
+ guessed: new Set(),
+ missed: [],
+ over: null,
+ };
+ },
+
+ onKey(state, pressed) {
+ if (pressed.length !== 1) return state;
+ return guess(state, pressed);
+ },
+
+ status(state) {
+ if (state.over) return state.over;
+ const left = MISSES_ALLOWED - state.missed.length;
+ return `${left} wrong ${left === 1 ? "guess" : "guesses"} left`;
+ },
+
+ render(state) {
+ const art = GALLOWS[Math.min(state.missed.length, MISSES_ALLOWED)];
+ const shown = state.over && state.over.startsWith("hanged")
+ ? state.word.split("").map((c) => c.toUpperCase())
+ : mask(state);
+ const word = shown.map((c) => (c === "_" ? ash("_") : acid(c))).join(" ");
+ const missed = state.missed.length
+ ? `${ash("missed ")}${danger(state.missed.join(" ").toUpperCase())}`
+ : dim("no wrong letters yet");
+ return [
+ ...art.map((line) => bone(line)),
+ "",
+ ` ${word}`,
+ "",
+ ` ${missed}`,
+ ` ${amber("✳".repeat(MISSES_ALLOWED - state.missed.length))}${dim("✳".repeat(state.missed.length))}`,
+ ];
+ },
+};
diff --git a/src/games-pacman.mjs b/src/games-pacman.mjs
new file mode 100644
index 0000000..8a8cea2
--- /dev/null
+++ b/src/games-pacman.mjs
@@ -0,0 +1,205 @@
+// Pac-Man, arcade-sized. One maze, 240-odd dots, four power pellets and three
+// ghosts that are not quite as clever as the ones in 1980 — on purpose. This is
+// a game you can win on a coffee break.
+import { acid, amber, ash, dim, rgb } from "./ui.mjs";
+
+/**
+ * `#` wall · `.` dot · `o` power pellet · `P` where pac starts · `G` the pen.
+ *
+ * Symmetric, fully connected, and small enough that the whole board fits above
+ * the pit's prompt without scrolling.
+ */
+export const MAZE = [
+ "###################",
+ "#........#........#",
+ "#o##.###.#.###.##o#",
+ "#.................#",
+ "#.##.#.#####.#.##.#",
+ "#....#...G...#....#",
+ "####.##.###.##.####",
+ "#........P........#",
+ "#.##.####.####.##.#",
+ "#o...............o#",
+ "###################",
+];
+
+export const WIDTH = MAZE[0].length;
+export const HEIGHT = MAZE.length;
+
+const WALL = ash("██");
+const DOT = dim("· ");
+const POWER = amber("✳ ");
+const PAC = acid("● ");
+const BLANK = " ";
+const GHOST_COLORS = [rgb(255, 77, 61), rgb(255, 120, 180), rgb(90, 220, 250)];
+const SCARED = rgb(90, 140, 255);
+
+const DIRS = { up: [0, -1], down: [0, 1], left: [-1, 0], right: [1, 0] };
+const OPPOSITE = { up: "down", down: "up", left: "right", right: "left" };
+const FRIGHT_TICKS = 40;
+
+export const isWall = (x, y) => MAZE[y]?.[x] === "#" || MAZE[y]?.[x] === undefined;
+const cellKey = (x, y) => `${x},${y}`;
+const distance = (a, b) => Math.abs(a.x - b.x) + Math.abs(a.y - b.y);
+
+/** Every dot and pellet in the maze, as a fresh map. */
+export function pellets() {
+ const map = new Map();
+ for (let y = 0; y < HEIGHT; y++) {
+ for (let x = 0; x < WIDTH; x++) {
+ const cell = MAZE[y][x];
+ if (cell === "." || cell === "o") map.set(cellKey(x, y), cell);
+ }
+ }
+ return map;
+}
+
+function find(char) {
+ for (let y = 0; y < HEIGHT; y++) {
+ const x = MAZE[y].indexOf(char);
+ if (x >= 0) return { x, y };
+ }
+ return { x: 1, y: 1 };
+}
+
+/** Where everything stands at the start of a life. */
+function positions() {
+ const pen = find("G");
+ const pac = find("P");
+ return {
+ pac: { ...pac, dir: "left", want: "left" },
+ // Three ghosts abreast in the pen — the two beside it are open corridor in
+ // this maze, which is what keeps them from stepping on each other at spawn.
+ ghosts: [
+ { x: pen.x, y: pen.y },
+ { x: pen.x - 1, y: pen.y },
+ { x: pen.x + 1, y: pen.y },
+ ].map((g, i) => ({ ...g, home: { x: g.x, y: g.y }, dir: "up", color: i })),
+ };
+}
+
+/** Directions a ghost may take from where it stands. */
+export function options(ghost, { allowReverse = false } = {}) {
+ const open = Object.entries(DIRS)
+ .filter(([name, [dx, dy]]) => !isWall(ghost.x + dx, ghost.y + dy)
+ && (allowReverse || name !== OPPOSITE[ghost.dir]));
+ // A dead end is the one place reversing is the only move there is.
+ return open.length ? open : Object.entries(DIRS).filter(([, [dx, dy]]) => !isWall(ghost.x + dx, ghost.y + dy));
+}
+
+/**
+ * One ghost step. Chases by Manhattan distance, flees while you are lit up,
+ * and takes a random legal turn one time in five so the three of them do not
+ * arrive in a single-file line.
+ */
+export function moveGhost(ghost, pac, { frightened = false, rng = Math.random } = {}) {
+ const open = options(ghost);
+ if (!open.length) return ghost;
+ const scored = open.map(([name, [dx, dy]]) => ({
+ name,
+ dx,
+ dy,
+ d: distance({ x: ghost.x + dx, y: ghost.y + dy }, pac),
+ }));
+ let choice;
+ if (rng() < 0.2) choice = scored[Math.floor(rng() * scored.length) % scored.length];
+ else {
+ const sorted = scored.slice().sort((a, b) => (frightened ? b.d - a.d : a.d - b.d));
+ choice = sorted[0];
+ }
+ ghost.dir = choice.name;
+ ghost.x += choice.dx;
+ ghost.y += choice.dy;
+ return ghost;
+}
+
+function caught(state) {
+ const hits = state.ghosts.filter((g) => g.x === state.pac.x && g.y === state.pac.y);
+ if (!hits.length) return false;
+ if (state.fright > 0) {
+ for (const g of hits) {
+ state.score += 200;
+ Object.assign(g, { x: g.home.x, y: g.home.y, dir: "up" });
+ }
+ return false;
+ }
+ state.lives--;
+ if (state.lives <= 0) { state.over = "game over"; return true; }
+ Object.assign(state, positions(), { fright: 0 });
+ return true;
+}
+
+export const PACMAN = {
+ key: "pacman",
+ aliases: ["pac", "pacmam", "puckman"],
+ title: "PAC-MAN",
+ blurb: "eat the dots, dodge the ghosts, ✳ makes them edible",
+ keys: "← ↑ ↓ → steer · q quit",
+ tickMs: 150,
+
+ create({ rng = Math.random } = {}) {
+ return { ...positions(), dots: pellets(), score: 0, lives: 3, fright: 0, frame: 0, over: null, rng };
+ },
+
+ tick(state) {
+ state.frame++;
+ const pac = state.pac;
+ // A turn is remembered until it becomes legal, which is what makes a corner
+ // feel like a corner rather than a keypress you have to time.
+ const wanted = DIRS[pac.want];
+ if (wanted && !isWall(pac.x + wanted[0], pac.y + wanted[1])) pac.dir = pac.want;
+ const [dx, dy] = DIRS[pac.dir];
+ if (!isWall(pac.x + dx, pac.y + dy)) { pac.x += dx; pac.y += dy; }
+
+ const here = state.dots.get(cellKey(pac.x, pac.y));
+ if (here) {
+ state.dots.delete(cellKey(pac.x, pac.y));
+ state.score += here === "o" ? 50 : 10;
+ if (here === "o") state.fright = FRIGHT_TICKS;
+ }
+ if (!state.dots.size) { state.over = "maze cleared 🤘"; return state; }
+ if (caught(state)) return state;
+
+ // Ghosts move at half speed, and slower still while they are running away.
+ const beat = state.fright > 0 ? 3 : 2;
+ if (state.frame % beat === 0) {
+ for (const ghost of state.ghosts) {
+ moveGhost(ghost, pac, { frightened: state.fright > 0, rng: state.rng });
+ }
+ caught(state);
+ }
+ if (state.fright > 0) state.fright--;
+ return state;
+ },
+
+ onKey(state, pressed) {
+ if (DIRS[pressed]) state.pac.want = pressed;
+ return state;
+ },
+
+ status(state) {
+ const left = state.dots.size;
+ if (state.over) return `${state.over} · ${state.score} points`;
+ return `score ${state.score} · lives ${"●".repeat(Math.max(0, state.lives))} · dots ${left}`
+ + (state.fright > 0 ? " · RUN" : "");
+ },
+
+ render(state) {
+ const rows = [];
+ for (let y = 0; y < HEIGHT; y++) {
+ let row = "";
+ for (let x = 0; x < WIDTH; x++) {
+ const ghost = state.ghosts.find((g) => g.x === x && g.y === y);
+ if (state.pac.x === x && state.pac.y === y) row += PAC;
+ else if (ghost) row += (state.fright > 0 ? SCARED : GHOST_COLORS[ghost.color])("▲ ");
+ else if (MAZE[y][x] === "#") row += WALL;
+ else {
+ const pellet = state.dots.get(cellKey(x, y));
+ row += pellet === "o" ? POWER : pellet === "." ? DOT : BLANK;
+ }
+ }
+ rows.push(row);
+ }
+ return rows;
+ },
+};
diff --git a/src/games-snake.mjs b/src/games-snake.mjs
new file mode 100644
index 0000000..deaad8a
--- /dev/null
+++ b/src/games-snake.mjs
@@ -0,0 +1,111 @@
+// Snake. The cheapest game in the arcade and the hardest to stop playing.
+import { acid, amber, dim, rgb } from "./ui.mjs";
+
+export const WIDTH = 28;
+export const HEIGHT = 14;
+
+const HEAD = acid("█");
+const BODY = rgb(120, 190, 40)("▓");
+const FOOD = amber("✳");
+const EMPTY = dim("·");
+
+const DIRS = {
+ up: [0, -1],
+ down: [0, 1],
+ left: [-1, 0],
+ right: [1, 0],
+};
+
+const same = (a, b) => a[0] === b[0] && a[1] === b[1];
+
+/** A cell nothing is standing on, so the food never lands under the snake. */
+export function placeFood(snake, rng) {
+ const free = [];
+ for (let y = 0; y < HEIGHT; y++) {
+ for (let x = 0; x < WIDTH; x++) {
+ if (!snake.some((s) => same(s, [x, y]))) free.push([x, y]);
+ }
+ }
+ if (!free.length) return null; // the board is snake — that is a win
+ return free[Math.floor(rng() * free.length) % free.length];
+}
+
+/**
+ * One step. Returns the state; sets `over` when the head meets a wall or
+ * itself. Kept separate from the game object so a test can walk a snake into
+ * its own tail on purpose.
+ */
+export function step(state) {
+ const [dx, dy] = DIRS[state.dir];
+ const head = [state.snake[0][0] + dx, state.snake[0][1] + dy];
+ if (head[0] < 0 || head[0] >= WIDTH || head[1] < 0 || head[1] >= HEIGHT) {
+ state.over = "into the wall";
+ return state;
+ }
+ // The tail cell is about to move out from under the head, so it is only a
+ // collision when the snake is about to grow into it.
+ const eating = state.food && same(head, state.food);
+ const body = eating ? state.snake : state.snake.slice(0, -1);
+ if (body.some((s) => same(s, head))) {
+ state.over = "ate itself";
+ return state;
+ }
+ state.snake = [head, ...body];
+ if (eating) {
+ state.score += 10;
+ state.food = placeFood(state.snake, state.rng);
+ if (!state.food) state.over = "the whole board — no notes";
+ }
+ state.turned = false;
+ return state;
+}
+
+export const SNAKE = {
+ key: "snake",
+ aliases: ["worm", "nibbles"],
+ title: "SNAKE",
+ blurb: "eat, grow, and try not to eat yourself",
+ keys: "← ↑ ↓ → turn · q quit",
+ tickMs: (state) => Math.max(60, 130 - state.snake.length * 2),
+
+ create({ rng = Math.random } = {}) {
+ const mid = Math.floor(HEIGHT / 2);
+ const snake = [[6, mid], [5, mid], [4, mid]];
+ return { snake, dir: "right", turned: false, score: 0, over: null, rng, food: placeFood(snake, rng) };
+ },
+
+ tick: step,
+
+ onKey(state, key) {
+ if (!DIRS[key]) return state;
+ // One turn per tick, and never a full reverse: without either, a fast
+ // left-then-up folds the snake back through its own neck.
+ const opposite = { up: "down", down: "up", left: "right", right: "left" };
+ if (state.turned || opposite[key] === state.dir || key === state.dir) return state;
+ state.dir = key;
+ state.turned = true;
+ return state;
+ },
+
+ status(state) {
+ return state.over
+ ? `${state.over} · ${state.score} points`
+ : `score ${state.score} · length ${state.snake.length}`;
+ },
+
+ render(state) {
+ const rows = [];
+ for (let y = 0; y < HEIGHT; y++) {
+ let row = "";
+ for (let x = 0; x < WIDTH; x++) {
+ const cell = [x, y];
+ if (same(state.snake[0], cell)) row += HEAD;
+ else if (state.snake.some((s) => same(s, cell))) row += BODY;
+ else if (state.food && same(state.food, cell)) row += FOOD;
+ else row += EMPTY;
+ }
+ rows.push(row);
+ }
+ return rows;
+ },
+};
diff --git a/src/games-tetris.mjs b/src/games-tetris.mjs
new file mode 100644
index 0000000..b84f280
--- /dev/null
+++ b/src/games-tetris.mjs
@@ -0,0 +1,221 @@
+// Tetris, for the moshcode arcade. Ten wide, twenty deep, seven bricks.
+//
+// Everything here is pure — rotate a shape, ask whether it collides, merge it,
+// clear the full rows — so the whole game can be played in a test with no
+// terminal anywhere near it. See src/games.mjs for the frame it is drawn in.
+import { acid, amber, ash, danger, dim, rgb } from "./ui.mjs";
+
+export const WIDTH = 10;
+export const HEIGHT = 20;
+
+/**
+ * The seven tetrominoes, drawn as square grids so one rotate() handles them
+ * all. Each fills itself with its own letter, which is also its colour key —
+ * merging a piece into the board is then a copy, with no bookkeeping.
+ */
+export const SHAPES = {
+ I: ["....", "IIII", "....", "...."],
+ O: ["OO", "OO"],
+ T: [".T.", "TTT", "..."],
+ S: [".SS", "SS.", "..."],
+ Z: ["ZZ.", ".ZZ", "..."],
+ J: ["J..", "JJJ", "..."],
+ L: ["..L", "LLL", "..."],
+};
+
+const COLORS = {
+ I: rgb(90, 220, 250),
+ O: amber,
+ T: rgb(190, 130, 255),
+ S: acid,
+ Z: danger,
+ J: rgb(90, 140, 255),
+ L: rgb(255, 150, 60),
+};
+
+const BAG = Object.keys(SHAPES);
+const BLOCK = "██";
+const EMPTY = dim("· ");
+const GHOST = ash("░░");
+
+/** Clockwise quarter turn of a square shape. */
+export function rotate(shape) {
+ return shape.map((_, i) => shape.map((row) => row[i]).reverse().join(""));
+}
+
+export const emptyBoard = () =>
+ Array.from({ length: HEIGHT }, () => Array.from({ length: WIDTH }, () => null));
+
+/** Would this shape overlap a wall, the floor, or something already stacked? */
+export function collides(board, shape, px, py) {
+ for (let y = 0; y < shape.length; y++) {
+ for (let x = 0; x < shape[y].length; x++) {
+ if (shape[y][x] === ".") continue;
+ const bx = px + x;
+ const by = py + y;
+ if (bx < 0 || bx >= WIDTH || by >= HEIGHT) return true;
+ // Above the ceiling is legal — that is where a piece spawns from.
+ if (by >= 0 && board[by][bx]) return true;
+ }
+ }
+ return false;
+}
+
+/** Stamp a piece into the board. Mutates, and is only ever called on a lock. */
+export function merge(board, piece) {
+ for (let y = 0; y < piece.shape.length; y++) {
+ for (let x = 0; x < piece.shape[y].length; x++) {
+ const cell = piece.shape[y][x];
+ if (cell === ".") continue;
+ const by = piece.y + y;
+ if (by >= 0) board[by][piece.x + x] = cell;
+ }
+ }
+ return board;
+}
+
+/** Drop out every full row, refill from the top. Returns how many went. */
+export function clearLines(board) {
+ const kept = board.filter((row) => row.some((cell) => !cell));
+ const cleared = HEIGHT - kept.length;
+ while (kept.length < HEIGHT) kept.unshift(Array.from({ length: WIDTH }, () => null));
+ for (let y = 0; y < HEIGHT; y++) board[y] = kept[y];
+ return cleared;
+}
+
+const pick = (rng) => BAG[Math.floor(rng() * BAG.length) % BAG.length];
+
+function spawn(state, key) {
+ const shape = SHAPES[key];
+ const piece = { key, shape, x: Math.floor((WIDTH - shape[0].length) / 2), y: 0 };
+ if (collides(state.board, piece.shape, piece.x, piece.y)) state.over = "stacked out";
+ state.piece = piece;
+ return state;
+}
+
+export const level = (state) => 1 + Math.floor(state.lines / 10);
+
+/** Where the piece would land if you let go of it — drawn as the ghost. */
+export function landing(state) {
+ let y = state.piece.y;
+ while (!collides(state.board, state.piece.shape, state.piece.x, y + 1)) y++;
+ return y;
+}
+
+function lock(state) {
+ merge(state.board, state.piece);
+ const cleared = clearLines(state.board);
+ if (cleared) {
+ state.lines += cleared;
+ state.score += [0, 100, 300, 500, 800][cleared] * level(state);
+ }
+ const key = state.next;
+ state.next = pick(state.rng);
+ return spawn(state, key);
+}
+
+export const TETRIS = {
+ key: "tetris",
+ aliases: ["blocks", "bricks"],
+ title: "TETRIS",
+ blurb: "stack the bricks, clear the lines, outrun gravity",
+ keys: "← → move · ↑ rotate · ↓ drop one · space slam · q quit",
+ // Gravity is the level, and the level is the lines you have cleared.
+ tickMs: (state) => Math.max(90, 700 - (level(state) - 1) * 65),
+
+ create({ rng = Math.random } = {}) {
+ const state = { board: emptyBoard(), score: 0, lines: 0, over: null, rng, next: pick(rng) };
+ return spawn(state, pick(rng));
+ },
+
+ tick(state) {
+ if (collides(state.board, state.piece.shape, state.piece.x, state.piece.y + 1)) return lock(state);
+ state.piece.y++;
+ return state;
+ },
+
+ onKey(state, key) {
+ const p = state.piece;
+ if (key === "left" && !collides(state.board, p.shape, p.x - 1, p.y)) p.x--;
+ else if (key === "right" && !collides(state.board, p.shape, p.x + 1, p.y)) p.x++;
+ else if (key === "down") return TETRIS.tick(state);
+ else if (key === "up" || key === "x") {
+ const turned = rotate(p.shape);
+ // Wall kicks, the simple kind: if the turn doesn't fit, shove it a column
+ // or two off the wall before giving up. Without this an I-piece can never
+ // stand up in the left gutter.
+ for (const dx of [0, -1, 1, -2, 2]) {
+ if (!collides(state.board, turned, p.x + dx, p.y)) {
+ p.shape = turned;
+ p.x += dx;
+ break;
+ }
+ }
+ } else if (key === "space") {
+ const drop = landing(state);
+ state.score += (drop - p.y) * 2;
+ p.y = drop;
+ return lock(state);
+ }
+ return state;
+ },
+
+ status(state) {
+ return state.over
+ ? `${state.over} · score ${state.score}`
+ : `score ${state.score} · lines ${state.lines}`;
+ },
+
+ render(state) {
+ const view = state.board.map((row) => row.slice());
+ const p = state.piece;
+ if (!state.over) {
+ const gy = landing(state);
+ for (let y = 0; y < p.shape.length; y++) {
+ for (let x = 0; x < p.shape[y].length; x++) {
+ if (p.shape[y][x] === ".") continue;
+ if (gy + y >= 0 && gy + y < HEIGHT) view[gy + y][p.x + x] = "ghost";
+ }
+ }
+ }
+ for (let y = 0; y < p.shape.length; y++) {
+ for (let x = 0; x < p.shape[y].length; x++) {
+ if (p.shape[y][x] === ".") continue;
+ if (p.y + y >= 0 && p.y + y < HEIGHT) view[p.y + y][p.x + x] = p.key;
+ }
+ }
+ const gutter = sidePanel(state);
+ return view.map((row, y) => {
+ const well = row.map((cell) => {
+ if (!cell) return EMPTY;
+ if (cell === "ghost") return GHOST;
+ return COLORS[cell](BLOCK);
+ }).join("");
+ return `${well} ${gutter[y] ?? ""}`;
+ });
+ },
+};
+
+/**
+ * The strip down the right: what is coming, and what level you are on.
+ *
+ * It is also what makes the well look like a tetris cabinet rather than a
+ * column of bricks floating in a frame — the board sets the frame's width, so
+ * without it the box is wider than the game.
+ */
+function sidePanel(state) {
+ const colour = COLORS[state.next];
+ const shape = SHAPES[state.next];
+ const lines = [
+ ash("NEXT"),
+ ...shape.map((row) => row.split("").map((c) => (c === "." ? " " : colour(BLOCK))).join("")),
+ "",
+ ash(`LVL ${level(state)}`),
+ ];
+ // Padded to a fixed width so a narrow piece cannot make the frame breathe in
+ // and out as the bag turns over.
+ return lines.map((line) => {
+ const width = line.replace(/\x1b\[[0-9;]*m/g, "").length;
+ return line + " ".repeat(Math.max(0, 8 - width));
+ });
+}
diff --git a/src/games-tictactoe.mjs b/src/games-tictactoe.mjs
new file mode 100644
index 0000000..881b291
--- /dev/null
+++ b/src/games-tictactoe.mjs
@@ -0,0 +1,124 @@
+// Tic-tac-toe against an opponent that cannot be beaten, only held.
+//
+// The AI is a full minimax — the search space is 9! at its very worst, which is
+// nothing — so a draw is a win. It breaks ties at random, which is the only
+// reason two games in a row are not the same game.
+import { acid, ash, bone, danger, dim } from "./ui.mjs";
+
+export const LINES = [
+ [0, 1, 2], [3, 4, 5], [6, 7, 8],
+ [0, 3, 6], [1, 4, 7], [2, 5, 8],
+ [0, 4, 8], [2, 4, 6],
+];
+
+export const emptyBoard = () => Array.from({ length: 9 }, () => null);
+
+/** "X", "O", "draw", or null while there is still a game on. */
+export function winner(board) {
+ for (const [a, b, c] of LINES) {
+ if (board[a] && board[a] === board[b] && board[b] === board[c]) return board[a];
+ }
+ return board.every(Boolean) ? "draw" : null;
+}
+
+const other = (player) => (player === "X" ? "O" : "X");
+
+/**
+ * Minimax with no pruning and no depth limit — 3×3 does not need either.
+ * Depth is in the score so it prefers winning sooner and losing later, which is
+ * what stops it from wandering into a fork it could have blocked.
+ */
+export function score(board, player, me, depth = 0) {
+ const done = winner(board);
+ if (done === me) return 10 - depth;
+ if (done === other(me)) return depth - 10;
+ if (done === "draw") return 0;
+
+ const scores = [];
+ for (let i = 0; i < 9; i++) {
+ if (board[i]) continue;
+ board[i] = player;
+ scores.push(score(board, other(player), me, depth + 1));
+ board[i] = null;
+ }
+ return player === me ? Math.max(...scores) : Math.min(...scores);
+}
+
+/** The best square for `player`, chosen at random among equally good ones. */
+export function bestMove(board, player, rng = Math.random) {
+ let best = -Infinity;
+ let moves = [];
+ for (let i = 0; i < 9; i++) {
+ if (board[i]) continue;
+ board[i] = player;
+ const value = score(board, other(player), player, 1);
+ board[i] = null;
+ if (value > best) { best = value; moves = [i]; }
+ else if (value === best) moves.push(i);
+ }
+ if (!moves.length) return null;
+ return moves[Math.floor(rng() * moves.length) % moves.length];
+}
+
+function finish(state) {
+ const result = winner(state.board);
+ if (!result) return state;
+ state.over = result === "draw" ? "a draw — the only honest result"
+ : result === "X" ? "you win 🤘" : "the machine takes it";
+ return state;
+}
+
+export const TICTACTOE = {
+ key: "tictactoe",
+ // `tic-tac-toe` needs no alias — resolveGame drops dashes before it looks.
+ aliases: ["ttt", "tiktaktoe", "noughts", "xo"],
+ title: "TIC-TAC-TOE",
+ blurb: "three in a row against a perfect opponent",
+ keys: "← ↑ ↓ → move · enter mark · r new game · q quit",
+
+ create() {
+ return { board: emptyBoard(), cursor: 4, over: null, turn: "X" };
+ },
+
+ onKey(state, pressed, { rng = Math.random } = {}) {
+ const x = state.cursor % 3;
+ const y = Math.floor(state.cursor / 3);
+ if (pressed === "left") state.cursor = y * 3 + (x + 2) % 3;
+ else if (pressed === "right") state.cursor = y * 3 + (x + 1) % 3;
+ else if (pressed === "up") state.cursor = ((y + 2) % 3) * 3 + x;
+ else if (pressed === "down") state.cursor = ((y + 1) % 3) * 3 + x;
+ else if (pressed === "enter" || pressed === "space") {
+ if (state.board[state.cursor]) return state;
+ state.board[state.cursor] = "X";
+ if (finish(state).over) return state;
+ const reply = bestMove(state.board, "O", rng);
+ if (reply != null) state.board[reply] = "O";
+ finish(state);
+ }
+ return state;
+ },
+
+ status(state) {
+ return state.over ? state.over : `you ${acid("X")} ${ash("· machine")} ${bone("O")}`;
+ },
+
+ render(state) {
+ const mark = (i) => {
+ const value = state.board[i];
+ const glyph = value === "X" ? acid("X") : value === "O" ? danger("O") : " ";
+ // The cursor is drawn as brackets rather than a highlight so it survives
+ // NO_COLOR, a pipe, and every terminal that lies about its capabilities.
+ return i === state.cursor && !state.over ? ` ${acid("[")}${glyph}${acid("]")} ` : ` ${glyph} `;
+ };
+ const line = (l, m, r) => ash(`${l}─────${m}─────${m}─────${r}`);
+ const rows = [];
+ rows.push(line("┌", "┬", "┐"));
+ for (let y = 0; y < 3; y++) {
+ rows.push(`${ash("│")}${mark(y * 3)}${ash("│")}${mark(y * 3 + 1)}${ash("│")}${mark(y * 3 + 2)}${ash("│")}`);
+ rows.push(y < 2 ? line("├", "┼", "┤") : line("└", "┴", "┘"));
+ }
+ rows.push("");
+ rows.push(dim(state.over ? "r for another" : "enter to mark the square"));
+ return rows;
+ },
+};
diff --git a/src/games.mjs b/src/games.mjs
new file mode 100644
index 0000000..7a912a5
--- /dev/null
+++ b/src/games.mjs
@@ -0,0 +1,337 @@
+// The moshcode arcade — `/games` in the pit, `moshcode games` from a shell.
+//
+// Six games, one frame. Every game here is the same shape (see GAME_SHAPE
+// below) and is drawn by the same `frame()`, so they look like one arcade
+// rather than six weekend projects: a title, a status line, a boxed board, and
+// one line of keys along the bottom. There is no menu, no options screen and no
+// difficulty prompt — `/games tetris` is already playing by the time the frame
+// lands, and `q` is always the way out.
+//
+// The split is deliberate: the games themselves (games-*.mjs) are pure — create
+// a state, hand it a key, hand it a tick, ask it for rows — and everything that
+// touches a terminal lives in `runGame` down the bottom. That is what makes an
+// arcade testable: test/games.test.mjs plays entire games without a TTY.
+import { acid, amber, ash, bone, danger, dim, rgb } from "./ui.mjs";
+import { TETRIS } from "./games-tetris.mjs";
+import { SNAKE } from "./games-snake.mjs";
+import { PACMAN } from "./games-pacman.mjs";
+import { TICTACTOE } from "./games-tictactoe.mjs";
+import { HANGMAN } from "./games-hangman.mjs";
+import { CHESS } from "./games-chess.mjs";
+
+/**
+ * @typedef {object} Game — the whole contract, so a seventh game is an import.
+ * @property {string} key the name typed after /games
+ * @property {string[]} aliases other spellings (tiktaktoe is a real thing people type)
+ * @property {string} title shown in the frame's header
+ * @property {string} blurb one line, for /games list
+ * @property {string} keys the footer; the only place controls are ever explained
+ * @property {number|Function} [tickMs] real-time games only — a number, or (state) => number
+ * @property {Function} create ({ rng }) => state
+ * @property {Function} onKey (state, key, { rng }) => state
+ * @property {Function} [tick] (state, { rng }) => state
+ * @property {Function} render (state) => string[] the board, already coloured
+ * @property {Function} status (state) => string right of the title
+ */
+
+/** The cabinet. Order is the order `/games` lists them. */
+export const GAMES = [TETRIS, SNAKE, PACMAN, TICTACTOE, CHESS, HANGMAN];
+
+/** Games by name, following aliases. Case- and slash-insensitive. */
+export function resolveGame(name) {
+ const wanted = String(name ?? "").toLowerCase().replace(/^\//, "").replace(/[-_\s]/g, "");
+ if (!wanted) return null;
+ return GAMES.find((g) => g.key === wanted || (g.aliases || []).includes(wanted)) ?? null;
+}
+
+/* ------------------------------------------------------------------- frame */
+
+// Colour codes are invisible but not zero-width to `.length`, so every pad in
+// here measures the stripped string. Getting this wrong is how a board's right
+// edge ends up ragged the moment someone wins.
+const ANSI = /\x1b\[[0-9;]*m/g;
+export const strip = (s) => String(s).replace(ANSI, "");
+export const visible = (s) => strip(s).length;
+const pad = (s, width) => s + " ".repeat(Math.max(0, width - visible(s)));
+
+/**
+ * The one frame every game is drawn in.
+ *
+ * ```
+ * TETRIS score 1200 · lines 12
+ * ┌────────────────────┐
+ * │ ██████ │
+ * └────────────────────┘
+ * ← → move · ↑ rotate · space slam · q quit
+ * ```
+ *
+ * Returns a string with no trailing newline; `runGame` owns the cursor.
+ */
+export function frame({ title = "", status = "", rows = [], keys = "" } = {}) {
+ const body = rows.map((r) => String(r));
+ // The board sets the width. The header and the key line sit outside the box,
+ // so letting either of them stretch it is how a 20-column tetris well ends up
+ // in a 54-column frame.
+ const inner = Math.max(...body.map(visible), 20);
+ const gap = inner - visible(title) - visible(status);
+ const head = !visible(status) ? acid(title)
+ // Right-align the status to the box edge when there is room for it, and
+ // fall back to a caption rather than pushing the board around when a long
+ // status (chess, mid-game) would not fit.
+ : gap >= 2 ? pad(acid(title), inner - visible(status)) + ash(status)
+ : `${acid(title)} ${ash(status)}`;
+ const out = [
+ ` ${head}`,
+ ` ${ash(`┌${"─".repeat(inner + 2)}┐`)}`,
+ ...body.map((row) => ` ${ash("│")} ${pad(row, inner)} ${ash("│")}`),
+ ` ${ash(`└${"─".repeat(inner + 2)}┘`)}`,
+ ` ${ash(keys)}`,
+ ];
+ return out.join("\n");
+}
+
+/* --------------------------------------------------------------------- keys */
+
+/**
+ * Raw terminal bytes → key names the games understand.
+ *
+ * Games never see an escape sequence; they see "up", "enter", "a". A chunk can
+ * hold several keypresses (hold an arrow key down and they arrive in batches),
+ * which is why this returns a list.
+ */
+export function decodeKeys(chunk) {
+ const input = String(chunk);
+ const keys = [];
+ for (let i = 0; i < input.length; i++) {
+ const c = input[i];
+ if (c === "\x1b") {
+ const seq = input.slice(i, i + 3);
+ const arrow = { "\x1b[A": "up", "\x1b[B": "down", "\x1b[C": "right", "\x1b[D": "left" }[seq];
+ if (arrow) { keys.push(arrow); i += 2; continue; }
+ // A bare escape is a quit everywhere in the arcade; a longer sequence we
+ // don't know (mouse, function key) is swallowed rather than misread.
+ if (input[i + 1] === "[" || input[i + 1] === "O") { i += 2; continue; }
+ keys.push("escape");
+ continue;
+ }
+ if (c === "\r" || c === "\n") { keys.push("enter"); continue; }
+ if (c === " ") { keys.push("space"); continue; }
+ if (c === "\x03" || c === "\x04") { keys.push("quit"); continue; }
+ if (c === "\x7f" || c === "\b") { keys.push("backspace"); continue; }
+ if (c === "\t") { keys.push("tab"); continue; }
+ // vim keys, everywhere, for free — every game reads arrows, so mapping
+ // hjkl here means no game has to know about them.
+ const vim = { h: "left", j: "down", k: "up", l: "right" }[c];
+ if (vim) { keys.push(vim); continue; }
+ if (c >= " " && c <= "~") keys.push(c.toLowerCase());
+ }
+ return keys;
+}
+
+/* -------------------------------------------------------------------- list */
+
+/**
+ * `/games` with no argument: the cabinet, and how to start one.
+ *
+ * `prefix` is how the caller is spelled — the pit says `/games tetris` and a
+ * shell says `moshcode games tetris`, and printing the wrong one is how a list
+ * teaches somebody a command that does not work where they are standing.
+ */
+export function renderList({ prefix = "moshcode games" } = {}) {
+ const width = Math.max(...GAMES.map((g) => g.key.length));
+ return [
+ ` ${acid("moshcode arcade")} ${ash(`— ${GAMES.length} games, no menus, no options screens`)}`,
+ "",
+ ...GAMES.map((g) => ` ${bone(g.key.padEnd(width))} ${ash(g.blurb)}`),
+ "",
+ ` ${ash("play one:")} ${acid(`${prefix} ${GAMES[0].key}`)}`,
+ ` ${ash("every game: arrows move · q quits · r starts another")}`,
+ ].join("\n");
+}
+
+/** The same cabinet, for something that cannot read a terminal. */
+export function gamesModel() {
+ return {
+ games: GAMES.map((g) => ({
+ name: g.key,
+ aliases: g.aliases || [],
+ description: g.blurb,
+ keys: g.keys,
+ realtime: Boolean(g.tickMs),
+ })),
+ };
+}
+
+/* ------------------------------------------------------------------ driver */
+
+const ESC = {
+ hideCursor: "\x1b[?25l",
+ showCursor: "\x1b[?25h",
+ up: (n) => (n > 0 ? `\x1b[${n}A` : ""),
+ eraseDown: "\x1b[0J",
+};
+
+/**
+ * Play one game until `q`.
+ *
+ * Drawn in place rather than on the alternate screen, so the final board — the
+ * score, the checkmate, the word you didn't get — stays in the pit's scrollback
+ * where you can look at it. Redrawing is "jump back up over the frame and
+ * write it again", which is why every frame is the same height.
+ */
+export async function runGame(game, deps = {}) {
+ const {
+ input = process.stdin,
+ output = process.stdout,
+ rng = Math.random,
+ // Tests hand in their own clock so a "real-time" game can be played turn by
+ // turn, deterministically, with no timers left running after the assertion.
+ setTimer = (fn, ms) => setTimeout(fn, ms),
+ clearTimer = (t) => clearTimeout(t),
+ } = deps;
+
+ const ctx = { rng };
+ let state = game.create(ctx);
+ let height = 0;
+ let timer = null;
+ let closed = false;
+
+ let painted = null;
+ const draw = () => {
+ if (closed) return;
+ const text = frame({
+ title: game.title,
+ status: game.status(state),
+ rows: game.render(state),
+ keys: state.over ? `${game.keys} · ${bone("r")} again` : game.keys,
+ });
+ // A frame identical to the one already on the screen is not written at all.
+ // Chess idles on its clock while it is your move, and repainting the same
+ // board twice a second is exactly the flicker that would make it feel busy.
+ if (text === painted) return;
+ output.write(`${ESC.up(height)}${ESC.eraseDown}${text}\n`);
+ painted = text;
+ height = text.split("\n").length;
+ };
+
+ const stop = () => { if (timer !== null) { clearTimer(timer); timer = null; } };
+ const schedule = () => {
+ stop();
+ if (!game.tickMs || state.over) return;
+ const ms = typeof game.tickMs === "function" ? game.tickMs(state) : game.tickMs;
+ timer = setTimer(() => {
+ timer = null;
+ if (closed || state.over) return;
+ state = game.tick(state, ctx) || state;
+ draw();
+ schedule();
+ }, ms);
+ };
+
+ const wasRaw = Boolean(input.isRaw);
+ const restore = () => {
+ if (closed) return;
+ closed = true;
+ stop();
+ output.write(ESC.showCursor);
+ try { input.setRawMode?.(wasRaw); } catch { /* already gone */ }
+ input.off?.("data", onData);
+ input.pause?.();
+ };
+ const onSignal = () => { restore(); process.exit(130); };
+
+ function onData(chunk) {
+ for (const key of decodeKeys(chunk)) {
+ if (key === "quit" || key === "q" || key === "escape") { restore(); resolve(); return; }
+ if (key === "r" && (state.over || game.restartable !== false)) {
+ state = game.create(ctx);
+ draw();
+ schedule();
+ continue;
+ }
+ if (state.over) continue; // a finished board takes r and q, nothing else
+ state = game.onKey(state, key, ctx) || state;
+ draw();
+ // A key can end a real-time game (a hard drop into the ceiling) or start
+ // one moving again, so the clock is re-armed off every keypress.
+ if (game.tickMs) schedule();
+ }
+ }
+
+ let resolve;
+ const done = new Promise((res) => { resolve = res; });
+
+ output.write(ESC.hideCursor);
+ try { input.setRawMode?.(true); } catch { /* not a tty */ }
+ input.setEncoding?.("utf8");
+ input.resume?.();
+ input.on?.("data", onData);
+ process.on("SIGINT", onSignal);
+ process.on("SIGTERM", onSignal);
+
+ draw();
+ schedule();
+ await done;
+ restore();
+ process.off("SIGINT", onSignal);
+ process.off("SIGTERM", onSignal);
+ output.write(` ${ash("thanks for playing 🤘")}\n`);
+ return 0;
+}
+
+/* ----------------------------------------------------------------- command */
+
+/**
+ * `/games [name]` in the pit, `moshcode games [name]` from a shell.
+ *
+ * The two are the same call; only the exit code is read by the CLI. Listing
+ * works anywhere, including a pipe — starting a game does not, because raw mode
+ * is how every one of them reads a key.
+ */
+export async function gamesCommand(argv = [], deps = {}) {
+ const {
+ out = (s) => console.log(s),
+ fail = (s) => console.error(s),
+ input = process.stdin,
+ output = process.stdout,
+ interactive = Boolean(input.isTTY && output.isTTY),
+ prefix,
+ ...rest
+ } = deps;
+
+ const args = argv.filter((a) => a !== undefined && a !== null).map(String);
+ const json = args.includes("--json");
+ const positional = args.filter((a) => !a.startsWith("-"));
+ const [name] = positional;
+
+ if (json && (!name || name === "list")) { out(JSON.stringify(gamesModel(), null, 2)); return 0; }
+ if (!name || name === "list" || name === "ls" || name === "games") { out(renderList({ prefix })); return 0; }
+
+ const game = resolveGame(name);
+ if (!game) {
+ fail(`${danger("✗ ")}no game called "${name}". ${ash(`try: ${GAMES.map((g) => g.key).join(" · ")}`)}`);
+ return 1;
+ }
+ if (!interactive) {
+ fail(`${danger("✗ ")}${game.key} needs an interactive terminal — it reads single keypresses.`);
+ fail(`${ash("· ")}${ash("run it from the pit, or a real shell — `moshcode games list` works anywhere.")}`);
+ return 1;
+ }
+
+ return runGame(game, { input, output, ...rest });
+}
+
+/* Shared by more than one game, and kept here so they agree on what a wall or a
+ * hazard looks like. A game that invents its own palette stops looking like the
+ * arcade it is in. */
+export const PALETTE = {
+ wall: (s) => ash(s),
+ empty: (s) => dim(s),
+ you: (s) => acid(s),
+ prize: (s) => amber(s),
+ hazard: (s) => danger(s),
+ piece: (s) => bone(s),
+ cool: rgb(90, 200, 250),
+ violet: rgb(190, 130, 255),
+ rose: rgb(255, 120, 180),
+};
diff --git a/src/tui.mjs b/src/tui.mjs
index 4287b86..87a69ff 100644
--- a/src/tui.mjs
+++ b/src/tui.mjs
@@ -23,6 +23,7 @@ import { moshVocabulary } from "./commands.mjs";
import { mcpCommand, pluginCommand, skillCommand } from "./integrations.mjs";
import { stocksCommand } from "./advisor.mjs";
import { cryptoCommand } from "./crypto.mjs";
+import { gamesCommand } from "./games.mjs";
import { canOpenBrowser, openBrowser } from "./open-url.mjs";
import { banner, hr, acid, ash, bone, dim, ok, err, warn, info, moshcodeVersion } from "./ui.mjs";
import { CORE_CLI_COMMAND_NAMES } from "./cli-schema.mjs";
@@ -882,6 +883,18 @@ export async function tui() {
await pluginCommand(rest);
continue;
}
+ // The arcade (src/games.mjs). Takes the terminal the way an engine session
+ // does, because every game reads single keypresses and readline cannot hand
+ // those over while it owns stdin. Listing is just printing, so it keeps the
+ // prompt.
+ if (cmd === "games" || cmd === "game" || cmd === "arcade" || cmd === "play") {
+ const listing = !rest.length || rest[0] === "list" || rest[0] === "ls" || rest[0] === "--json";
+ if (listing) { await gamesCommand(rest, { prefix: "/games" }); continue; }
+ rl.close();
+ await gamesCommand(rest, { prefix: "/games" });
+ rl = mkrl();
+ continue;
+ }
if (cmd === "socials" || cmd === "social") {
printSocials();
continue;
diff --git a/src/ui.mjs b/src/ui.mjs
index 14fce4a..b214132 100644
--- a/src/ui.mjs
+++ b/src/ui.mjs
@@ -12,7 +12,9 @@ export function moshcodeVersion() {
}
const useColor = process.env.NO_COLOR == null && process.stdout.isTTY === true;
-const rgb = (r, g, b) => (s) => (useColor ? `\x1b[38;2;${r};${g};${b}m${s}\x1b[39m` : String(s));
+// Exported so a module with its own hues (the arcade's seven tetrominoes) mixes
+// them the same way, and honours NO_COLOR without knowing it exists.
+export const rgb = (r, g, b) => (s) => (useColor ? `\x1b[38;2;${r};${g};${b}m${s}\x1b[39m` : String(s));
const wrap = (o, c) => (s) => (useColor ? `\x1b[${o}m${s}\x1b[${c}m` : String(s));
export const acid = rgb(158, 240, 26);
diff --git a/test/games.test.mjs b/test/games.test.mjs
new file mode 100644
index 0000000..ccfd53a
--- /dev/null
+++ b/test/games.test.mjs
@@ -0,0 +1,637 @@
+// The arcade, played without a terminal.
+//
+// Every game is pure — a state, a key, a tick — which is what makes this
+// possible: these tests finish real games of tetris, pac-man and chess, win
+// hangman, and prove the tic-tac-toe opponent cannot be beaten, with no TTY and
+// no timers left running.
+import test from "node:test";
+import assert from "node:assert/strict";
+import { EventEmitter } from "node:events";
+
+import {
+ GAMES, decodeKeys, frame, gamesCommand, gamesModel, renderList, resolveGame, runGame, strip, visible,
+} from "../src/games.mjs";
+import {
+ TETRIS, SHAPES, clearLines, collides, emptyBoard, landing, rotate, HEIGHT as T_HEIGHT, WIDTH as T_WIDTH,
+} from "../src/games-tetris.mjs";
+import { SNAKE, WIDTH as S_WIDTH, HEIGHT as S_HEIGHT, step } from "../src/games-snake.mjs";
+import { PACMAN, MAZE, isWall, pellets, WIDTH as P_WIDTH, HEIGHT as P_HEIGHT } from "../src/games-pacman.mjs";
+import { TICTACTOE, bestMove, emptyBoard as emptyGrid, winner } from "../src/games-tictactoe.mjs";
+import { HANGMAN, MISSES_ALLOWED, WORDS, guess, mask } from "../src/games-hangman.mjs";
+import {
+ CHESS, allMoves, apply, chooseMove, inCheck, legalMoves, name, outcome, parseBoard,
+} from "../src/games-chess.mjs";
+
+/** A predictable rng — the games take one, so a test can replay a session. */
+const seeded = (seed = 1) => () => {
+ seed = (seed * 16807) % 2147483647;
+ return (seed - 1) / 2147483646;
+};
+
+/* ------------------------------------------------------------- the cabinet */
+
+test("every game satisfies the shape the driver expects", () => {
+ for (const game of GAMES) {
+ assert.match(game.key, /^[a-z]+$/, "a game key is what someone types");
+ for (const field of ["title", "blurb", "keys"]) {
+ assert.ok(game[field]?.length, `${game.key} has no ${field}`);
+ }
+ assert.equal(typeof game.create, "function", `${game.key} cannot start`);
+ assert.equal(typeof game.onKey, "function", `${game.key} ignores the keyboard`);
+ assert.equal(typeof game.render, "function");
+ assert.equal(typeof game.status, "function");
+ // A real-time game must have something for the clock to call.
+ if (game.tickMs) assert.equal(typeof game.tick, "function", `${game.key} ticks into nothing`);
+ // Every game explains its own controls, and every one of them takes q.
+ assert.match(game.keys, /q quit/, `${game.key} does not say how to leave`);
+ }
+});
+
+test("game names are unique, aliases included", () => {
+ const seen = new Set();
+ for (const game of GAMES) {
+ for (const label of [game.key, ...(game.aliases || [])]) {
+ assert.ok(!seen.has(label), `"${label}" names two games`);
+ seen.add(label);
+ }
+ }
+});
+
+test("a game resolves however it is spelled", () => {
+ assert.equal(resolveGame("tetris")?.key, "tetris");
+ assert.equal(resolveGame("/TETRIS")?.key, "tetris");
+ // The spelling in the request this was built from, and the one everybody
+ // types second.
+ assert.equal(resolveGame("tiktaktoe")?.key, "tictactoe");
+ assert.equal(resolveGame("tic-tac-toe")?.key, "tictactoe");
+ assert.equal(resolveGame("ttt")?.key, "tictactoe");
+ assert.equal(resolveGame("pac")?.key, "pacman");
+ assert.equal(resolveGame(""), null);
+ assert.equal(resolveGame("doom"), null);
+});
+
+test("the list names every game and how to start one", () => {
+ const listed = strip(renderList({ prefix: "/games" }));
+ for (const game of GAMES) {
+ assert.ok(listed.includes(game.key), `${game.key} is missing from the list`);
+ assert.ok(listed.includes(game.blurb), `${game.key} is listed with no blurb`);
+ }
+ assert.match(listed, /\/games tetris/, "the list should say how to play one");
+ assert.match(strip(renderList()), /moshcode games tetris/, "and how to from a shell");
+});
+
+test("the roster is available as data", () => {
+ const model = gamesModel();
+ assert.equal(model.games.length, GAMES.length);
+ assert.deepEqual(model.games.map((g) => g.name), GAMES.map((g) => g.key));
+ assert.equal(model.games.find((g) => g.name === "tetris").realtime, true);
+ assert.equal(model.games.find((g) => g.name === "chess").realtime, true);
+ assert.equal(model.games.find((g) => g.name === "hangman").realtime, false);
+});
+
+/* -------------------------------------------------------------- the frame */
+
+test("the frame is a rectangle, whatever the board is made of", () => {
+ const rows = ["ab", "a much longer row than that one", "c"];
+ const lines = frame({ title: "GAME", status: "score 3", rows, keys: "q quit" }).split("\n");
+ const boxed = lines.slice(1, -1); // between header and key line
+ const widths = new Set(boxed.map(visible));
+ assert.equal(widths.size, 1, `the box is ragged: ${[...widths].join(", ")}`);
+});
+
+test("the key line does not stretch the board", () => {
+ // The bug this replaces: a 20-column tetris well drawn inside a 54-column
+ // frame, because the footer was measured as if it were part of the box.
+ const narrow = frame({ title: "T", rows: ["####"], keys: "q quit" });
+ const wide = frame({ title: "T", rows: ["####"], keys: "a very long line of key hints indeed, look at it go" });
+ assert.equal(visible(narrow.split("\n")[1]), visible(wide.split("\n")[1]));
+});
+
+test("the status sits on the right when it fits, and beside the title when it does not", () => {
+ const roomy = frame({ title: "GAME", status: "ok", rows: ["x".repeat(40)] }).split("\n")[0];
+ assert.match(strip(roomy), /GAME {2,}ok$/);
+ const tight = frame({ title: "GAME", status: "a status far too long for this board", rows: ["xx"] }).split("\n")[0];
+ assert.match(strip(tight), /^ {2}GAME {2}a status/);
+});
+
+/* ---------------------------------------------------------------- the keys */
+
+test("raw bytes become key names", () => {
+ assert.deepEqual(decodeKeys("\x1b[A\x1b[B\x1b[C\x1b[D"), ["up", "down", "right", "left"]);
+ assert.deepEqual(decodeKeys("\r"), ["enter"]);
+ assert.deepEqual(decodeKeys(" "), ["space"]);
+ assert.deepEqual(decodeKeys("\x03"), ["quit"]);
+ assert.deepEqual(decodeKeys("\x1b"), ["escape"]);
+ assert.deepEqual(decodeKeys("Q"), ["q"], "shift is not a different key here");
+ // hjkl everywhere, for free.
+ assert.deepEqual(decodeKeys("hjkl"), ["left", "down", "up", "right"]);
+ // Holding a key down delivers several at once.
+ assert.deepEqual(decodeKeys("\x1b[Ax\x1b[A"), ["up", "x", "up"]);
+ // An unknown escape sequence is swallowed, not misread as three keys.
+ assert.deepEqual(decodeKeys("\x1b[Z"), []);
+});
+
+/* ------------------------------------------------------------------ tetris */
+
+test("a shape turns without changing size", () => {
+ assert.deepEqual(rotate(SHAPES.O), SHAPES.O, "a square is a square");
+ assert.deepEqual(rotate(SHAPES.I), ["..I.", "..I.", "..I.", "..I."]);
+ assert.deepEqual(rotate(rotate(rotate(rotate(SHAPES.T)))), SHAPES.T, "four turns is where you started");
+});
+
+test("a piece collides with the walls, the floor, and the stack", () => {
+ const board = emptyBoard();
+ assert.equal(collides(board, SHAPES.O, -1, 0), true, "off the left");
+ assert.equal(collides(board, SHAPES.O, T_WIDTH - 1, 0), true, "off the right");
+ assert.equal(collides(board, SHAPES.O, 0, T_HEIGHT - 1), true, "through the floor");
+ assert.equal(collides(board, SHAPES.O, 0, 0), false);
+ board[4][0] = "I";
+ assert.equal(collides(board, SHAPES.O, 0, 3), true, "into the stack");
+});
+
+test("full rows clear and everything above drops", () => {
+ const board = emptyBoard();
+ board[T_HEIGHT - 1] = Array.from({ length: T_WIDTH }, () => "I");
+ board[T_HEIGHT - 2][3] = "T";
+ assert.equal(clearLines(board), 1);
+ assert.equal(board[T_HEIGHT - 1][3], "T", "the row above fell into the gap");
+ assert.equal(board[0].every((c) => c === null), true, "and the top is empty again");
+});
+
+test("a slam drops the piece to its landing square and locks it", () => {
+ const state = TETRIS.create({ rng: seeded(3) });
+ const target = landing(state);
+ TETRIS.onKey(state, "space");
+ assert.notEqual(state.piece.y, target, "a lock spawns the next piece");
+ const filled = state.board.filter((row) => row.some(Boolean)).length;
+ assert.ok(filled > 0, "the slammed piece is part of the board now");
+ assert.ok(state.score > 0, "and a slam pays for the distance");
+});
+
+test("tetris ends when the stack reaches the ceiling", () => {
+ const state = TETRIS.create({ rng: seeded(7) });
+ for (let i = 0; i < 200 && !state.over; i++) TETRIS.onKey(state, "space");
+ assert.ok(state.over, "200 slammed pieces should fill a ten-wide well");
+ assert.equal(typeof TETRIS.status(state), "string");
+ assert.match(strip(TETRIS.status(state)), /score/);
+});
+
+test("clearing a line pays, and the level follows the lines", () => {
+ const state = TETRIS.create({ rng: seeded(11) });
+ state.board[T_HEIGHT - 1] = Array.from({ length: T_WIDTH }, (_, x) => (x < T_WIDTH - 2 ? "I" : null));
+ state.piece = { key: "O", shape: SHAPES.O, x: T_WIDTH - 2, y: 0 };
+ TETRIS.onKey(state, "space");
+ assert.equal(state.lines, 1);
+ assert.ok(state.score >= 100, `a cleared line should pay: ${state.score}`);
+});
+
+test("a piece rotating in the gutter kicks off the wall", () => {
+ const state = TETRIS.create({ rng: seeded(5) });
+ state.piece = { key: "I", shape: SHAPES.I, x: -1, y: 5 };
+ TETRIS.onKey(state, "up");
+ assert.equal(collides(state.board, state.piece.shape, state.piece.x, state.piece.y), false);
+});
+
+/* ------------------------------------------------------------------- snake */
+
+test("the snake grows on food and keeps its length otherwise", () => {
+ const state = SNAKE.create({ rng: seeded(2) });
+ const head = state.snake[0];
+ state.food = [head[0] + 1, head[1]];
+ const before = state.snake.length;
+ step(state);
+ assert.equal(state.snake.length, before + 1, "eating grows it");
+ assert.equal(state.score, 10);
+ step(state);
+ assert.equal(state.snake.length, before + 1, "and a plain step does not");
+});
+
+test("the snake dies on the wall and on itself", () => {
+ const wall = SNAKE.create({ rng: seeded(2) });
+ wall.snake = [[S_WIDTH - 1, 0]];
+ wall.dir = "right";
+ step(wall);
+ assert.match(wall.over, /wall/);
+
+ const self = SNAKE.create({ rng: seeded(2) });
+ self.snake = [[5, 5], [6, 5], [6, 6], [5, 6], [4, 6]];
+ self.dir = "down";
+ self.food = null;
+ step(self);
+ assert.match(self.over, /itself/);
+});
+
+test("the snake cannot reverse into its own neck", () => {
+ const state = SNAKE.create({ rng: seeded(2) });
+ SNAKE.onKey(state, "left"); // it is travelling right
+ assert.equal(state.dir, "right");
+ SNAKE.onKey(state, "up");
+ assert.equal(state.dir, "up");
+ SNAKE.onKey(state, "left");
+ assert.equal(state.dir, "up", "one turn per tick");
+});
+
+test("food never lands under the snake", () => {
+ const state = SNAKE.create({ rng: seeded(9) });
+ for (let i = 0; i < 300 && !state.over; i++) {
+ if (state.food) assert.ok(!state.snake.some(([x, y]) => x === state.food[0] && y === state.food[1]));
+ SNAKE.onKey(state, ["up", "right", "down", "left"][i % 4]);
+ step(state);
+ }
+});
+
+test("the snake board is drawn to size", () => {
+ const state = SNAKE.create({ rng: seeded(2) });
+ const rows = SNAKE.render(state);
+ assert.equal(rows.length, S_HEIGHT);
+ assert.equal(visible(rows[0]), S_WIDTH);
+});
+
+/* ------------------------------------------------------------------ pacman */
+
+test("the maze is one connected place — every dot is reachable", () => {
+ // A maze with a walled-off pocket is a game that cannot be won, and no
+ // amount of playing it by hand would reliably find the pocket.
+ const start = { x: MAZE.findIndex(() => true), y: 0 };
+ let pac = null;
+ for (let y = 0; y < P_HEIGHT; y++) {
+ const x = MAZE[y].indexOf("P");
+ if (x >= 0) pac = { x, y };
+ }
+ assert.ok(pac, "the maze must say where pac starts");
+ assert.ok(start);
+
+ const seen = new Set([`${pac.x},${pac.y}`]);
+ const queue = [pac];
+ while (queue.length) {
+ const { x, y } = queue.shift();
+ for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
+ const nx = x + dx;
+ const ny = y + dy;
+ const key = `${nx},${ny}`;
+ if (isWall(nx, ny) || seen.has(key)) continue;
+ seen.add(key);
+ queue.push({ x: nx, y: ny });
+ }
+ }
+ for (const key of pellets().keys()) {
+ assert.ok(seen.has(key), `the dot at ${key} is walled off from the start`);
+ }
+});
+
+test("the maze is a rectangle with a wall all the way round", () => {
+ for (const row of MAZE) assert.equal(row.length, P_WIDTH);
+ for (let x = 0; x < P_WIDTH; x++) {
+ assert.equal(MAZE[0][x], "#", "the top leaks");
+ assert.equal(MAZE[P_HEIGHT - 1][x], "#", "the bottom leaks");
+ }
+ for (const row of MAZE) {
+ assert.equal(row[0], "#");
+ assert.equal(row[P_WIDTH - 1], "#");
+ }
+});
+
+test("eating a dot scores, and a pellet makes the ghosts edible", () => {
+ const state = PACMAN.create({ rng: seeded(4) });
+ const before = state.dots.size;
+ PACMAN.onKey(state, "left");
+ PACMAN.tick(state);
+ assert.equal(state.dots.size, before - 1, "pac ate the dot it walked onto");
+ assert.equal(state.score, 10);
+
+ // Stand pac on a power pellet and let the tick eat it.
+ const pellet = [...state.dots.entries()].find(([, kind]) => kind === "o")[0];
+ const [px, py] = pellet.split(",").map(Number);
+ state.pac = { x: px + 1, y: py, dir: "left", want: "left" };
+ PACMAN.tick(state);
+ assert.ok(state.fright > 0, "the ghosts should be running");
+});
+
+test("a ghost costs a life, and the third one ends it", () => {
+ const state = PACMAN.create({ rng: seeded(6) });
+ state.lives = 1;
+ const ghost = state.ghosts[0];
+ state.pac = { x: ghost.x, y: ghost.y, dir: "left", want: "left" };
+ // The tick moves pac first; put a ghost where it is about to arrive.
+ state.ghosts[0] = { ...ghost, x: ghost.x - 1, y: ghost.y };
+ PACMAN.tick(state);
+ assert.equal(state.over, "game over");
+});
+
+test("eating the last dot wins the maze", () => {
+ const state = PACMAN.create({ rng: seeded(8) });
+ const next = { x: state.pac.x - 1, y: state.pac.y };
+ state.dots = new Map([[`${next.x},${next.y}`, "."]]);
+ PACMAN.tick(state);
+ assert.match(state.over, /cleared/);
+});
+
+test("a ghost never steps into a wall", () => {
+ const state = PACMAN.create({ rng: seeded(12) });
+ for (let i = 0; i < 400 && !state.over; i++) {
+ PACMAN.onKey(state, ["left", "up", "right", "down"][i % 4]);
+ PACMAN.tick(state);
+ for (const ghost of state.ghosts) {
+ assert.equal(isWall(ghost.x, ghost.y), false, `a ghost is inside a wall at ${ghost.x},${ghost.y}`);
+ }
+ assert.equal(isWall(state.pac.x, state.pac.y), false, "pac is inside a wall");
+ }
+});
+
+/* -------------------------------------------------------------- tictactoe */
+
+test("three in a row is spotted in every direction", () => {
+ assert.equal(winner(["X", "X", "X", null, null, null, null, null, null]), "X");
+ assert.equal(winner(["O", null, null, "O", null, null, "O", null, null]), "O");
+ assert.equal(winner(["X", null, null, null, "X", null, null, null, "X"]), "X");
+ assert.equal(winner(emptyGrid()), null);
+ assert.equal(winner(["X", "O", "X", "X", "O", "O", "O", "X", "X"]), "draw");
+});
+
+test("the opponent takes the win in front of it, and blocks the one against it", () => {
+ assert.equal(bestMove(["O", "O", null, "X", "X", null, null, null, null], "O", () => 0), 2, "take the win");
+ assert.equal(bestMove(["X", "X", null, "O", null, null, null, null, null], "O", () => 0), 2, "block the loss");
+});
+
+test("the opponent cannot be beaten", () => {
+ // 40 games of random play against the search. A single loss here means the
+ // minimax is wrong, and it is the sort of wrong nobody notices by playing.
+ const rng = seeded(13);
+ for (let game = 0; game < 40; game++) {
+ const board = emptyGrid();
+ for (;;) {
+ const open = board.map((c, i) => (c ? null : i)).filter((i) => i !== null);
+ if (!open.length || winner(board)) break;
+ board[open[Math.floor(rng() * open.length) % open.length]] = "X";
+ if (winner(board)) break;
+ const reply = bestMove(board, "O", rng);
+ if (reply == null) break;
+ board[reply] = "O";
+ }
+ assert.notEqual(winner(board), "X", `random play beat the machine: ${board.join(",")}`);
+ }
+});
+
+test("marking a square answers immediately, and a taken square is refused", () => {
+ const state = TICTACTOE.create();
+ state.cursor = 0;
+ TICTACTOE.onKey(state, "enter", { rng: seeded(3) });
+ assert.equal(state.board[0], "X");
+ assert.equal(state.board.filter((c) => c === "O").length, 1, "the machine replied");
+ const before = state.board.slice();
+ TICTACTOE.onKey(state, "enter", { rng: seeded(3) });
+ assert.deepEqual(state.board, before, "you cannot play a square twice");
+});
+
+test("the cursor wraps rather than sticking to an edge", () => {
+ const state = TICTACTOE.create();
+ state.cursor = 0;
+ TICTACTOE.onKey(state, "left");
+ assert.equal(state.cursor, 2);
+ TICTACTOE.onKey(state, "up");
+ assert.equal(state.cursor, 8);
+});
+
+/* ---------------------------------------------------------------- hangman */
+
+test("a right letter is revealed and a wrong one costs a limb", () => {
+ const state = { word: "kernel", guessed: new Set(), missed: [], over: null };
+ guess(state, "e");
+ assert.equal(mask(state).join(""), "_E__E_");
+ guess(state, "z");
+ assert.deepEqual(state.missed, ["z"]);
+ guess(state, "z");
+ assert.deepEqual(state.missed, ["z"], "a repeat is free and tells you nothing");
+ guess(state, "3");
+ guess(state, "enter");
+ assert.deepEqual(state.missed, ["z"], "only letters count");
+});
+
+test("hangman is winnable and losable", () => {
+ const won = { word: "mosh", guessed: new Set(), missed: [], over: null };
+ for (const c of "mosh") guess(won, c);
+ assert.match(won.over, /got it/);
+
+ const lost = { word: "mosh", guessed: new Set(), missed: [], over: null };
+ for (const c of "bcdfgj".slice(0, MISSES_ALLOWED)) guess(lost, c);
+ assert.match(lost.over, /hanged/);
+ assert.match(lost.over, /MOSH/, "a loss shows the word");
+ assert.ok(strip(HANGMAN.render(lost).join("\n")).includes("☹"), "and finishes the gallows");
+});
+
+test("every hangman word is guessable with the keys the game offers", () => {
+ for (const word of WORDS) {
+ assert.match(word, /^[a-z]+$/, `"${word}" has a character nobody can type at it`);
+ }
+});
+
+/* ------------------------------------------------------------------ chess */
+
+test("a game of chess opens with twenty legal moves", () => {
+ const state = CHESS.create();
+ assert.equal(allMoves(state).length, 20);
+ assert.deepEqual(legalMoves(state, 52).map((m) => name(m.to)).sort(), ["e3", "e4"]);
+});
+
+test("castling is offered, moves the rook, and is refused through check", () => {
+ const open = { board: parseBoard("4k3/8/8/8/8/8/8/R3K2R"), turn: "w", castling: { K: true, Q: true }, ep: null };
+ const both = legalMoves(open, 60).filter((m) => m.castle);
+ assert.equal(both.length, 2, "both sides should be available");
+ const after = apply(open, both.find((m) => m.castle === "K"));
+ assert.equal(after.board[62], "K");
+ assert.equal(after.board[61], "R", "the rook came with it");
+ assert.equal(after.board[63], null);
+ assert.equal(after.castling.K, false, "and the right is spent");
+
+ const watched = { ...open, board: parseBoard("4kr2/8/8/8/8/8/8/R3K2R") };
+ assert.equal(legalMoves(watched, 60).some((m) => m.castle === "K"), false, "not through an attacked square");
+});
+
+test("en passant captures the pawn that ran past", () => {
+ const state = { board: parseBoard("4k3/8/8/3pP3/8/8/8/4K3"), turn: "w", castling: {}, ep: 19 };
+ const ep = legalMoves(state, 28).find((m) => m.enPassant);
+ assert.ok(ep, "the capture should be on offer");
+ const after = apply(state, ep);
+ assert.equal(after.board[19], "P");
+ assert.equal(after.board[27], null, "the black pawn is gone");
+});
+
+test("a pawn reaching the far rank becomes a queen", () => {
+ const state = { board: parseBoard("4k3/P7/8/8/8/8/8/4K3"), turn: "w", castling: {}, ep: null };
+ const promo = legalMoves(state, 8).find((m) => m.promotion);
+ assert.equal(apply(state, promo).board[0], "Q");
+});
+
+test("a king may not walk into check, or stay in it", () => {
+ const state = { board: parseBoard("4k3/8/8/8/8/8/4r3/4K3"), turn: "w", castling: {}, ep: null };
+ assert.equal(inCheck(state, true), true);
+ for (const move of allMoves(state, true)) {
+ assert.equal(inCheck(apply(state, move), true), false, `${name(move.from)}-${name(move.to)} leaves the king in check`);
+ }
+});
+
+test("checkmate and stalemate are told apart", () => {
+ let mate = CHESS.create();
+ for (const [from, to] of [[53, 45], [12, 28], [54, 38], [3, 39]]) {
+ mate = apply(mate, legalMoves(mate, from).find((m) => m.to === to));
+ }
+ assert.equal(outcome(mate), "checkmate");
+
+ const stuck = { board: parseBoard("7k/5Q2/6K1/8/8/8/8/8"), turn: "b", castling: {}, ep: null };
+ assert.equal(inCheck(stuck, false), false);
+ assert.equal(outcome(stuck), "stalemate");
+});
+
+test("the machine takes a piece left hanging", () => {
+ // Black to move, white queen on d5 undefended, and a knight on c3 that can
+ // reach it.
+ const state = { board: parseBoard("4k3/8/8/3Q4/8/2n5/8/4K3"), turn: "b", castling: {}, ep: null };
+ const move = chooseMove(state, { depth: 2, rng: seeded(3) });
+ assert.equal(name(move.to), "d5", `it should take the queen, played ${name(move.from)}-${name(move.to)}`);
+});
+
+test("picking a piece up shows where it can go, and putting it down moves it", () => {
+ const state = CHESS.create();
+ state.cursor = 52; // e2
+ CHESS.onKey(state, "enter");
+ assert.equal(state.selected, 52);
+ assert.equal(state.targets.length, 2);
+ state.cursor = 36; // e4
+ CHESS.onKey(state, "enter");
+ assert.equal(state.board[36], "P");
+ assert.equal(state.board[52], null);
+ assert.equal(state.turn, "b", "and it is the machine's move");
+ assert.equal(state.selected, null);
+
+ // The reply comes on the clock, so the player's own move draws first.
+ CHESS.tick(state, { rng: seeded(4) });
+ assert.equal(state.turn, "w");
+ assert.equal(state.played.length, 2);
+});
+
+test("a piece can be put back down without moving", () => {
+ const state = CHESS.create();
+ state.cursor = 52;
+ CHESS.onKey(state, "enter");
+ CHESS.onKey(state, "enter"); // same square
+ assert.equal(state.selected, null);
+ assert.equal(state.board[52], "P", "the pawn never left");
+ assert.equal(state.turn, "w");
+});
+
+test("black's pieces are lower case, so the board reads without colour", () => {
+ const rows = CHESS.render(CHESS.create()).map(strip);
+ assert.match(rows[0], /r {2}n {2}b {2}q {2}k/, "rank 8 is black");
+ assert.match(rows[7], /R {2}N {2}B {2}Q {2}K/, "rank 1 is white");
+});
+
+/* ----------------------------------------------------------- the command */
+
+test("with no game named, the command lists them", async () => {
+ const lines = [];
+ assert.equal(await gamesCommand([], { out: (s) => lines.push(s), interactive: false }), 0);
+ assert.match(strip(lines.join("\n")), /moshcode arcade/);
+});
+
+test("--json prints the roster and nothing else", async () => {
+ const lines = [];
+ await gamesCommand(["--json"], { out: (s) => lines.push(s), interactive: false });
+ const parsed = JSON.parse(lines.join("\n"));
+ assert.equal(parsed.games.length, GAMES.length);
+});
+
+test("an unknown game names the ones that exist", async () => {
+ const errors = [];
+ const code = await gamesCommand(["doom"], { out: () => {}, fail: (s) => errors.push(s), interactive: false });
+ assert.equal(code, 1);
+ assert.match(strip(errors.join("\n")), /no game called "doom"/);
+ assert.match(strip(errors.join("\n")), /tetris/);
+});
+
+test("a game refuses to start where there is no keyboard", async () => {
+ const errors = [];
+ const code = await gamesCommand(["tetris"], { out: () => {}, fail: (s) => errors.push(s), interactive: false });
+ assert.equal(code, 1);
+ assert.match(strip(errors.join("\n")), /interactive terminal/);
+});
+
+/* ------------------------------------------------------------- the driver */
+
+/** A stdin that is not a terminal, and an stdout that is a string. */
+function fakeIO() {
+ const input = new EventEmitter();
+ input.setRawMode = () => {};
+ input.setEncoding = () => {};
+ input.resume = () => {};
+ input.pause = () => {};
+ input.off = input.removeListener;
+ const written = [];
+ return { input, output: { write: (s) => written.push(s) }, written };
+}
+
+test("q leaves the game, and the last frame stays on the screen", async () => {
+ const { input, output, written } = fakeIO();
+ const done = runGame(TICTACTOE, { input, output, rng: seeded(3) });
+ await new Promise((r) => setImmediate(r));
+ input.emit("data", "q");
+ assert.equal(await done, 0);
+ const screen = strip(written.join(""));
+ assert.match(screen, /TIC-TAC-TOE/);
+ assert.match(screen, /thanks for playing/);
+ assert.equal(written.join("").includes("\x1b[?25h"), true, "the cursor comes back");
+});
+
+test("keys reach the game and the board is redrawn", async () => {
+ const { input, output, written } = fakeIO();
+ const done = runGame(TICTACTOE, { input, output, rng: seeded(3) });
+ await new Promise((r) => setImmediate(r));
+ const before = written.length;
+ input.emit("data", "\x1b[A");
+ assert.ok(written.length > before, "a keypress should repaint");
+ input.emit("data", "q");
+ await done;
+});
+
+test("an identical frame is not repainted", async () => {
+ const { input, output, written } = fakeIO();
+ const done = runGame(HANGMAN, { input, output, rng: seeded(3) });
+ await new Promise((r) => setImmediate(r));
+ const before = written.length;
+ input.emit("data", "\t"); // a key hangman does nothing with
+ assert.equal(written.length, before, "nothing changed, so nothing was drawn");
+ input.emit("data", "q");
+ await done;
+});
+
+test("a real-time game runs on the clock it is given, and stops when it ends", async () => {
+ const { input, output } = fakeIO();
+ let fire = null;
+ const done = runGame(SNAKE, {
+ input,
+ output,
+ rng: seeded(5),
+ setTimer: (fn) => { fire = fn; return 1; },
+ clearTimer: () => { fire = null; },
+ });
+ await new Promise((r) => setImmediate(r));
+ assert.equal(typeof fire, "function", "the game should have armed its clock");
+ for (let i = 0; i < 40 && fire; i++) {
+ const next = fire;
+ fire = null;
+ next();
+ }
+ // Twenty-eight columns of board and a snake pointed at the wall: by now it
+ // has either crashed (clock stopped) or is still going with the clock armed.
+ input.emit("data", "q");
+ assert.equal(await done, 0);
+});
+
+test("r starts another game once the last one is over", async () => {
+ const { input, output } = fakeIO();
+ const done = runGame(HANGMAN, { input, output, rng: seeded(21) });
+ await new Promise((r) => setImmediate(r));
+ input.emit("data", "bcdfgjkmpqvwxz".slice(0, 12)); // wrong letters, mostly
+ input.emit("data", "r");
+ input.emit("data", "\x03");
+ assert.equal(await done, 0);
+});