diff --git a/README.md b/README.md
index f078a9a..8ceea6f 100644
--- a/README.md
+++ b/README.md
@@ -50,7 +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 — eight games, no menus |
+| `moshcode games`
`game` `arcade` | arcade | the moshcode arcade — twenty-two 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 |
@@ -584,7 +584,7 @@ the composer URL instead.
## The arcade (`/games`)
-Eight games, in the pit or straight from a shell. There are no menus, no options
+Twenty-two 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
@@ -611,7 +611,21 @@ moshcode games --json # the roster, for a machine
| `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 |
+| `invaders` | forty of them, and the last one moves fastest |
+| `centipede` | shoot it in the middle and now there are two of them |
| `asteroids` | turn, thrust, shoot — every rock you break becomes two |
+| `breakout` | dig a channel up the side and let the ball do the rest |
+| `pong` | first to seven, and the angle is all in where you hit it |
+| `tank` | two tanks, one yard, five hits — line it up and let go |
+| `digdug` | dig the tunnels, pump the monsters, drop rocks on the rest |
+| `frogger` | the road kills what it touches, the river kills what it doesn't |
+| `kong` | five girders, four ladders, and a barrel with your name on it |
+| `pitfall` | jump the logs, swing the pits, and get the gold before dark |
+| `choplifter` | fly out, land, fill the back, and get them home |
+| `spyhunter` | keep it on the tarmac, shoot the ones shooting back |
+| `outrun` | a road that bends, traffic that doesn't, and a clock that always wins |
+| `excitebike` | turbo until it cooks, and land the way you took off |
+| `stagedive` | run the barricade, hop the gear, duck the crowd, take the picks |
| `tictactoe` | three in a row against an opponent that cannot be beaten |
| `blackjack` | hit, stand, double, split — dealer stands on 17, and pays 3:2 |
| `chess` | full rules — castling, en passant, promotion — and it plays back |
diff --git a/src/cli-schema.mjs b/src/cli-schema.mjs
index 7fae237..7359a58 100644
--- a/src/cli-schema.mjs
+++ b/src/cli-schema.mjs
@@ -396,7 +396,7 @@ export const CORE_CLI_COMMANDS = [
{
name: "games",
group: "arcade",
- description: "the moshcode arcade — eight games, no menus",
+ description: "the moshcode arcade — twenty-two games, no menus",
synopsis: [
["moshcode games", "the cabinet, and what each one is"],
["moshcode games ", "play it, right here in the terminal"],
@@ -409,6 +409,8 @@ export const CORE_CLI_COMMANDS = [
["moshcode games pacman", "dots, ghosts, three lives"],
["moshcode games asteroids", "turn, thrust, shoot"],
["moshcode games 21", "blackjack, 100 chips, 3:2"],
+ ["moshcode games invaders", "forty of them, coming down"],
+ ["moshcode games stagedive", "jump the gear, take the picks"],
],
seeAlso: ["help"],
note: "every game works the same way: arrows move, q quits, r starts another. "
@@ -932,7 +934,7 @@ export const PIT_COMMANDS = [
{ 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, asteroids, blackjack, chess and more" },
+ description: "the arcade — tetris, invaders, pac-man, frogger, kong, outrun, chess and more" },
{ name: "socials", aliases: ["social"], pitOnly: true,
description: "list social networks available for posting" },
{ name: "post", args: ' "message"', pitOnly: true,
diff --git a/src/games-breakout.mjs b/src/games-breakout.mjs
new file mode 100644
index 0000000..798e21b
--- /dev/null
+++ b/src/games-breakout.mjs
@@ -0,0 +1,186 @@
+// Breakout. A wall, a paddle, and one ball that is always your fault.
+//
+// The bounce off the paddle is not a mirror: where the ball lands on the paddle
+// decides the angle it leaves at, so the paddle is a steering wheel rather than
+// a wall. Without that you cannot dig a channel up the side of the wall, and
+// digging a channel is the entire reason anybody still plays this.
+import { acid, amber, bone, danger, rgb } from "./ui.mjs";
+
+export const WIDTH = 40;
+export const HEIGHT = 17;
+
+export const BRICK_W = 4;
+export const BRICK_COLS = WIDTH / BRICK_W; // 10
+export const BRICK_ROWS = 5;
+export const BRICK_TOP = 1;
+
+export const PADDLE_W = 7;
+export const PADDLE_ROW = HEIGHT - 1;
+const PADDLE_STEP = 2;
+
+const LIVES = 3;
+const BASE_VX = 0.62;
+const BASE_VY = 0.34; // rows per tick — half of vx, because a row is two columns
+const SPIN = 0.5;
+const LEVEL_UP = 1.12;
+
+/** Top rows are worth more, which is what makes the ball worth risking. */
+export const ROW_POINTS = [50, 40, 30, 20, 10];
+const ROW_COLOR = [danger, amber, acid, rgb(90, 200, 250), rgb(190, 130, 255)];
+
+const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
+
+/** A full wall: every brick standing. */
+export const buildWall = () => Array.from({ length: BRICK_ROWS }, () => Array.from({ length: BRICK_COLS }, () => true));
+
+export const bricksLeft = (wall) => wall.reduce((n, row) => n + row.filter(Boolean).length, 0);
+
+/** The brick under a cell, or null. */
+export function brickAt(wall, x, y) {
+ const row = y - BRICK_TOP;
+ if (row < 0 || row >= BRICK_ROWS) return null;
+ const col = Math.floor(x / BRICK_W);
+ if (col < 0 || col >= BRICK_COLS || !wall[row]?.[col]) return null;
+ return { row, col };
+}
+
+/** The ball sitting on the paddle, waiting for space. */
+function rest(state) {
+ state.ball = { x: state.paddle + PADDLE_W / 2, y: PADDLE_ROW - 1, vx: 0, vy: 0 };
+ state.stuck = true;
+ return state;
+}
+
+export function launch(state) {
+ if (!state.stuck) return state;
+ state.stuck = false;
+ state.ball.vx = (state.rng() < 0.5 ? -1 : 1) * BASE_VX * state.pace;
+ state.ball.vy = -BASE_VY * state.pace;
+ return state;
+}
+
+/** One tick. Exported so a test can clear a whole wall with no clock. */
+export function step(state) {
+ if (state.stuck) {
+ // A ball that has not been launched rides the paddle, so moving before you
+ // serve aims the serve.
+ state.ball.x = state.paddle + PADDLE_W / 2;
+ return state;
+ }
+
+ const ball = state.ball;
+ const wasCol = Math.round(ball.x);
+ const wasRow = Math.round(ball.y);
+ ball.x += ball.vx;
+ ball.y += ball.vy;
+
+ if (ball.x < 0) { ball.x = -ball.x; ball.vx = Math.abs(ball.vx); }
+ if (ball.x > WIDTH - 1) { ball.x = 2 * (WIDTH - 1) - ball.x; ball.vx = -Math.abs(ball.vx); }
+ if (ball.y < 0) { ball.y = -ball.y; ball.vy = Math.abs(ball.vy); }
+
+ const col = Math.round(ball.x);
+ const row = Math.round(ball.y);
+ const brick = brickAt(state.wall, col, row);
+ if (brick) {
+ state.wall[brick.row][brick.col] = false;
+ state.score += ROW_POINTS[brick.row];
+ // Which way it bounces depends on which way it came in: through a row means
+ // the ball flips vertically, along a row means it flips sideways.
+ if (row !== wasRow) ball.vy = -ball.vy;
+ else if (col !== wasCol) ball.vx = -ball.vx;
+ else ball.vy = -ball.vy;
+ if (!bricksLeft(state.wall)) return cleared(state);
+ }
+
+ if (ball.vy > 0 && ball.y >= PADDLE_ROW - 1) {
+ const off = ball.x - (state.paddle + (PADDLE_W - 1) / 2);
+ if (Math.abs(off) <= PADDLE_W / 2 + 0.5) {
+ ball.y = PADDLE_ROW - 1;
+ ball.vy = -Math.abs(ball.vy);
+ // The steering wheel: the further out you take it, the flatter it leaves.
+ ball.vx = clamp(ball.vx + (off / (PADDLE_W / 2)) * SPIN * 0.5, -1.4, 1.4);
+ if (Math.abs(ball.vx) < 0.15) ball.vx = ball.vx < 0 ? -0.15 : 0.15;
+ }
+ }
+
+ if (ball.y > PADDLE_ROW) {
+ state.lives--;
+ if (state.lives <= 0) {
+ state.lives = 0;
+ state.over = `out of balls · ${state.score} points`;
+ return state;
+ }
+ rest(state);
+ }
+ return state;
+}
+
+function cleared(state) {
+ state.level++;
+ state.pace *= LEVEL_UP;
+ state.wall = buildWall();
+ state.score += 100;
+ return rest(state);
+}
+
+export const BREAKOUT = {
+ key: "breakout",
+ aliases: ["arkanoid", "wall"],
+ title: "BREAKOUT",
+ blurb: "dig a channel up the side and let the ball do the rest",
+ keys: "← → paddle · space launch · q quit",
+ tickMs: 50,
+
+ create({ rng = Math.random } = {}) {
+ const state = {
+ wall: buildWall(),
+ paddle: Math.floor((WIDTH - PADDLE_W) / 2),
+ score: 0,
+ lives: LIVES,
+ level: 1,
+ pace: 1,
+ over: null,
+ rng,
+ };
+ return rest(state);
+ },
+
+ tick: step,
+
+ onKey(state, key) {
+ if (key === "left") state.paddle = clamp(state.paddle - PADDLE_STEP, 0, WIDTH - PADDLE_W);
+ else if (key === "right") state.paddle = clamp(state.paddle + PADDLE_STEP, 0, WIDTH - PADDLE_W);
+ else if (key === "space" || key === "up" || key === "enter") launch(state);
+ return state;
+ },
+
+ status(state) {
+ return state.over
+ ? state.over
+ : `${state.score} · level ${state.level} · ${"●".repeat(state.lives)}`;
+ },
+
+ render(state) {
+ const grid = Array.from({ length: HEIGHT }, () => Array.from({ length: WIDTH }, () => null));
+ const put = (x, y, glyph) => {
+ if (x < 0 || x >= WIDTH || y < 0 || y >= HEIGHT) return;
+ grid[y][x] = glyph;
+ };
+
+ for (let row = 0; row < BRICK_ROWS; row++) {
+ for (let col = 0; col < BRICK_COLS; col++) {
+ if (!state.wall[row][col]) continue;
+ // A brick is drawn exactly as wide as it is hit, with a seam so the wall
+ // reads as bricks rather than as one solid slab.
+ for (let i = 0; i < BRICK_W; i++) {
+ put(col * BRICK_W + i, BRICK_TOP + row, ROW_COLOR[row](i === BRICK_W - 1 ? "▓" : "█"));
+ }
+ }
+ }
+
+ for (let i = 0; i < PADDLE_W; i++) put(state.paddle + i, PADDLE_ROW, bone("▀"));
+ put(Math.round(state.ball.x), Math.round(state.ball.y), state.stuck ? amber("●") : bone("●"));
+
+ return grid.map((row) => row.map((cell) => cell ?? " ").join(""));
+ },
+};
diff --git a/src/games-centipede.mjs b/src/games-centipede.mjs
new file mode 100644
index 0000000..2bd73a1
--- /dev/null
+++ b/src/games-centipede.mjs
@@ -0,0 +1,241 @@
+// Centipede. It comes down through the mushrooms, and every piece you shoot out
+// of the middle leaves you two of them.
+//
+// The trick that makes this cheap: the centipede is not a linked body, it is a
+// list of segments that each obey the same rule — walk sideways, and when
+// something is in the way, drop a row and turn round. Kept that way, a shot to
+// the middle needs no surgery at all. You remove one segment and the ones
+// behind it simply carry on, which is exactly what splitting looks like.
+import { acid, amber, danger, dim, rgb } from "./ui.mjs";
+
+export const WIDTH = 38;
+export const HEIGHT = 18;
+
+/** The bottom strip is yours; the centipede comes down into it. */
+export const ZONE_TOP = HEIGHT - 5;
+const MUSHROOM_HP = 4;
+const LIVES = 3;
+const SHOTS = 3;
+const SHOT_SPEED = 2;
+
+const shroom = rgb(120, 190, 40);
+const MUSH_ART = ["▁", "▄", "▆", "█"]; // fuller the healthier
+
+const key = (x, y) => `${x},${y}`;
+
+/** A field of mushrooms, none of them in the row you stand on. */
+export function seedField(rng, count = 34) {
+ const field = new Map();
+ for (let i = 0; i < count; i++) {
+ const x = Math.floor(rng() * WIDTH);
+ const y = 1 + Math.floor(rng() * (HEIGHT - 3));
+ if (y >= HEIGHT - 1) continue;
+ field.set(key(x, y), MUSHROOM_HP);
+ }
+ return field;
+}
+
+/** Chip a mushroom, and say whether one was there. A dead one is worth points. */
+export function bite(field, x, y) {
+ const hp = field.get(key(x, y));
+ if (!hp) return 0;
+ if (hp <= 1) { field.delete(key(x, y)); return 5; }
+ field.set(key(x, y), hp - 1);
+ return 1;
+}
+
+/** A fresh centipede, strung out along the top row. */
+export function newCentipede(length = 10) {
+ return Array.from({ length }, (_, i) => ({ x: length - 1 - i, y: 0, dir: 1, down: 1 }));
+}
+
+const blocked = (state, x, y) => x < 0 || x >= WIDTH || state.field.has(key(x, y));
+
+/** Walk one segment: sideways if it can, otherwise down a row and about turn. */
+export function walk(state, seg) {
+ if (!blocked(state, seg.x + seg.dir, seg.y)) { seg.x += seg.dir; return seg; }
+ seg.dir *= -1;
+ seg.y += seg.down;
+ // It bounces off the floor and climbs back up rather than vanishing, which is
+ // what keeps the bottom of the board dangerous instead of a safe corner.
+ if (seg.y >= HEIGHT - 1) { seg.y = HEIGHT - 1; seg.down = -1; }
+ if (seg.y <= 0) { seg.y = 0; seg.down = 1; }
+ return seg;
+}
+
+/** How many ticks between steps — shorter as the wave goes on. */
+export const cadence = (state) => Math.max(2, 7 - state.wave);
+
+/**
+ * The spider: in from the side, bouncing diagonally through your strip, eating
+ * mushrooms as it goes.
+ *
+ * It is here because without it the bottom of the board is safe. The centipede
+ * only reaches your row on the sweeps it happens to end on, so a player who
+ * simply never moves can survive a long time — which is not a game. The spider
+ * is the reason you cannot stand still.
+ */
+export function spiderStep(state) {
+ const spider = state.spider;
+ if (!spider) return state;
+ spider.x += spider.dx;
+ spider.y += spider.dy;
+ if (spider.y < ZONE_TOP) { spider.y = ZONE_TOP; spider.dy = 1; }
+ if (spider.y > HEIGHT - 1) { spider.y = HEIGHT - 1; spider.dy = -1; }
+ // It leaves the way it came in rather than turning round at the wall, so it
+ // is a visitor and not a permanent resident.
+ if (spider.x < 0 || spider.x >= WIDTH) { state.spider = null; return state; }
+ state.field.delete(key(spider.x, spider.y));
+ return state;
+}
+
+export function spawnSpider(state) {
+ const fromLeft = state.rng() < 0.5;
+ state.spider = {
+ x: fromLeft ? 0 : WIDTH - 1,
+ y: HEIGHT - 1 - Math.floor(state.rng() * 3),
+ dx: fromLeft ? 1 : -1,
+ dy: state.rng() < 0.5 ? -1 : 1,
+ };
+ return state;
+}
+
+function hitPlayer(state) {
+ state.lives--;
+ if (state.lives <= 0) {
+ state.lives = 0;
+ state.over = `eaten · ${state.score} points`;
+ return state;
+ }
+ state.centipede = newCentipede(10);
+ state.shots = [];
+ state.spider = null;
+ state.player = { x: Math.floor(WIDTH / 2), y: HEIGHT - 1 };
+ return state;
+}
+
+/** One tick. Exported so a test can clear a wave with no clock. */
+export function step(state) {
+ for (let i = 0; i < SHOT_SPEED; i++) {
+ for (const shot of [...state.shots]) {
+ shot.y -= 1;
+ if (shot.y < 0) { state.shots = state.shots.filter((s) => s !== shot); continue; }
+ const points = bite(state.field, shot.x, shot.y);
+ if (points) { state.score += points; state.shots = state.shots.filter((s) => s !== shot); continue; }
+ if (state.spider && state.spider.x === shot.x && state.spider.y === shot.y) {
+ state.spider = null;
+ state.shots = state.shots.filter((s) => s !== shot);
+ state.score += 300;
+ continue;
+ }
+ const seg = state.centipede.find((s) => s.x === shot.x && s.y === shot.y);
+ if (!seg) continue;
+ state.shots = state.shots.filter((s) => s !== shot);
+ state.centipede = state.centipede.filter((s) => s !== seg);
+ state.score += 10;
+ // Every piece you take out of it leaves a mushroom where it fell, which
+ // is how the field thickens and the next wave gets harder for free.
+ state.field.set(key(seg.x, seg.y), MUSHROOM_HP);
+ }
+ }
+
+ if (!state.centipede.length) {
+ state.wave++;
+ state.centipede = newCentipede(10);
+ state.score += 100;
+ return state;
+ }
+
+ state.clock++;
+ if (state.clock >= cadence(state)) {
+ state.clock = 0;
+ for (const seg of state.centipede) walk(state, seg);
+ if (state.centipede.some((s) => s.x === state.player.x && s.y === state.player.y)) return hitPlayer(state);
+ }
+
+ state.spiderClock++;
+ if (state.spiderClock % 3 === 0) {
+ spiderStep(state);
+ const spider = state.spider;
+ if (spider && spider.x === state.player.x && spider.y === state.player.y) return hitPlayer(state);
+ }
+ if (!state.spider && state.rng() < 0.02) spawnSpider(state);
+ return state;
+}
+
+export const CENTIPEDE = {
+ key: "centipede",
+ aliases: ["cent", "bug", "millipede"],
+ title: "CENTIPEDE",
+ blurb: "shoot it in the middle and now there are two of them",
+ keys: "← ↑ ↓ → move · space fire · q quit",
+ tickMs: 55,
+
+ create({ rng = Math.random } = {}) {
+ return {
+ field: seedField(rng),
+ centipede: newCentipede(10),
+ player: { x: Math.floor(WIDTH / 2), y: HEIGHT - 1 },
+ shots: [],
+ spider: null,
+ spiderClock: 0,
+ score: 0,
+ lives: LIVES,
+ wave: 1,
+ clock: 0,
+ over: null,
+ rng,
+ };
+ },
+
+ tick: step,
+
+ onKey(state, pressed) {
+ const p = state.player;
+ // You are free in the bottom strip and nowhere else — the whole game is
+ // fought in five rows.
+ if (pressed === "left") p.x = Math.max(0, p.x - 1);
+ else if (pressed === "right") p.x = Math.min(WIDTH - 1, p.x + 1);
+ else if (pressed === "up") p.y = Math.max(ZONE_TOP, p.y - 1);
+ else if (pressed === "down") p.y = Math.min(HEIGHT - 1, p.y + 1);
+ else if (pressed === "space" || pressed === "enter") {
+ if (state.shots.length < SHOTS) state.shots.push({ x: p.x, y: p.y - 1 });
+ return state;
+ }
+ // Walking into a mushroom is walking into a wall.
+ if (state.field.has(key(p.x, p.y))) {
+ if (pressed === "left") p.x += 1;
+ else if (pressed === "right") p.x -= 1;
+ else if (pressed === "up") p.y += 1;
+ else if (pressed === "down") p.y -= 1;
+ }
+ return state;
+ },
+
+ status(state) {
+ return state.over
+ ? state.over
+ : `${state.score} · wave ${state.wave} · ${"▲".repeat(state.lives)}`;
+ },
+
+ render(state) {
+ const grid = Array.from({ length: HEIGHT }, () => Array.from({ length: WIDTH }, () => null));
+ const put = (x, y, glyph) => {
+ if (x < 0 || x >= WIDTH || y < 0 || y >= HEIGHT) return;
+ grid[y][x] = glyph;
+ };
+
+ for (const [at, hp] of state.field) {
+ const [x, y] = at.split(",").map(Number);
+ put(x, y, shroom(MUSH_ART[hp - 1] ?? "▁"));
+ }
+ for (const seg of state.centipede) put(seg.x, seg.y, danger("◍"));
+ if (state.spider) put(state.spider.x, state.spider.y, rgb(190, 130, 255)("✻"));
+ for (const shot of state.shots) put(shot.x, shot.y, amber("│"));
+ put(state.player.x, state.player.y, state.over ? danger("✷") : acid("▲"));
+
+ return grid.map((row, y) => row.map((cell) => (
+ cell ?? (y === ZONE_TOP - 1 ? dim("┈") : " ")
+ )).join(""));
+ },
+};
diff --git a/src/games-choplifter.mjs b/src/games-choplifter.mjs
new file mode 100644
index 0000000..c5f09c0
--- /dev/null
+++ b/src/games-choplifter.mjs
@@ -0,0 +1,179 @@
+// Choplifter. Fly out, land, load them up, fly home. Do it before the tanks
+// work out where you are going.
+//
+// The world is wider than the screen, which is the whole point — the camera
+// follows the chopper and the base sits off the left edge, so "get back" is a
+// real journey rather than a step. Everything is stored in world columns and
+// only turned into screen columns at the very end, in render().
+import { acid, amber, ash, bone, danger, dim, rgb } from "./ui.mjs";
+
+export const WIDTH = 46; // what you can see
+export const HEIGHT = 14;
+export const WORLD = 150; // how far it actually goes
+
+export const GROUND = HEIGHT - 2;
+export const BASE = 6; // the pad, at the left-hand end of the world
+const BASE_W = 7;
+export const SEATS = 4; // how many fit in the back
+const LIVES = 3;
+const SHELL_EVERY = 46; // ticks between a tank taking a shot
+
+const sand = rgb(200, 170, 110);
+
+export const onPad = (x) => x >= BASE - 1 && x <= BASE + BASE_W;
+
+/** Hostages waiting in the desert, and the tanks that would rather they stayed. */
+export function populate(rng) {
+ const people = [];
+ for (let i = 0; i < 12; i++) {
+ people.push({ x: 40 + Math.floor(rng() * (WORLD - 55)), waving: true });
+ }
+ const tanks = [];
+ for (let i = 0; i < 4; i++) {
+ tanks.push({ x: 55 + Math.floor(rng() * (WORLD - 70)), dir: rng() < 0.5 ? -1 : 1 });
+ }
+ return { people, tanks };
+}
+
+function hit(state, why) {
+ state.lives--;
+ // Anybody in the back goes down with it. That is the cost of one more pickup.
+ state.aboard = 0;
+ if (state.lives <= 0) {
+ state.lives = 0;
+ state.over = `${why} · ${state.home} home`;
+ return state;
+ }
+ state.chopper = { x: BASE + 2, y: GROUND - 1, vy: 0 };
+ state.shells = [];
+ return state;
+}
+
+/** One tick. Exported so a test can fly a whole rescue with no clock. */
+export function step(state) {
+ const chop = state.chopper;
+ chop.x = Math.max(0, Math.min(WORLD - 1, chop.x + state.throttle));
+ chop.y = Math.max(1, Math.min(GROUND, chop.y + chop.vy));
+ // Both axes drift to a stop rather than stopping dead, so it hovers when you
+ // let go instead of dropping out of the sky the moment you stop pressing up.
+ state.throttle *= 0.72;
+ if (Math.abs(state.throttle) < 0.05) state.throttle = 0;
+ chop.vy *= 0.72;
+ if (Math.abs(chop.vy) < 0.05) chop.vy = 0;
+
+ const landed = chop.y >= GROUND;
+
+ if (landed) {
+ // On the ground at the base: everybody out, and that is the score.
+ if (onPad(Math.round(chop.x))) {
+ state.home += state.aboard;
+ state.score += state.aboard * 100;
+ state.aboard = 0;
+ } else {
+ // Out in the desert: anybody close enough climbs in.
+ for (const person of state.people) {
+ if (state.aboard >= SEATS) break;
+ if (Math.abs(person.x - chop.x) > 2 || person.rescued) continue;
+ person.rescued = true;
+ state.aboard++;
+ }
+ state.people = state.people.filter((p) => !p.rescued);
+ }
+ }
+
+ state.clock++;
+ for (const tank of state.tanks) {
+ if (state.clock % 5 === 0) {
+ tank.x += tank.dir;
+ if (tank.x < 30 || tank.x > WORLD - 4) tank.dir *= -1;
+ }
+ // A tank shoots when you are overhead, and only then — you can outrun them.
+ if (state.clock % SHELL_EVERY === 0 && Math.abs(tank.x - chop.x) < 12) {
+ state.shells.push({ x: tank.x, y: GROUND - 1, vy: -0.55 });
+ }
+ }
+
+ for (const shell of state.shells) shell.y += shell.vy;
+ state.shells = state.shells.filter((s) => s.y > 0);
+ const struck = state.shells.find((s) => Math.abs(s.x - chop.x) < 2 && Math.abs(s.y - chop.y) < 1);
+ if (struck) return hit(state, "shot down");
+
+ const run_over = state.tanks.find((t) => landed && Math.abs(t.x - chop.x) < 2);
+ if (run_over) return hit(state, "flattened on the ground");
+
+ if (!state.people.length && !state.aboard) {
+ state.over = `everyone out · ${state.home} home`;
+ }
+ return state;
+}
+
+export const CHOPLIFTER = {
+ key: "choplifter",
+ aliases: ["chopper", "rescue", "heli"],
+ title: "CHOPLIFTER",
+ blurb: "fly out, land, fill the back, and get them home",
+ keys: "← → fly · ↑ ↓ climb and land · q quit",
+ tickMs: 55,
+
+ create({ rng = Math.random } = {}) {
+ const { people, tanks } = populate(rng);
+ return {
+ chopper: { x: BASE + 2, y: GROUND - 1, vy: 0 },
+ throttle: 0,
+ people,
+ tanks,
+ shells: [],
+ aboard: 0,
+ home: 0,
+ score: 0,
+ lives: LIVES,
+ clock: 0,
+ over: null,
+ rng,
+ };
+ },
+
+ tick: step,
+
+ onKey(state, pressed) {
+ const chop = state.chopper;
+ if (pressed === "left") state.throttle = Math.max(-1.4, state.throttle - 0.7);
+ else if (pressed === "right") state.throttle = Math.min(1.4, state.throttle + 0.7);
+ else if (pressed === "up") chop.vy = Math.max(-0.7, chop.vy - 0.45);
+ else if (pressed === "down") chop.vy = Math.min(0.7, chop.vy + 0.45);
+ return state;
+ },
+
+ status(state) {
+ if (state.over) return state.over;
+ return `${state.home} home · ${state.aboard}/${SEATS} aboard · ${state.people.length} waiting · ${"▲".repeat(state.lives)}`;
+ },
+
+ render(state) {
+ // The camera keeps the chopper in the middle until the world runs out.
+ const camera = Math.max(0, Math.min(WORLD - WIDTH, Math.round(state.chopper.x) - Math.floor(WIDTH / 2)));
+ const grid = Array.from({ length: HEIGHT }, () => Array.from({ length: WIDTH }, () => null));
+ const put = (worldX, y, glyph) => {
+ const x = Math.round(worldX) - camera;
+ if (x < 0 || x >= WIDTH || y < 0 || y >= HEIGHT) return;
+ grid[y][x] = glyph;
+ };
+
+ for (let i = 0; i < BASE_W; i++) put(BASE + i, GROUND, acid("═"));
+ for (const person of state.people) put(person.x, GROUND - 1, bone("Ω"));
+ for (const tank of state.tanks) put(tank.x, GROUND - 1, danger("▙"));
+ for (const shell of state.shells) put(shell.x, shell.y, amber("•"));
+ const chop = state.chopper;
+ put(chop.x, Math.round(chop.y), state.over ? danger("✷") : acid("╤"));
+ put(chop.x - 1, Math.round(chop.y), state.over ? danger("✷") : ash("─"));
+ put(chop.x + 1, Math.round(chop.y), state.over ? danger("✷") : ash("─"));
+
+ return grid.map((row, y) => row.map((cell, x) => {
+ if (cell) return cell;
+ if (y === GROUND) return sand("▀");
+ if (y > GROUND) return sand("░");
+ // A horizon marker every ten columns of world, so flying feels like it.
+ return (x + camera) % 12 === 0 && y === 1 ? dim("│") : " ";
+ }).join(""));
+ },
+};
diff --git a/src/games-digdug.mjs b/src/games-digdug.mjs
new file mode 100644
index 0000000..da8d20d
--- /dev/null
+++ b/src/games-digdug.mjs
@@ -0,0 +1,267 @@
+// Dig Dug. You are underground, everything down here wants you, and the only
+// wall between you and it is the ground you have not dug yet.
+//
+// The board is the enemy AI. There is no pathfinding: a monster walks the tunnel
+// it is in and turns towards you at a junction, so the shape you dig is the
+// shape of the fight. Dig a straight line and they queue up behind you; dig a
+// loop and they come round both ends of it.
+import { acid, amber, ash, bone, danger, dim, rgb } from "./ui.mjs";
+
+export const WIDTH = 40;
+export const HEIGHT = 16;
+export const SKY = 1; // rows above this are open air
+
+const LIVES = 3;
+const HARPOON = 5; // how far the pump reaches
+const POPS_AT = 3; // pumps to burst a monster
+const MONSTER_EVERY = 9; // ticks between monster steps
+const ROCK_EVERY = 3;
+
+const soil = rgb(150, 100, 60);
+const DIRS = { up: [0, -1], down: [0, 1], left: [-1, 0], right: [1, 0] };
+
+const key = (x, y) => `${x},${y}`;
+
+/** Solid ground everywhere below the sky, minus the shafts the level starts with. */
+export function buildGround() {
+ const ground = new Set();
+ for (let y = SKY + 1; y < HEIGHT; y++) for (let x = 0; x < WIDTH; x++) ground.add(key(x, y));
+ return ground;
+}
+
+export const isDug = (ground, x, y) => (
+ x >= 0 && x < WIDTH && y >= 0 && y < HEIGHT && !ground.has(key(x, y))
+);
+
+/** Where the monsters and rocks start — spread out, and never on top of you. */
+export function populate(state, level) {
+ state.monsters = [];
+ for (let i = 0; i < 3 + Math.min(3, level - 1); i++) {
+ state.monsters.push({
+ x: 6 + Math.floor(state.rng() * (WIDTH - 12)),
+ y: SKY + 2 + Math.floor(state.rng() * (HEIGHT - SKY - 3)),
+ dir: state.rng() < 0.5 ? "left" : "right",
+ pumped: 0,
+ ghost: 0,
+ });
+ }
+ state.rocks = [];
+ for (let i = 0; i < 4; i++) {
+ state.rocks.push({
+ x: 4 + Math.floor(state.rng() * (WIDTH - 8)),
+ y: SKY + 2 + Math.floor(state.rng() * 5),
+ falling: false,
+ });
+ }
+ // Every monster and rock starts buried, so the board opens up only where you
+ // dig it.
+ for (const thing of [...state.monsters, ...state.rocks]) state.ground.delete(key(thing.x, thing.y));
+ return state;
+}
+
+function lose(state, why) {
+ state.lives--;
+ if (state.lives <= 0) {
+ state.lives = 0;
+ state.over = `${why} · ${state.score} points`;
+ return state;
+ }
+ state.player = { x: Math.floor(WIDTH / 2), y: SKY + 1, dir: "down" };
+ state.harpoon = null;
+ state.ground.delete(key(state.player.x, state.player.y));
+ return state;
+}
+
+/** A rock with nothing under it falls, and takes anything under it with it. */
+export function fallRocks(state) {
+ for (const rock of state.rocks) {
+ const below = { x: rock.x, y: rock.y + 1 };
+ if (!rock.falling && !isDug(state.ground, below.x, below.y)) continue;
+ if (below.y >= HEIGHT) { rock.falling = false; continue; }
+ rock.falling = true;
+ rock.y += 1;
+ state.ground.delete(key(rock.x, rock.y));
+ const squashed = state.monsters.filter((m) => m.x === rock.x && m.y === rock.y);
+ if (squashed.length) {
+ state.monsters = state.monsters.filter((m) => !squashed.includes(m));
+ state.score += squashed.length * 200;
+ }
+ if (state.player.x === rock.x && state.player.y === rock.y) return lose(state, "under a rock");
+ }
+ return state;
+}
+
+/** One monster step: down its own tunnel, turning towards you at a junction. */
+export function walkMonster(state, monster) {
+ if (monster.pumped) return monster; // a hooked monster is going nowhere
+ const player = state.player;
+
+ // Once in a while one gives up on the tunnels and comes straight through the
+ // ground at you. Without it, a player who digs one deep hole is untouchable.
+ if (monster.ghost > 0) {
+ monster.ghost--;
+ monster.x += Math.sign(player.x - monster.x);
+ if (monster.x === player.x) monster.y += Math.sign(player.y - monster.y);
+ return monster;
+ }
+ if (state.rng() < 0.02) { monster.ghost = 6; return monster; }
+
+ const options = Object.entries(DIRS)
+ .filter(([, [dx, dy]]) => isDug(state.ground, monster.x + dx, monster.y + dy))
+ .sort(([, a], [, b]) => {
+ const da = Math.abs(monster.x + a[0] - player.x) + Math.abs(monster.y + a[1] - player.y);
+ const db = Math.abs(monster.x + b[0] - player.x) + Math.abs(monster.y + b[1] - player.y);
+ return da - db;
+ });
+ if (!options.length) return monster;
+ // It prefers the way it is already going when that is no worse, so it does not
+ // jitter on the spot at a crossroads.
+ const [name, [dx, dy]] = options[0];
+ monster.dir = name;
+ monster.x += dx;
+ monster.y += dy;
+ return monster;
+}
+
+/** One tick. Exported so a test can clear a level with no clock. */
+export function step(state) {
+ state.clock++;
+
+ if (state.clock % ROCK_EVERY === 0) {
+ fallRocks(state);
+ if (state.over) return state;
+ }
+
+ if (state.harpoon) {
+ // The harpoon holds whatever it caught; it is the pump that does the work.
+ const hooked = state.monsters.find((m) => m === state.harpoon.on);
+ if (!hooked) state.harpoon = null;
+ }
+
+ if (state.clock % MONSTER_EVERY === 0) {
+ for (const monster of state.monsters) walkMonster(state, monster);
+ const caught = state.monsters.find((m) => m.x === state.player.x && m.y === state.player.y);
+ if (caught) return lose(state, "caught underground");
+ }
+
+ if (!state.monsters.length) {
+ state.level++;
+ state.ground = buildGround();
+ state.player = { x: Math.floor(WIDTH / 2), y: SKY + 1, dir: "down" };
+ state.ground.delete(key(state.player.x, state.player.y));
+ state.score += 500;
+ populate(state, state.level);
+ }
+ return state;
+}
+
+/** Fire the harpoon down the tunnel you are facing, or pump what is on it. */
+export function pump(state) {
+ if (state.harpoon) {
+ const monster = state.harpoon.on;
+ monster.pumped++;
+ if (monster.pumped >= POPS_AT) {
+ state.monsters = state.monsters.filter((m) => m !== monster);
+ state.score += 100 * POPS_AT;
+ state.harpoon = null;
+ }
+ return state;
+ }
+ const [dx, dy] = DIRS[state.player.dir];
+ for (let i = 1; i <= HARPOON; i++) {
+ const x = state.player.x + dx * i;
+ const y = state.player.y + dy * i;
+ if (!isDug(state.ground, x, y)) break; // it does not go through dirt
+ const monster = state.monsters.find((m) => m.x === x && m.y === y);
+ if (monster) {
+ state.harpoon = { on: monster, x, y };
+ monster.pumped = 1;
+ return state;
+ }
+ }
+ return state;
+}
+
+export const DIGDUG = {
+ key: "digdug",
+ aliases: ["dig", "pooka"],
+ title: "DIG DUG",
+ blurb: "dig the tunnels, pump the monsters, drop rocks on the rest",
+ keys: "← ↑ ↓ → dig · space pump · q quit",
+ tickMs: 60,
+
+ create({ rng = Math.random } = {}) {
+ const state = {
+ ground: buildGround(),
+ player: { x: Math.floor(WIDTH / 2), y: SKY + 1, dir: "down" },
+ monsters: [],
+ rocks: [],
+ harpoon: null,
+ score: 0,
+ lives: LIVES,
+ level: 1,
+ clock: 0,
+ over: null,
+ rng,
+ };
+ state.ground.delete(key(state.player.x, state.player.y));
+ return populate(state, 1);
+ },
+
+ tick: step,
+
+ onKey(state, pressed) {
+ if (pressed === "space" || pressed === "enter") return pump(state);
+ const move = DIRS[pressed];
+ if (!move) return state;
+ // Moving is digging: the tunnel is wherever you have been.
+ state.harpoon = null;
+ state.player.dir = pressed;
+ const x = state.player.x + move[0];
+ const y = state.player.y + move[1];
+ if (x < 0 || x >= WIDTH || y <= SKY || y >= HEIGHT) return state;
+ if (state.rocks.some((r) => r.x === x && r.y === y && !r.falling)) return state;
+ if (state.ground.delete(key(x, y))) state.score += 1;
+ state.player.x = x;
+ state.player.y = y;
+ return state;
+ },
+
+ status(state) {
+ return state.over
+ ? state.over
+ : `${state.score} · level ${state.level} · ${state.monsters.length} left · ${"▲".repeat(state.lives)}`;
+ },
+
+ render(state) {
+ const grid = Array.from({ length: HEIGHT }, (_, y) => Array.from({ length: WIDTH }, (_, x) => (
+ state.ground.has(key(x, y)) ? soil("▒") : null
+ )));
+ const put = (x, y, glyph) => {
+ if (x < 0 || x >= WIDTH || y < 0 || y >= HEIGHT) return;
+ grid[y][x] = glyph;
+ };
+
+ for (const rock of state.rocks) put(rock.x, rock.y, ash("▣"));
+ for (const monster of state.monsters) {
+ // A monster part-way through being pumped is visibly bigger, which is the
+ // only feedback the pump gives you.
+ const art = monster.pumped >= 2 ? "◯" : monster.pumped ? "◎" : "◉";
+ put(monster.x, monster.y, (monster.ghost ? amber : danger)(art));
+ }
+ if (state.harpoon) {
+ const [dx, dy] = DIRS[state.player.dir];
+ for (let i = 1; i < HARPOON; i++) {
+ const x = state.player.x + dx * i;
+ const y = state.player.y + dy * i;
+ if (x === state.harpoon.x && y === state.harpoon.y) break;
+ put(x, y, bone(dx ? "─" : "│"));
+ }
+ }
+ put(state.player.x, state.player.y, state.over ? danger("✷") : acid("◈"));
+
+ return grid.map((row, y) => row.map((cell) => (
+ cell ?? (y <= SKY ? dim("·") : " ")
+ )).join(""));
+ },
+};
diff --git a/src/games-excitebike.mjs b/src/games-excitebike.mjs
new file mode 100644
index 0000000..89b8581
--- /dev/null
+++ b/src/games-excitebike.mjs
@@ -0,0 +1,206 @@
+// Excitebike. The throttle is not the interesting part — the temperature gauge
+// is, and so is what your front wheel is doing when you land.
+//
+// Two things make this game rather than a side-scroller with ramps. Turbo is
+// free until it isn't: heat climbs while you hold it and the engine seizes at
+// the top of the gauge, so the fast lap is the one that cools off in the right
+// places. And a jump is only as good as its landing: you pitch the bike in the
+// air, and coming down nose-first puts you over the handlebars.
+import { acid, ash, bone, danger, dim, rgb } from "./ui.mjs";
+
+export const WIDTH = 46;
+export const HEIGHT = 12;
+
+export const GROUND = HEIGHT - 3;
+export const RIDER = 8; // your column; the track comes to you
+
+const BASE_SPEED = 0.35;
+const MAX_SPEED = 1.5;
+const TURBO = 0.55; // extra columns per tick while it is held
+const HEAT_UP = 1.6;
+const HEAT_DOWN = 0.8;
+export const SEIZE_TICKS = 70; // how long a cooked engine costs you
+const PITCH_LIMIT = 2.2;
+// The nose drops on its own all the way down. Without this the pitch you leave
+// the ramp with is the pitch you land on, and the landing — the whole second
+// half of this game — is something you can simply ignore.
+const PITCH_DROP = 0.075;
+export const LAND_OK = 1.1; // how far from level you may land
+const CRASH_TICKS = 45;
+export const FINISH = 900; // columns of track in a race
+const TIME = 2600;
+
+const dirt = rgb(170, 130, 90);
+
+/** A ramp is three columns of take-off; hit one moving and you are airborne. */
+export const RAMP_W = 3;
+
+export function spawn(state) {
+ const { rng } = state;
+ state.things.push({ kind: "ramp", x: WIDTH + 2 });
+ state.next = 18 + rng() * 22;
+ return state;
+}
+
+export const rampSpan = (thing) => {
+ const x = Math.round(thing.x);
+ return [x, x + RAMP_W - 1];
+};
+
+/** Speed right now, with everything that is holding it back applied. */
+export function speedOf(state) {
+ if (state.crash > 0) return 0;
+ if (state.seized > 0) return BASE_SPEED * 0.4;
+ return Math.min(MAX_SPEED, state.throttle + (state.turbo ? TURBO : 0));
+}
+
+function crash(state, why) {
+ state.crash = CRASH_TICKS;
+ state.air = 0;
+ state.pitch = 0;
+ state.throttle = BASE_SPEED;
+ state.spills++;
+ state.last = why;
+ return state;
+}
+
+/** One tick of track. Exported so a test can ride a whole race with no clock. */
+export function step(state) {
+ state.clock++;
+ if (state.clock >= TIME) {
+ state.over = `out of time · ${Math.round(state.dist)} of ${FINISH}`;
+ return state;
+ }
+ if (state.crash > 0) { state.crash--; return state; }
+
+ // The gauge. Turbo is a loan, and this is where it is called in.
+ if (state.turbo && !state.seized) state.heat = Math.min(100, state.heat + HEAT_UP);
+ else state.heat = Math.max(0, state.heat - HEAT_DOWN);
+ if (state.heat >= 100 && !state.seized) { state.seized = SEIZE_TICKS; state.turbo = false; }
+ if (state.seized > 0) { state.seized--; if (!state.seized) state.heat = 40; }
+
+ const speed = speedOf(state);
+ state.dist += speed;
+ for (const thing of state.things) thing.x -= speed;
+ state.things = state.things.filter((t) => rampSpan(t)[1] > -3);
+ state.next -= speed;
+ if (state.next <= 0) spawn(state);
+
+ if (state.air > 0) {
+ state.air--;
+ state.pitch = Math.min(PITCH_LIMIT, state.pitch + PITCH_DROP);
+ if (!state.air) {
+ // Landing: level enough is a landing, anything else is a tumble.
+ if (Math.abs(state.pitch) > LAND_OK) return crash(state, "over the handlebars");
+ // A flat landing carries the speed; a wobbly one scrubs some off.
+ state.throttle = Math.min(MAX_SPEED, state.throttle + (Math.abs(state.pitch) < 0.4 ? 0.12 : -0.1));
+ state.score += 50;
+ state.pitch = 0;
+ }
+ } else {
+ const ramp = state.things.find((t) => {
+ const [from, to] = rampSpan(t);
+ return RIDER >= from && RIDER <= to;
+ });
+ if (ramp && !ramp.used) {
+ ramp.used = true;
+ // The faster you hit it, the longer you are in the air — and the more time
+ // you have to get the pitch wrong.
+ state.air = Math.round(10 + speed * 14);
+ state.pitch = 0.6; // it launches you nose-up, and you ride it down
+ }
+ }
+
+ if (state.dist >= FINISH) {
+ state.race++;
+ state.score += Math.max(200, 2000 - state.clock);
+ state.dist = 0;
+ state.clock = 0;
+ state.things = [];
+ state.heat = 0;
+ }
+ return state;
+}
+
+export const EXCITEBIKE = {
+ key: "excitebike",
+ aliases: ["bike", "moto", "excite"],
+ title: "EXCITEBIKE",
+ blurb: "turbo until it cooks, and land the way you took off",
+ keys: "← → throttle · space turbo · ↑ ↓ pitch in the air · q quit",
+ tickMs: 55,
+
+ create({ rng = Math.random } = {}) {
+ return {
+ things: [],
+ next: 16,
+ throttle: BASE_SPEED,
+ turbo: false,
+ heat: 0,
+ seized: 0,
+ air: 0,
+ pitch: 0,
+ crash: 0,
+ spills: 0,
+ dist: 0,
+ race: 1,
+ clock: 0,
+ score: 0,
+ last: null,
+ over: null,
+ rng,
+ };
+ },
+
+ tick: step,
+
+ onKey(state, pressed) {
+ if (state.crash > 0) return state;
+ if (pressed === "right") state.throttle = Math.min(MAX_SPEED, state.throttle + 0.12);
+ else if (pressed === "left") state.throttle = Math.max(BASE_SPEED * 0.5, state.throttle - 0.12);
+ else if (pressed === "space" || pressed === "enter") state.turbo = !state.turbo;
+ else if (pressed === "up" || pressed === "down") {
+ // Pitch only means anything off the ground; on it, this does nothing at
+ // all, which is the honest answer.
+ if (!state.air) return state;
+ state.pitch = Math.max(-PITCH_LIMIT, Math.min(PITCH_LIMIT, state.pitch + (pressed === "up" ? -0.35 : 0.35)));
+ }
+ return state;
+ },
+
+ status(state) {
+ if (state.over) return state.over;
+ const gauge = Math.round(state.heat / 10);
+ const bar = `${"█".repeat(gauge)}${"░".repeat(10 - gauge)}`;
+ return `race ${state.race} · ${Math.round(state.dist)}/${FINISH} · heat ${bar}${state.seized ? " seized" : ""}`;
+ },
+
+ render(state) {
+ const grid = Array.from({ length: HEIGHT }, () => Array.from({ length: WIDTH }, () => null));
+ const put = (x, y, glyph) => {
+ if (x < 0 || x >= WIDTH || y < 0 || y >= HEIGHT) return;
+ grid[y][x] = glyph;
+ };
+
+ for (const thing of state.things) {
+ const [from] = rampSpan(thing);
+ // A ramp climbs, so it is drawn climbing.
+ [...("▁▄█")].forEach((c, i) => put(from + i, GROUND - (i > 1 ? 1 : 0), dirt(c)));
+ }
+
+ const height = state.air ? Math.min(4, 1 + Math.round(state.air / 6)) : 0;
+ const nose = state.pitch < -LAND_OK ? "◜" : state.pitch > LAND_OK ? "◞" : state.air ? "◠" : "◉";
+ put(RIDER, GROUND - 1 - height, state.crash ? danger("✷") : acid(nose));
+ if (!state.air && !state.crash) put(RIDER + 1, GROUND - 1, ash("·"));
+
+ return grid.map((row, y) => row.map((cell, x) => {
+ if (cell) return cell;
+ if (y === GROUND) return dirt("▀");
+ if (y > GROUND) return dim("░");
+ // The finish, coming up the track.
+ const toGo = FINISH - state.dist;
+ if (toGo < WIDTH - RIDER && x === RIDER + Math.round(toGo)) return bone("┋");
+ return " ";
+ }).join(""));
+ },
+};
diff --git a/src/games-frogger.mjs b/src/games-frogger.mjs
new file mode 100644
index 0000000..95faacc
--- /dev/null
+++ b/src/games-frogger.mjs
@@ -0,0 +1,194 @@
+// Frogger. Five lanes of traffic that kill you if they touch you, then five of
+// river that kill you if they don't.
+//
+// That inversion is the whole game and it is worth stating plainly in the code:
+// on the road, being on something is death; on the river, being on nothing is.
+// Everything else here — the lanes, the hops, the homes — is bookkeeping.
+import { acid, amber, ash, bone, danger, dim, rgb } from "./ui.mjs";
+
+export const WIDTH = 40;
+
+/**
+ * The board, bottom to top. Row 0 is the top (the homes), and the frog starts
+ * on the bank at the bottom.
+ */
+export const HOME_ROW = 0;
+export const RIVER = [1, 2, 3, 4, 5];
+export const MEDIAN = 6;
+export const ROAD = [7, 8, 9, 10, 11];
+export const BANK = 12;
+export const HEIGHT = BANK + 1;
+
+/** Five places to get to, evenly spaced along the top. */
+export const HOMES = [3, 11, 19, 27, 35];
+const HOME_W = 3;
+
+const LIVES = 3;
+const water = rgb(60, 130, 220);
+const log = rgb(150, 100, 60);
+
+/** Each lane: which way it runs, how fast, and how thick the things in it are. */
+export const LANES = {
+ 1: { dir: 1, speed: 0.16, len: 4, gap: 11, kind: "log" },
+ 2: { dir: -1, speed: 0.22, len: 3, gap: 9, kind: "turtle" },
+ 3: { dir: 1, speed: 0.13, len: 6, gap: 14, kind: "log" },
+ 4: { dir: -1, speed: 0.28, len: 3, gap: 10, kind: "turtle" },
+ 5: { dir: 1, speed: 0.2, len: 5, gap: 13, kind: "log" },
+ 7: { dir: -1, speed: 0.26, len: 2, gap: 9, kind: "car" },
+ 8: { dir: 1, speed: 0.19, len: 3, gap: 11, kind: "truck" },
+ 9: { dir: -1, speed: 0.33, len: 2, gap: 12, kind: "car" },
+ 10: { dir: 1, speed: 0.15, len: 4, gap: 13, kind: "truck" },
+ 11: { dir: -1, speed: 0.24, len: 2, gap: 10, kind: "car" },
+};
+
+const ART = {
+ car: { paint: danger, art: "▄▄▄▄▄▄" },
+ truck: { paint: amber, art: "██████" },
+ log: { paint: log, art: "▓▓▓▓▓▓" },
+ turtle: { paint: acid, art: "◠◠◠◠◠◠" },
+};
+
+/** Every lane laid out end to end, evenly spaced. */
+export function buildTraffic() {
+ const things = [];
+ for (const [row, lane] of Object.entries(LANES)) {
+ for (let x = 0; x < WIDTH + lane.gap; x += lane.gap) {
+ things.push({ row: Number(row), x, len: lane.len, kind: lane.kind });
+ }
+ }
+ return things;
+}
+
+/** The thing under a cell, if there is one. */
+export const thingAt = (things, row, x) => things.find(
+ (t) => t.row === row && x >= Math.round(t.x) && x < Math.round(t.x) + t.len,
+) ?? null;
+
+/** Which home a frog at `x` has reached, or -1. */
+export const homeAt = (x) => HOMES.findIndex((h) => x >= h && x < h + HOME_W);
+
+const start = () => ({ x: Math.floor(WIDTH / 2), row: BANK });
+
+function drown(state, why) {
+ state.lives--;
+ if (state.lives <= 0) {
+ state.lives = 0;
+ state.over = `${why} · ${state.score} points`;
+ return state;
+ }
+ state.frog = start();
+ return state;
+}
+
+/** One tick. Exported so a test can get a frog home with no clock. */
+export function step(state) {
+ for (const thing of state.traffic) {
+ const lane = LANES[thing.row];
+ thing.x += lane.dir * lane.speed;
+ if (thing.x > WIDTH + 2) thing.x = -thing.len - 2;
+ if (thing.x < -thing.len - 2) thing.x = WIDTH + 2;
+ }
+
+ const frog = state.frog;
+ if (RIVER.includes(frog.row)) {
+ // The river carries you. Riding a log off the edge of the world still
+ // counts as losing the frog, which is the lesson every player learns twice.
+ const ride = thingAt(state.traffic, frog.row, Math.round(frog.drift ?? frog.x));
+ if (!ride) return drown(state, "into the river");
+ frog.drift = (frog.drift ?? frog.x) + LANES[frog.row].dir * LANES[frog.row].speed;
+ frog.x = Math.round(frog.drift);
+ if (frog.x < 0 || frog.x >= WIDTH) return drown(state, "carried off the edge");
+ } else if (ROAD.includes(frog.row)) {
+ if (thingAt(state.traffic, frog.row, frog.x)) return drown(state, "flattened");
+ }
+ return state;
+}
+
+/** Hop, and settle what the frog landed on. */
+export function hop(state, dx, dy) {
+ const frog = state.frog;
+ const row = Math.min(BANK, Math.max(HOME_ROW, frog.row + dy));
+ const x = Math.min(WIDTH - 1, Math.max(0, frog.x + dx));
+ frog.row = row;
+ frog.x = x;
+ frog.drift = RIVER.includes(row) ? x : null;
+
+ if (row === HOME_ROW) {
+ const home = homeAt(x);
+ if (home < 0 || state.homes[home]) {
+ // The bank between the homes is not a home, and neither is one you have
+ // already filled.
+ return drown(state, "nowhere to land");
+ }
+ state.homes[home] = true;
+ state.score += 100;
+ if (state.homes.every(Boolean)) {
+ state.level++;
+ state.homes = HOMES.map(() => false);
+ state.score += 500;
+ }
+ state.frog = start();
+ return state;
+ }
+ if (dy < 0) state.score += 10; // forwards only, so hopping on the spot pays nothing
+ return step(state);
+}
+
+export const FROGGER = {
+ key: "frogger",
+ aliases: ["frog", "hop"],
+ title: "FROGGER",
+ blurb: "the road kills what it touches, the river kills what it doesn't",
+ keys: "← ↑ ↓ → hop · q quit",
+ tickMs: 60,
+
+ create() {
+ return {
+ traffic: buildTraffic(),
+ frog: start(),
+ homes: HOMES.map(() => false),
+ score: 0,
+ lives: LIVES,
+ level: 1,
+ over: null,
+ };
+ },
+
+ tick: step,
+
+ onKey(state, pressed) {
+ const moves = { left: [-1, 0], right: [1, 0], up: [0, -1], down: [0, 1] };
+ const move = moves[pressed];
+ if (!move) return state;
+ return hop(state, move[0], move[1]);
+ },
+
+ status(state) {
+ return state.over
+ ? state.over
+ : `${state.score} · level ${state.level} · ${state.homes.filter(Boolean).length}/5 home · ${"▲".repeat(state.lives)}`;
+ },
+
+ render(state) {
+ const grid = Array.from({ length: HEIGHT }, (_, row) => Array.from({ length: WIDTH }, () => (
+ RIVER.includes(row) ? water("░") : ROAD.includes(row) ? dim("·") : null
+ )));
+ const put = (x, y, glyph) => {
+ if (x < 0 || x >= WIDTH || y < 0 || y >= HEIGHT) return;
+ grid[y][x] = glyph;
+ };
+
+ for (const [i, home] of HOMES.entries()) {
+ for (let j = 0; j < HOME_W; j++) put(home + j, HOME_ROW, state.homes[i] ? acid("▓") : ash("▒"));
+ }
+ for (const thing of state.traffic) {
+ const { paint, art } = ART[thing.kind];
+ for (let i = 0; i < thing.len; i++) put(Math.round(thing.x) + i, thing.row, paint(art[i % art.length]));
+ }
+ put(state.frog.x, state.frog.row, state.over ? danger("✷") : bone("◉"));
+
+ return grid.map((row, y) => row.map((cell) => (
+ cell ?? (y === MEDIAN || y === BANK ? ash("═") : " ")
+ )).join(""));
+ },
+};
diff --git a/src/games-invaders.mjs b/src/games-invaders.mjs
new file mode 100644
index 0000000..b01cdb5
--- /dev/null
+++ b/src/games-invaders.mjs
@@ -0,0 +1,258 @@
+// Space Invaders. Forty of them, coming down a row at a time, and the fewer are
+// left the faster the rest move — which is the joke the original hardware told by
+// accident and every version since has kept on purpose.
+//
+// Nothing here moves on a fraction of a cell. The fleet steps a whole column at
+// a time on a counter, shots move a whole row per tick, and every hit is one
+// grid cell against another. A game whose whole tension is "will it get to the
+// bottom before I do" should never lose a shot to a rounding error.
+import { acid, amber, ash, bone, danger, dim, rgb } from "./ui.mjs";
+
+export const WIDTH = 44;
+export const HEIGHT = 16;
+
+export const ROWS = 5;
+export const COLS = 8;
+const PITCH_X = 4; // an alien is two cells wide, with two between them
+// One row per rank. At two the fleet stood nine rows tall on a board with eleven
+// above the bunkers, so it "landed" after two drops and no wave was survivable.
+const PITCH_Y = 1;
+
+export const CANNON_ROW = HEIGHT - 2;
+const FLOOR_ROW = HEIGHT - 1;
+export const BUNKER_ROW = HEIGHT - 4;
+const LIVES = 3;
+const BOMB_EVERY = 2; // bombs fall on every other tick, so they can be dodged
+const SHOT_SPEED = 2; // rows per tick — a slow shot makes the whole game a queue
+
+/** Top rows are worth more, and look meaner. */
+export const KINDS = [
+ { art: "▛▜", points: 30, paint: rgb(190, 130, 255) },
+ { art: "▛▜", points: 20, paint: rgb(90, 200, 250) },
+ { art: "▙▟", points: 20, paint: acid },
+ { art: "▙▟", points: 10, paint: amber },
+ { art: "▞▚", points: 10, paint: ash },
+];
+
+const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
+
+/** Where an alien sits, given the fleet's corner. */
+export const alienAt = (fleet, row, col) => ({
+ x: fleet.x + col * PITCH_X,
+ y: fleet.y + row * PITCH_Y,
+});
+
+export const aliveCount = (fleet) => fleet.alive.reduce((n, row) => n + row.filter(Boolean).length, 0);
+
+/**
+ * How many ticks between steps. Forty aliens crawl; the last one sprints. This
+ * is the entire difficulty curve of the game and it costs one line.
+ */
+export const cadence = (fleet) => Math.max(2, Math.round(aliveCount(fleet) / 2.4));
+
+const newFleet = (wave) => ({
+ x: 3,
+ y: 1 + Math.min(3, wave - 1), // each wave starts closer to the floor
+ dir: 1,
+ alive: Array.from({ length: ROWS }, () => Array.from({ length: COLS }, () => true)),
+ clock: 0,
+});
+
+/** Four bunkers, each cell able to take two hits before it is gone. */
+export function buildBunkers() {
+ const bunkers = new Map();
+ for (let b = 0; b < 4; b++) {
+ const left = 5 + b * 10;
+ for (let i = 0; i < 5; i++) bunkers.set(left + i, 2);
+ }
+ return bunkers;
+}
+
+/** Chip a bunker cell, and say whether there was one there to chip. */
+export function chip(bunkers, x, y) {
+ if (y !== BUNKER_ROW) return false;
+ const hp = bunkers.get(x);
+ if (!hp) return false;
+ if (hp <= 1) bunkers.delete(x); else bunkers.set(x, hp - 1);
+ return true;
+}
+
+/** The lowest live alien in each column — the only ones that can drop a bomb. */
+export function frontLine(fleet) {
+ const front = [];
+ for (let col = 0; col < COLS; col++) {
+ for (let row = ROWS - 1; row >= 0; row--) {
+ if (fleet.alive[row][col]) { front.push({ row, col }); break; }
+ }
+ }
+ return front;
+}
+
+/** Whether a shot at (x, y) hits an alien, and which one. */
+export function alienHit(fleet, x, y) {
+ for (let row = 0; row < ROWS; row++) {
+ for (let col = 0; col < COLS; col++) {
+ if (!fleet.alive[row][col]) continue;
+ const at = alienAt(fleet, row, col);
+ if (y === at.y && x >= at.x && x <= at.x + 1) return { row, col };
+ }
+ }
+ return null;
+}
+
+function marchFleet(state) {
+ const fleet = state.fleet;
+ const cols = [];
+ for (let col = 0; col < COLS; col++) if (fleet.alive.some((row) => row[col])) cols.push(col);
+ if (!cols.length) return state;
+ const leftMost = fleet.x + cols[0] * PITCH_X;
+ const rightMost = fleet.x + cols[cols.length - 1] * PITCH_X + 1;
+
+ if ((fleet.dir > 0 && rightMost >= WIDTH - 1) || (fleet.dir < 0 && leftMost <= 0)) {
+ fleet.dir *= -1;
+ fleet.y += 1;
+ } else {
+ fleet.x += fleet.dir;
+ }
+
+ // The fleet landing is the loss condition, and it beats having lives left.
+ for (let row = 0; row < ROWS; row++) {
+ for (let col = 0; col < COLS; col++) {
+ if (fleet.alive[row][col] && alienAt(fleet, row, col).y >= BUNKER_ROW) {
+ state.over = `the fleet landed · ${state.score} points`;
+ return state;
+ }
+ }
+ }
+ return state;
+}
+
+/** One tick. Exported so a test can clear a wave with no clock. */
+export function step(state) {
+ const { rng } = state;
+
+ // The shot climbs a row at a time even though it covers two, so it can never
+ // skip over the row an alien is standing on.
+ for (let i = 0; i < SHOT_SPEED && state.shot; i++) {
+ state.shot.y -= 1;
+ if (state.shot.y < 0) { state.shot = null; break; }
+ if (chip(state.bunkers, state.shot.x, state.shot.y)) { state.shot = null; break; }
+ const hit = alienHit(state.fleet, state.shot.x, state.shot.y);
+ if (!hit) continue;
+ state.fleet.alive[hit.row][hit.col] = false;
+ state.score += KINDS[hit.row].points;
+ state.shot = null;
+ if (!aliveCount(state.fleet)) {
+ state.wave++;
+ state.fleet = newFleet(state.wave);
+ state.bombs = [];
+ return state;
+ }
+ }
+
+ state.tick++;
+ if (state.tick % BOMB_EVERY === 0) {
+ for (const bomb of state.bombs) bomb.y += 1;
+ state.bombs = state.bombs.filter((bomb) => {
+ if (chip(state.bunkers, bomb.x, bomb.y)) return false;
+ if (bomb.y >= FLOOR_ROW) return false;
+ if (bomb.y === CANNON_ROW && Math.abs(bomb.x - state.cannon) <= 1) {
+ state.lives--;
+ if (state.lives <= 0) { state.lives = 0; state.over = `out of cannons · ${state.score} points`; }
+ return false;
+ }
+ return true;
+ });
+ }
+ if (state.over) return state;
+
+ // Somebody on the front line lets one go, more often the fewer are left.
+ const front = frontLine(state.fleet);
+ if (front.length && state.bombs.length < 3 && rng() < 0.02 + (COLS - front.length) * 0.004) {
+ const from = front[Math.floor(rng() * front.length) % front.length];
+ const at = alienAt(state.fleet, from.row, from.col);
+ state.bombs.push({ x: at.x, y: at.y + 1 });
+ }
+
+ state.fleet.clock++;
+ if (state.fleet.clock >= cadence(state.fleet)) {
+ state.fleet.clock = 0;
+ marchFleet(state);
+ }
+ return state;
+}
+
+export const INVADERS = {
+ key: "invaders",
+ aliases: ["spaceinvaders", "space", "aliens"],
+ title: "SPACE INVADERS",
+ blurb: "forty of them, and the last one moves fastest",
+ keys: "← → move · space fire · q quit",
+ tickMs: 55,
+
+ create({ rng = Math.random } = {}) {
+ return {
+ fleet: newFleet(1),
+ bunkers: buildBunkers(),
+ cannon: Math.floor(WIDTH / 2),
+ shot: null,
+ bombs: [],
+ score: 0,
+ lives: LIVES,
+ wave: 1,
+ tick: 0,
+ over: null,
+ rng,
+ };
+ },
+
+ tick: step,
+
+ onKey(state, key) {
+ if (key === "left") state.cannon = clamp(state.cannon - 1, 1, WIDTH - 2);
+ else if (key === "right") state.cannon = clamp(state.cannon + 1, 1, WIDTH - 2);
+ else if (key === "space" || key === "up" || key === "enter") {
+ // One shot in the air at a time. Everything about the pacing of this game
+ // comes from that single rule.
+ if (!state.shot) state.shot = { x: state.cannon, y: CANNON_ROW - 1 };
+ }
+ return state;
+ },
+
+ status(state) {
+ return state.over
+ ? state.over
+ : `${state.score} · wave ${state.wave} · ${"▲".repeat(state.lives)}`;
+ },
+
+ render(state) {
+ const grid = Array.from({ length: HEIGHT }, () => Array.from({ length: WIDTH }, () => null));
+ const put = (x, y, glyph) => {
+ if (x < 0 || x >= WIDTH || y < 0 || y >= HEIGHT) return;
+ grid[y][x] = glyph;
+ };
+
+ for (let row = 0; row < ROWS; row++) {
+ for (let col = 0; col < COLS; col++) {
+ if (!state.fleet.alive[row][col]) continue;
+ const at = alienAt(state.fleet, row, col);
+ const kind = KINDS[row];
+ [...kind.art].forEach((c, i) => put(at.x + i, at.y, kind.paint(c)));
+ }
+ }
+
+ for (const [x, hp] of state.bunkers) put(x, BUNKER_ROW, hp > 1 ? acid("█") : dim("▓"));
+ for (const bomb of state.bombs) put(bomb.x, bomb.y, danger("╽"));
+ if (state.shot) put(state.shot.x, state.shot.y, bone("│"));
+
+ if (state.over) {
+ put(state.cannon, CANNON_ROW, danger("✷"));
+ } else {
+ [...("▟█▙")].forEach((c, i) => put(state.cannon - 1 + i, CANNON_ROW, acid(c)));
+ }
+
+ return grid.map((row, y) => row.map((cell) => (
+ cell ?? (y === FLOOR_ROW ? ash("═") : " ")
+ )).join(""));
+ },
+};
diff --git a/src/games-kong.mjs b/src/games-kong.mjs
new file mode 100644
index 0000000..a2f1198
--- /dev/null
+++ b/src/games-kong.mjs
@@ -0,0 +1,191 @@
+// Kong. Girders, ladders, barrels, and a climb you have done before.
+//
+// The girders here are flat. The originals slope, and a slope is the one thing
+// this board cannot honestly draw — a terminal row is a terminal row, and faking
+// it with half-blocks would make a game that looks like the arcade and plays
+// like a bug report. What the slope actually *does* is kept: each girder has a
+// direction, they alternate, and the ladder down sits at the far end of each
+// one. So barrels cross a whole girder and drop, and a player climbing the same
+// ladders walks every girder the opposite way — which is why you meet them head
+// on instead of following them around.
+import { acid, amber, ash, bone, danger, rgb } from "./ui.mjs";
+
+export const WIDTH = 34;
+export const HEIGHT = 17;
+
+/**
+ * Top to bottom: the row, the way barrels roll along it, the column of the
+ * ladder barrels come down, and a second ladder that only you use.
+ *
+ * The second one is not decoration. With a single ladder per girder, the only
+ * way up is the chute the barrels fall down, and climbing it is a coin toss you
+ * cannot jump out of — the board becomes unplayable rather than hard.
+ */
+export const GIRDERS = [
+ { y: 3, dir: -1, ladder: 3, climb: WIDTH - 12 },
+ { y: 6, dir: 1, ladder: WIDTH - 4, climb: 8 },
+ { y: 9, dir: -1, ladder: 3, climb: WIDTH - 12 },
+ { y: 12, dir: 1, ladder: WIDTH - 4, climb: 8 },
+ { y: 15, dir: -1, ladder: null, climb: null },
+];
+
+export const TOP = GIRDERS[0].y;
+export const FLOOR = GIRDERS[GIRDERS.length - 1].y;
+
+/** Where Kong stands and throws from, and the way out beside him. */
+export const KONG_X = WIDTH - 3;
+const GOAL_X = WIDTH - 6;
+
+const LIVES = 3;
+const JUMP_TICKS = 6;
+const kong = rgb(190, 130, 255);
+
+export const girderAt = (y) => GIRDERS.find((g) => g.y === y) ?? null;
+
+/** The ladders, derived from the girders so the two can never disagree. */
+export const LADDERS = GIRDERS.slice(0, -1).flatMap((g, i) => (
+ [g.ladder, g.climb].map((x) => ({ x, top: g.y, bottom: GIRDERS[i + 1].y, barrels: x === g.ladder }))
+));
+
+export const ladderAt = (x, y) => LADDERS.find((l) => l.x === x && y >= l.top && y <= l.bottom) ?? null;
+
+/** A barrel starts at Kong's end of the top girder and works its way down. */
+export const newBarrel = () => ({ x: KONG_X, y: TOP, falling: null, jumped: false });
+
+/**
+ * One barrel step: along its girder in that girder's direction, and down the
+ * ladder at the end of it. Rolling off the bottom girder is how a barrel leaves.
+ */
+export function rollBarrel(state, barrel) {
+ if (barrel.falling !== null) {
+ barrel.y += 1;
+ if (barrel.y >= barrel.falling) barrel.falling = null;
+ return barrel;
+ }
+ const girder = girderAt(barrel.y);
+ if (!girder) { barrel.done = true; return barrel; }
+ if (girder.ladder !== null && barrel.x === girder.ladder) {
+ // Mostly it takes the ladder; sometimes it carries on and rolls off the end,
+ // which is the only thing that makes two barrels behave differently.
+ if (state.rng() < 0.8) {
+ barrel.falling = GIRDERS[GIRDERS.indexOf(girder) + 1].y;
+ return barrel;
+ }
+ }
+ barrel.x += girder.dir;
+ if (barrel.x < 0 || barrel.x >= WIDTH) barrel.done = true;
+ return barrel;
+}
+
+function lose(state, why) {
+ state.lives--;
+ if (state.lives <= 0) {
+ state.lives = 0;
+ state.over = `${why} · ${state.score} points`;
+ return state;
+ }
+ state.player = { x: 1, y: FLOOR, jump: 0 };
+ state.barrels = [];
+ return state;
+}
+
+/** Barrels roll this often, and a level makes them quicker. */
+export const rollEvery = (state) => Math.max(2, 5 - Math.floor(state.level / 2));
+export const throwEvery = (state) => Math.max(14, 40 - state.level * 5);
+
+/** One tick. Exported so a test can climb the whole board with no clock. */
+export function step(state) {
+ state.clock++;
+ if (state.player.jump > 0) state.player.jump--;
+
+ if (state.clock % rollEvery(state) === 0) {
+ for (const barrel of state.barrels) rollBarrel(state, barrel);
+ state.barrels = state.barrels.filter((b) => !b.done);
+ }
+ if (state.clock % throwEvery(state) === 0) state.barrels.push(newBarrel());
+
+ for (const barrel of state.barrels) {
+ if (barrel.x !== state.player.x || barrel.y !== state.player.y) continue;
+ // A barrel you are in the air over is a barrel you have jumped.
+ if (!state.player.jump) return lose(state, "flattened by a barrel");
+ if (!barrel.jumped) { barrel.jumped = true; state.score += 100; }
+ }
+
+ if (state.player.y === TOP && state.player.x >= GOAL_X) {
+ state.level++;
+ state.score += 1000;
+ state.player = { x: 1, y: FLOOR, jump: 0 };
+ state.barrels = [];
+ }
+ return state;
+}
+
+export const KONG = {
+ key: "kong",
+ aliases: ["dk", "barrels", "climb"],
+ title: "KONG",
+ blurb: "five girders, four ladders, and a barrel with your name on it",
+ keys: "← → walk · ↑ ↓ ladders · space jump · q quit",
+ tickMs: 60,
+
+ create({ rng = Math.random } = {}) {
+ return {
+ player: { x: 1, y: FLOOR, jump: 0 },
+ barrels: [],
+ score: 0,
+ lives: LIVES,
+ level: 1,
+ clock: 0,
+ over: null,
+ rng,
+ };
+ },
+
+ tick: step,
+
+ onKey(state, pressed) {
+ const p = state.player;
+ if (pressed === "space" || pressed === "enter") {
+ if (!p.jump) p.jump = JUMP_TICKS;
+ return state;
+ }
+ if (pressed === "left" && p.x > 0) p.x -= 1;
+ else if (pressed === "right" && p.x < WIDTH - 1) p.x += 1;
+ else if (pressed === "up" || pressed === "down") {
+ // Ladders are the only way between girders, and you have to be standing on
+ // one to use it.
+ const ladder = ladderAt(p.x, p.y);
+ if (!ladder) return state;
+ const next = pressed === "up" ? p.y - 1 : p.y + 1;
+ if (next >= ladder.top && next <= ladder.bottom) p.y = next;
+ }
+ return state;
+ },
+
+ status(state) {
+ return state.over
+ ? state.over
+ : `${state.score} · level ${state.level} · ${"▲".repeat(state.lives)}`;
+ },
+
+ render(state) {
+ const grid = Array.from({ length: HEIGHT }, () => Array.from({ length: WIDTH }, () => null));
+ const put = (x, y, glyph) => {
+ if (x < 0 || x >= WIDTH || y < 0 || y >= HEIGHT) return;
+ grid[y][x] = glyph;
+ };
+
+ for (const girder of GIRDERS) for (let x = 0; x < WIDTH; x++) put(x, girder.y, ash("═"));
+ for (const ladder of LADDERS) {
+ for (let y = ladder.top; y <= ladder.bottom; y++) put(ladder.x, y, bone("╫"));
+ }
+ put(KONG_X, TOP - 1, kong("♜"));
+ put(GOAL_X, TOP - 1, amber("♥"));
+
+ for (const barrel of state.barrels) put(barrel.x, barrel.y, danger("◍"));
+ const p = state.player;
+ put(p.x, p.jump ? p.y - 1 : p.y, state.over ? danger("✷") : acid(p.jump ? "⌃" : "◉"));
+
+ return grid.map((row) => row.map((cell) => cell ?? " ").join(""));
+ },
+};
diff --git a/src/games-outrun.mjs b/src/games-outrun.mjs
new file mode 100644
index 0000000..f43e12d
--- /dev/null
+++ b/src/games-outrun.mjs
@@ -0,0 +1,208 @@
+// OutRun. A road drawn in perspective, a clock that is always losing, and a
+// checkpoint that gives you a bit of it back.
+//
+// This is the one game in the cabinet that fakes a third dimension, and it does
+// it the way the eighties did: the road is not an object, it is a rule for
+// drawing each row. Rows near the top are far away, so the tarmac is narrow
+// there and the bend is wide; rows near the bottom are under your bumper, so the
+// tarmac is wide and dead centre. Nothing is ever transformed — `roadHalf` and
+// `centreAt` are the whole renderer, and the same two functions decide what you
+// have hit.
+import { acid, bone, danger, dim, rgb } from "./ui.mjs";
+
+export const WIDTH = 48;
+export const HEIGHT = 16;
+
+const NEAR_HALF = 20; // half the road's width under your bumper
+const FAR_HALF = 3; // and up at the horizon
+const BEND = 15; // how far a full-lock corner throws the far end sideways
+
+const MAX_SPEED = 1.9;
+const ACCEL = 0.06;
+const BRAKE = 0.12;
+const DRAG = 0.012;
+const OFF_ROAD = 0.55; // the fastest you will ever go with two wheels on the grass
+const DRIFT = 0.5; // how hard a corner pushes you towards the outside
+const START_TIME = 900; // ticks
+const CHECKPOINT = 320; // and how far apart the checkpoints are
+const CHECK_BONUS = 220; // flat out, a checkpoint takes about 170 ticks to reach
+
+const grass = rgb(60, 140, 70);
+const road = rgb(90, 90, 95);
+
+/** How far from the middle the tarmac reaches on a given row. */
+export const roadHalf = (row) => {
+ const p = (row + 1) / HEIGHT;
+ return FAR_HALF + (NEAR_HALF - FAR_HALF) * p ** 1.7;
+};
+
+/**
+ * Where the middle of the road is on a given row.
+ *
+ * The bend is squared against distance so it opens up towards the horizon and
+ * closes to nothing under the car — which is exactly what a corner looks like
+ * from the driver's seat, and why the bottom row never moves.
+ */
+export const centreAt = (row, curve) => {
+ const p = (row + 1) / HEIGHT;
+ return WIDTH / 2 + curve * BEND * (1 - p) ** 2;
+};
+
+export const PLAYER_ROW = HEIGHT - 1;
+export const CAR_W = 3;
+
+/** A stretch of road: how long it runs, and how hard it bends. */
+export function nextSegment(rng) {
+ return { left: 40 + Math.floor(rng() * 60), curve: (rng() * 2 - 1) * (rng() < 0.35 ? 1 : 0.45) };
+}
+
+/** Whether the car is on the tarmac at all. */
+export const onRoad = (x, curve) => Math.abs(x - centreAt(PLAYER_ROW, curve)) <= roadHalf(PLAYER_ROW) - 1;
+
+/** Where a car at depth `z` (1 at the horizon, 0 at your bumper) is drawn. */
+export const rowAt = (z) => Math.round(PLAYER_ROW - z * (PLAYER_ROW - 1));
+
+function spin(state) {
+ state.speed = 0;
+ state.spins++;
+ state.stunned = 30;
+ return state;
+}
+
+/** One tick of road. Exported so a test can drive the whole stage with no clock. */
+export function step(state) {
+ state.clock--;
+ if (state.clock <= 0) {
+ state.clock = 0;
+ state.over = `time up · ${Math.round(state.dist)} miles`;
+ return state;
+ }
+ if (state.stunned > 0) state.stunned--;
+
+ // The road ahead, one segment at a time, eased towards rather than snapped to.
+ state.segment.left -= state.speed;
+ if (state.segment.left <= 0) state.segment = nextSegment(state.rng);
+ state.curve += (state.segment.curve - state.curve) * 0.04;
+
+ state.speed = Math.max(0, state.speed - DRAG);
+ const off = !onRoad(state.car, state.curve);
+ if (off) state.speed = Math.min(state.speed, OFF_ROAD);
+ if (state.stunned) state.speed = Math.min(state.speed, 0.2);
+
+ state.dist += state.speed;
+ // A corner throws you at the outside of it. Steering is how you stay in.
+ state.car += state.curve * state.speed * DRIFT;
+ state.car = Math.max(0, Math.min(WIDTH - 1, state.car));
+
+ for (const car of state.traffic) car.z -= (state.speed - car.speed) * 0.012;
+ state.traffic = state.traffic.filter((c) => c.z > -0.05 && c.z < 1.2);
+ if (state.traffic.length < 3 && state.rng() < 0.03) {
+ state.traffic.push({ z: 1.1, lane: state.rng() * 1.4 - 0.7, speed: 0.35 + state.rng() * 0.5 });
+ }
+
+ if (!state.stunned) {
+ const hit = state.traffic.find((c) => {
+ if (c.z > 0.08) return false;
+ const at = centreAt(PLAYER_ROW, state.curve) + c.lane * roadHalf(PLAYER_ROW);
+ return Math.abs(at - state.car) < CAR_W;
+ });
+ if (hit) {
+ state.traffic = state.traffic.filter((c) => c !== hit);
+ spin(state);
+ }
+ }
+
+ if (state.dist >= state.nextCheck) {
+ state.nextCheck += CHECKPOINT;
+ state.clock += CHECK_BONUS;
+ state.checks++;
+ state.score += 1000;
+ }
+ state.score += Math.floor(state.dist) - state.scored;
+ state.scored = Math.floor(state.dist);
+ return state;
+}
+
+export const OUTRUN = {
+ key: "outrun",
+ aliases: ["run", "coast", "racer"],
+ title: "OUTRUN",
+ blurb: "a road that bends, traffic that doesn't, and a clock that always wins",
+ keys: "← → steer · ↑ throttle · ↓ brake · q quit",
+ tickMs: 55,
+
+ create({ rng = Math.random } = {}) {
+ return {
+ car: WIDTH / 2,
+ speed: 0,
+ curve: 0,
+ segment: nextSegment(rng),
+ traffic: [],
+ dist: 0,
+ scored: 0,
+ nextCheck: CHECKPOINT,
+ checks: 0,
+ clock: START_TIME,
+ stunned: 0,
+ spins: 0,
+ score: 0,
+ over: null,
+ rng,
+ };
+ },
+
+ tick: step,
+
+ onKey(state, pressed) {
+ if (pressed === "left") state.car -= 0.9;
+ else if (pressed === "right") state.car += 0.9;
+ else if (pressed === "up") state.speed = Math.min(MAX_SPEED, state.speed + ACCEL * 4);
+ else if (pressed === "down") state.speed = Math.max(0, state.speed - BRAKE * 2);
+ state.car = Math.max(0, Math.min(WIDTH - 1, state.car));
+ return state;
+ },
+
+ status(state) {
+ if (state.over) return state.over;
+ const kph = Math.round(state.speed * 120);
+ return `${kph} kph · ${Math.round(state.clock / 20)}s · check ${state.checks} · ${Math.round(state.dist)} mi`;
+ },
+
+ render(state) {
+ const grid = Array.from({ length: HEIGHT }, () => Array.from({ length: WIDTH }, () => null));
+ const put = (x, y, glyph) => {
+ if (x < 0 || x >= WIDTH || y < 0 || y >= HEIGHT) return;
+ grid[y][x] = glyph;
+ };
+
+ for (const car of state.traffic) {
+ const row = rowAt(car.z);
+ if (row < 1 || row > PLAYER_ROW) continue;
+ const at = centreAt(row, state.curve) + car.lane * roadHalf(row);
+ // Cars shrink with distance, the same way the road does.
+ const w = Math.max(1, Math.round(CAR_W * ((row + 1) / HEIGHT)));
+ for (let i = 0; i < w; i++) put(Math.round(at) - Math.floor(w / 2) + i, row, danger("▀"));
+ }
+
+ const nose = state.stunned ? danger("✷") : acid("▟▙");
+ put(Math.round(state.car) - 1, PLAYER_ROW, state.stunned ? nose : acid("▟"));
+ put(Math.round(state.car), PLAYER_ROW, state.stunned ? nose : acid("█"));
+ put(Math.round(state.car) + 1, PLAYER_ROW, state.stunned ? nose : acid("▙"));
+
+ return grid.map((row, y) => {
+ const centre = centreAt(y, state.curve);
+ const half = roadHalf(y);
+ return row.map((cell, x) => {
+ if (cell) return cell;
+ const from = centre - half;
+ const to = centre + half;
+ if (x < from || x > to) return grass("░");
+ // Kerbs, and a centre line that moves with you so the road runs.
+ if (x < from + 1 || x > to - 1) return ((y + Math.floor(state.dist)) % 4 < 2 ? bone : danger)("│");
+ const middle = Math.round(centre);
+ if (x === middle && (y + Math.floor(state.dist * 1.5)) % 4 < 2) return dim("┆");
+ return road(" ");
+ }).join("");
+ });
+ },
+};
diff --git a/src/games-pitfall.mjs b/src/games-pitfall.mjs
new file mode 100644
index 0000000..c9be46e
--- /dev/null
+++ b/src/games-pitfall.mjs
@@ -0,0 +1,211 @@
+// Pitfall. The jungle goes past whether you are ready or not: pits, logs,
+// scorpions, and a vine over the worst of them.
+//
+// The vine is the reason this is not just another jumping game. A jump is short
+// and you commit to it the moment you press it; a swing is long, and you can
+// only start one where a vine is hanging — so the hazards that are too wide to
+// jump are the ones that have a vine over them, and reading which is which as
+// it comes at you is the game.
+import { acid, ash, danger, dim, rgb } from "./ui.mjs";
+
+export const WIDTH = 46;
+export const HEIGHT = 13;
+
+export const GROUND = 9; // the row you run along
+const CANOPY = 2; // where the vines hang from
+export const RUNNER = 9; // your column; the jungle moves, you do not
+
+const JUMP = 10; // ticks in the air — about five columns of jungle
+const SWING = 21; // ticks on a vine — a five-wide pit takes about sixteen
+const REACH = 1; // how close to a vine you must be to catch it
+const LIVES = 3;
+const SPEED = 0.55; // columns of jungle per tick
+const TIME = 2400; // ticks on the clock
+
+const jungle = rgb(60, 140, 70);
+const gold = rgb(255, 200, 60);
+
+/** What the jungle throws at you, and what gets you past it. */
+export const HAZARDS = {
+ pit: { w: 5, art: " ", paint: ash, cleared: "swing", death: "down a pit" },
+ log: { w: 2, art: "◙◙", paint: rgb(150, 100, 60), cleared: "jump", death: "rolled over by a log" },
+ scorpion: { w: 1, art: "%", paint: danger, cleared: "jump", death: "stung" },
+};
+
+/** The columns a thing covers. A vine and a bar of gold are one cell each. */
+export const span = (thing) => {
+ const x = Math.round(thing.x);
+ return [x, x + (HAZARDS[thing.kind]?.w ?? 1) - 1];
+};
+
+const touching = (thing) => {
+ const [from, to] = span(thing);
+ return RUNNER >= from && RUNNER <= to;
+};
+
+/**
+ * The next thing down the trail, and how far behind it the one after that will
+ * be. A pit always comes with a vine over it: it is five columns wide and a
+ * jump covers three, so a pit with no vine is a pit nobody gets past.
+ */
+export function spawn(state) {
+ const { rng } = state;
+ const edge = WIDTH + 2;
+ const roll = rng();
+ if (roll < 0.22) {
+ state.things.push({ kind: "treasure", x: edge });
+ state.next = 8 + rng() * 8;
+ } else if (roll < 0.5) {
+ state.things.push({ kind: "pit", x: edge });
+ state.things.push({ kind: "vine", x: edge - 3 });
+ state.next = 22 + rng() * 10;
+ } else if (roll < 0.78) {
+ state.things.push({ kind: "log", x: edge });
+ state.next = 16 + rng() * 10;
+ } else {
+ state.things.push({ kind: "scorpion", x: edge });
+ state.next = 16 + rng() * 10;
+ }
+ return state;
+}
+
+function lose(state, why) {
+ state.lives--;
+ if (state.lives <= 0) {
+ state.lives = 0;
+ state.over = `${why} · ${state.treasure} treasure`;
+ return state;
+ }
+ state.things = state.things.filter((t) => span(t)[1] < RUNNER - 2 || span(t)[0] > RUNNER + 12);
+ state.air = 0;
+ state.swinging = false;
+ state.clock = Math.max(0, state.clock - 120); // a fall costs you time as well
+ return state;
+}
+
+/** One tick of jungle. Exported so a test can run the trail with no clock. */
+export function step(state) {
+ state.clock++;
+ state.dist += SPEED;
+ if (state.clock >= TIME) {
+ state.over = `out of daylight · ${state.treasure} treasure`;
+ return state;
+ }
+
+ if (state.air > 0) {
+ state.air--;
+ if (!state.air) state.swinging = false;
+ }
+
+ for (const thing of state.things) thing.x -= SPEED;
+ state.things = state.things.filter((t) => !t.taken && span(t)[1] > -3);
+
+ state.next -= SPEED;
+ if (state.next <= 0) spawn(state);
+
+ for (const thing of state.things) {
+ if (!touching(thing)) continue;
+ if (thing.kind === "vine") continue; // a vine is scenery until you grab it
+ if (thing.kind === "treasure") {
+ if (state.air) continue; // you cannot scoop it up mid-swing
+ thing.taken = true;
+ state.treasure++;
+ state.score += 500;
+ continue;
+ }
+ // In the air is past it, whichever way you got there.
+ if (state.air > 0) continue;
+ return lose(state, HAZARDS[thing.kind].death);
+ }
+ return state;
+}
+
+/** Grab the vine you are under, if there is one. */
+export function grab(state) {
+ if (state.air) return state;
+ const vine = state.things.find((t) => t.kind === "vine" && Math.abs(Math.round(t.x) - RUNNER) <= REACH);
+ if (!vine) return state;
+ state.air = SWING;
+ state.swinging = true;
+ return state;
+}
+
+export const PITFALL = {
+ key: "pitfall",
+ aliases: ["jungle", "vine"],
+ title: "PITFALL",
+ blurb: "jump the logs, swing the pits, and get the gold before dark",
+ keys: "space jump · ↑ grab a vine · q quit",
+ tickMs: 55,
+
+ create({ rng = Math.random } = {}) {
+ return {
+ things: [],
+ air: 0,
+ swinging: false,
+ next: 20,
+ dist: 0,
+ clock: 0,
+ treasure: 0,
+ score: 0,
+ lives: LIVES,
+ over: null,
+ rng,
+ };
+ },
+
+ tick: step,
+
+ onKey(state, pressed) {
+ if (pressed === "up") return grab(state);
+ if (pressed === "space" || pressed === "enter") {
+ if (!state.air) state.air = JUMP;
+ }
+ return state;
+ },
+
+ status(state) {
+ if (state.over) return state.over;
+ const left = Math.max(0, Math.round((TIME - state.clock) / 20));
+ return `${state.score} · ${state.treasure} gold · ${left}s · ${"▲".repeat(state.lives)}`;
+ },
+
+ render(state) {
+ const grid = Array.from({ length: HEIGHT }, () => Array.from({ length: WIDTH }, () => null));
+ const put = (x, y, glyph) => {
+ if (x < 0 || x >= WIDTH || y < 0 || y >= HEIGHT) return;
+ grid[y][x] = glyph;
+ };
+
+ for (const thing of state.things) {
+ const [from] = span(thing);
+ if (thing.kind === "vine") {
+ for (let y = CANOPY; y < GROUND - 2; y++) put(from, y, jungle("│"));
+ continue;
+ }
+ if (thing.kind === "treasure") { put(from, GROUND - 1, gold("▮")); continue; }
+ const { art, paint, w } = HAZARDS[thing.kind];
+ for (let i = 0; i < w; i++) {
+ // A pit is a hole in the floor rather than something drawn on it.
+ if (thing.kind === "pit") put(from + i, GROUND, dim(" "));
+ else put(from + i, GROUND - 1, paint(art[i]));
+ }
+ }
+
+ const y = state.swinging ? GROUND - 4 : state.air ? GROUND - 3 : GROUND - 1;
+ put(RUNNER, y, state.over ? danger("✷") : acid(state.swinging ? "⌾" : "◉"));
+ if (state.swinging) for (let v = CANOPY; v < y; v++) put(RUNNER, v, jungle("│"));
+
+ return grid.map((row, ry) => row.map((cell, x) => {
+ if (cell) return cell;
+ if (ry === GROUND) {
+ // The floor, with the pits left out of it.
+ const overPit = state.things.some((t) => t.kind === "pit" && x >= span(t)[0] && x <= span(t)[1]);
+ return overPit ? " " : jungle("▀");
+ }
+ if (ry === CANOPY - 1) return dim("╌");
+ if (ry > GROUND) return ash("░");
+ return " ";
+ }).join(""));
+ },
+};
diff --git a/src/games-pong.mjs b/src/games-pong.mjs
new file mode 100644
index 0000000..f78287f
--- /dev/null
+++ b/src/games-pong.mjs
@@ -0,0 +1,147 @@
+// Pong. The oldest one in the cabinet, and still the one that explains itself
+// fastest: you are the left paddle, the ball is going that way, do something.
+//
+// The machine on the right is deliberately not perfect. It waits until the ball
+// crosses the halfway line before it starts tracking, and then it moves slowly
+// enough that it cannot reach a corner from the middle in the time it has left.
+// A flat return it will always get; one taken off the end of your paddle it will
+// not. That is the whole game, and it is why the angle off the paddle depends on
+// where the ball hit it.
+import { acid, bone, danger, dim } from "./ui.mjs";
+
+export const WIDTH = 44;
+export const HEIGHT = 16;
+
+/** A row is worth two columns, so the ball travels at the angle it looks like. */
+const ASPECT = 0.5;
+
+export const PADDLE = 4; // rows tall
+export const YOU_COL = 2;
+export const THEM_COL = WIDTH - 3;
+export const TARGET = 7; // first to this many
+
+const SERVE_SPEED = 0.85;
+const MAX_SPEED = 1.7;
+const SPIN = 0.55; // how much the edge of the paddle bends the ball
+const THEM_SPEED = 0.3; // slow enough that a ball into the corner beats it
+const YOU_STEP = 1;
+
+const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
+
+/** A ball in the middle, heading at whoever just lost the point. */
+export function serve(state, toward) {
+ state.ball = {
+ x: WIDTH / 2,
+ y: HEIGHT / 2,
+ vx: toward * SERVE_SPEED,
+ // Never dead flat: a ball with no angle is a rally nobody can lose.
+ vy: (state.rng() < 0.5 ? -1 : 1) * (0.15 + state.rng() * 0.2),
+ };
+ return state;
+}
+
+/** Where a paddle's rows are, given its top row. */
+export const paddleRows = (top) => Array.from({ length: PADDLE }, (_, i) => Math.round(top) + i);
+
+const catches = (top, y) => y >= top - 0.5 && y <= top + PADDLE - 0.5;
+
+/**
+ * Bounce off a paddle, steeper the further from its middle you take it. This is
+ * the only way a player gets to aim, so it does more work than the physics.
+ */
+function returned(ball, top, dir) {
+ const offset = (ball.y - (top + (PADDLE - 1) / 2)) / (PADDLE / 2);
+ ball.vx = dir * Math.min(MAX_SPEED, Math.abs(ball.vx) * 1.06);
+ ball.vy = clamp(offset * SPIN * ASPECT + ball.vy * 0.3, -0.5, 0.5);
+ // Never let a rally go flat. A ball with no angle is one the machine can park
+ // in front of forever, and a rally that cannot end is not a game.
+ if (Math.abs(ball.vy) < 0.08) ball.vy = (ball.vy < 0 ? -1 : 1) * 0.12;
+ return ball;
+}
+
+/** One tick of rally. Exported so a test can play a whole match with no clock. */
+export function step(state) {
+ const ball = state.ball;
+ ball.x += ball.vx;
+ ball.y += ball.vy;
+
+ // The top and bottom are walls, and the ball is put back inside rather than
+ // just reflected — at speed, a reflection alone can leave it outside.
+ if (ball.y < 0) { ball.y = -ball.y; ball.vy = Math.abs(ball.vy); }
+ if (ball.y > HEIGHT - 1) { ball.y = 2 * (HEIGHT - 1) - ball.y; ball.vy = -Math.abs(ball.vy); }
+
+ if (ball.vx < 0 && ball.x <= YOU_COL) {
+ if (catches(state.you, ball.y)) { ball.x = YOU_COL; returned(ball, state.you, 1); }
+ } else if (ball.vx > 0 && ball.x >= THEM_COL) {
+ if (catches(state.them, ball.y)) { ball.x = THEM_COL; returned(ball, state.them, -1); }
+ }
+
+ if (ball.x < 0) { state.theirs++; point(state, 1); }
+ else if (ball.x > WIDTH - 1) { state.yours++; point(state, -1); }
+
+ // The machine: idle in the middle until the ball is on its half, then chase
+ // the ball's row. Perfect tracking here would make the game unloseable for it,
+ // which is the same thing as unplayable.
+ const chasing = state.ball.vx > 0 && state.ball.x > WIDTH * 0.4;
+ const want = chasing ? state.ball.y - (PADDLE - 1) / 2 : (HEIGHT - PADDLE) / 2;
+ const move = clamp(want - state.them, -THEM_SPEED, chasing ? THEM_SPEED : THEM_SPEED / 2);
+ state.them = clamp(state.them + move, 0, HEIGHT - PADDLE);
+
+ return state;
+}
+
+function point(state, toward) {
+ if (state.yours >= TARGET) state.over = `you take it ${state.yours}–${state.theirs} 🤘`;
+ else if (state.theirs >= TARGET) state.over = `the machine takes it ${state.theirs}–${state.yours}`;
+ else serve(state, toward);
+}
+
+export const PONG = {
+ key: "pong",
+ aliases: ["tennis", "paddle"],
+ title: "PONG",
+ blurb: "first to seven, and the angle is all in where you hit it",
+ keys: "↑ ↓ move · q quit",
+ tickMs: 55,
+
+ create({ rng = Math.random } = {}) {
+ const state = {
+ you: (HEIGHT - PADDLE) / 2,
+ them: (HEIGHT - PADDLE) / 2,
+ yours: 0,
+ theirs: 0,
+ over: null,
+ rng,
+ };
+ return serve(state, rng() < 0.5 ? -1 : 1);
+ },
+
+ tick: step,
+
+ onKey(state, key) {
+ if (key === "up") state.you = clamp(state.you - YOU_STEP, 0, HEIGHT - PADDLE);
+ if (key === "down") state.you = clamp(state.you + YOU_STEP, 0, HEIGHT - PADDLE);
+ return state;
+ },
+
+ status(state) {
+ return state.over ? state.over : `you ${state.yours} · machine ${state.theirs}`;
+ },
+
+ render(state) {
+ const grid = Array.from({ length: HEIGHT }, () => Array.from({ length: WIDTH }, () => null));
+ const put = (x, y, glyph) => {
+ if (x < 0 || x >= WIDTH || y < 0 || y >= HEIGHT) return;
+ grid[y][x] = glyph;
+ };
+
+ for (const row of paddleRows(state.you)) put(YOU_COL, row, acid("█"));
+ for (const row of paddleRows(state.them)) put(THEM_COL, row, danger("█"));
+ put(Math.round(state.ball.x), Math.round(state.ball.y), bone("●"));
+
+ return grid.map((row, y) => row.map((cell, x) => (
+ // The net, which is only there so the middle of the table has a middle.
+ cell ?? (x === Math.floor(WIDTH / 2) && y % 2 === 0 ? dim("┊") : " ")
+ )).join(""));
+ },
+};
diff --git a/src/games-spyhunter.mjs b/src/games-spyhunter.mjs
new file mode 100644
index 0000000..6e6c18a
--- /dev/null
+++ b/src/games-spyhunter.mjs
@@ -0,0 +1,230 @@
+// Spy Hunter. A road that will not hold still, traffic that will not get out of
+// the way, and a gun. Stay on the tarmac, shoot the ones shooting back, and do
+// not shoot the ones just driving home.
+//
+// The road is a list of rows, each one a left and a right edge, scrolled down
+// under a car that only ever moves sideways. Generating the next row from the
+// last one — rather than from a function of distance — is what makes the verge
+// bend instead of zig-zag, and it is the only reason it reads as a road.
+import { acid, amber, ash, bone, danger, dim } from "./ui.mjs";
+
+export const WIDTH = 40;
+export const HEIGHT = 18;
+
+/** The row your car is on. It never changes; the road comes to you. */
+export const CAR_ROW = HEIGHT - 3;
+export const CAR_W = 2;
+
+const MIN_ROAD = 13;
+const MAX_ROAD = 24;
+const BASE_SPEED = 0.34; // rows of road per tick
+const MAX_SPEED = 0.75;
+const LIVES = 3;
+const GRACE = 25; // ticks of "the road is clear" after a wreck
+const SHOT_SPEED = 1.6; // rows per tick, travelled in halves so nothing is skipped
+
+/** The cars that are not you. */
+export const TRAFFIC = {
+ enemy: { art: "▜▛", paint: danger, points: 50, homing: 0.06 },
+ civilian: { art: "▐▌", paint: bone, points: -100, homing: 0 },
+};
+
+const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
+
+/**
+ * The next row of road, bent a little from the one before it.
+ *
+ * The bend is random but pulled towards the middle of the screen, in proportion
+ * to how far out it already is. A drift with no pull is a random walk, and a
+ * random walk parks the road against one edge and leaves it there — which looks
+ * less like a road than like a bug.
+ */
+export function nextRow(prev, rng) {
+ const width = clamp(prev.right - prev.left + (rng() < 0.3 ? (rng() < 0.5 ? -1 : 1) : 0), MIN_ROAD, MAX_ROAD);
+ const centre = (prev.left + prev.right) / 2;
+ const pull = clamp((WIDTH / 2 - centre) / 8, -0.45, 0.45);
+ const roll = rng() * 2 - 1 + pull;
+ const drift = Math.abs(roll) < 0.55 ? 0 : Math.sign(roll);
+ // One cell of verge on each side always stays on screen, so "the road bends"
+ // never reads as "the road ends".
+ const left = clamp(prev.left + drift, 1, WIDTH - width - 2);
+ return { left, right: left + width };
+}
+
+/** A straight run of road to start on, so the first thing you meet is not a bend. */
+export function openRoad() {
+ const left = Math.floor((WIDTH - 20) / 2);
+ return Array.from({ length: HEIGHT }, () => ({ left, right: left + 20 }));
+}
+
+export const onRoad = (row, x) => row && x >= row.left && x + CAR_W - 1 <= row.right;
+
+/** Whether two cars, each CAR_W wide, are in the same place. */
+export const overlaps = (ax, bx) => Math.abs(Math.round(ax) - Math.round(bx)) < CAR_W;
+
+function wreck(state, why) {
+ state.lives--;
+ if (state.lives <= 0) {
+ state.lives = 0;
+ state.over = `${why} · ${state.score} points`;
+ return state;
+ }
+ // A wreck clears the road ahead, or you respawn straight into the car that
+ // just got you and lose the rest of your lives in three ticks.
+ state.traffic = [];
+ state.grace = GRACE;
+ const row = state.road[CAR_ROW];
+ state.car = Math.round((row.left + row.right) / 2) - 1;
+ return state;
+}
+
+/** One tick of road. Exported so a test can drive a whole run with no clock. */
+export function step(state) {
+ const { rng } = state;
+ state.dist += state.speed;
+ state.speed = Math.min(MAX_SPEED, BASE_SPEED + state.dist / 900);
+ if (state.grace > 0) state.grace--;
+
+ // Scroll: the road only shifts on whole rows, so the verge never shimmers.
+ state.scroll += state.speed;
+ while (state.scroll >= 1) {
+ state.scroll -= 1;
+ state.road.pop();
+ state.road.unshift(nextRow(state.road[0], rng));
+ for (const car of state.traffic) car.y += 1;
+ for (const shot of state.shots) shot.y += 1;
+ }
+
+ // Shots move faster than a car is tall, so they travel in half-steps and are
+ // checked against the traffic after each one. Moving the whole way in one go
+ // lets a shot pass clean through a car that was between the two positions.
+ for (let half = 0; half < 2; half++) {
+ for (const shot of state.shots) shot.y -= SHOT_SPEED / 2;
+ hitTraffic(state);
+ }
+ state.shots = state.shots.filter((shot) => shot.y > -1);
+
+ for (const car of state.traffic) {
+ car.y += state.speed - car.speed;
+ // An enemy leans towards you; traffic just drives.
+ if (TRAFFIC[car.kind].homing) {
+ car.x += Math.sign(state.car - car.x) * TRAFFIC[car.kind].homing;
+ }
+ const row = state.road[Math.round(car.y)];
+ if (row) car.x = clamp(car.x, row.left, row.right - CAR_W + 1);
+ }
+ state.traffic = state.traffic.filter((car) => car.y < HEIGHT + 1 && car.y > -3);
+
+ if (!state.grace) {
+ const row = state.road[CAR_ROW];
+ if (!onRoad(row, state.car)) return wreck(state, "off the road");
+ const rammed = state.traffic.find((car) => Math.round(car.y) === CAR_ROW && overlaps(car.x, state.car));
+ if (rammed) return wreck(state, `rammed ${rammed.kind === "enemy" ? "an enemy" : "a civilian"}`);
+ }
+
+ if (!state.grace && state.traffic.length < 4 && rng() < 0.05) {
+ const row = state.road[0];
+ const kind = rng() < 0.6 ? "enemy" : "civilian";
+ state.traffic.push({
+ kind,
+ x: row.left + Math.floor(rng() * (row.right - row.left - CAR_W + 1)),
+ y: 0,
+ // Slower than you, or the road behind would never catch anybody up.
+ speed: 0.1 + rng() * 0.18,
+ });
+ }
+
+ state.score += Math.floor(state.dist / 10) - state.miles;
+ state.miles = Math.floor(state.dist / 10);
+ return state;
+}
+
+/** Shots meet traffic. A civilian you shoot is a civilian you pay for. */
+function hitTraffic(state) {
+ for (const shot of [...state.shots]) {
+ const hit = state.traffic.find((car) => Math.abs(car.y - shot.y) < 0.9 && overlaps(car.x, shot.x - 0.5));
+ if (!hit) continue;
+ state.shots = state.shots.filter((s) => s !== shot);
+ state.traffic = state.traffic.filter((c) => c !== hit);
+ state.score = Math.max(0, state.score + TRAFFIC[hit.kind].points);
+ }
+ return state;
+}
+
+export const SPYHUNTER = {
+ key: "spyhunter",
+ aliases: ["spy", "chase", "hunter"],
+ title: "SPY HUNTER",
+ blurb: "keep it on the tarmac, shoot the ones shooting back",
+ keys: "← → steer · space fire · q quit",
+ tickMs: 55,
+
+ create({ rng = Math.random } = {}) {
+ const road = openRoad();
+ return {
+ road,
+ traffic: [],
+ shots: [],
+ car: Math.round((road[CAR_ROW].left + road[CAR_ROW].right) / 2) - 1,
+ speed: BASE_SPEED,
+ scroll: 0,
+ dist: 0,
+ miles: 0,
+ score: 0,
+ lives: LIVES,
+ grace: 0,
+ over: null,
+ rng,
+ };
+ },
+
+ tick: step,
+
+ onKey(state, key) {
+ if (key === "left") state.car -= 1;
+ else if (key === "right") state.car += 1;
+ else if (key === "space" || key === "up" || key === "enter") {
+ if (state.shots.length < 3) state.shots.push({ x: state.car + 0.5, y: CAR_ROW - 1 });
+ }
+ // Steering off the edge of the screen is a wreck like any other, so the car
+ // is only kept on the board, not on the road.
+ state.car = clamp(state.car, 0, WIDTH - CAR_W);
+ return state;
+ },
+
+ status(state) {
+ if (state.over) return state.over;
+ return `${state.score} · ${state.miles} mi · ${"▲".repeat(state.lives)}`;
+ },
+
+ render(state) {
+ const grid = Array.from({ length: HEIGHT }, () => Array.from({ length: WIDTH }, () => null));
+ const put = (x, y, glyph) => {
+ if (x < 0 || x >= WIDTH || y < 0 || y >= HEIGHT) return;
+ grid[y][x] = glyph;
+ };
+
+ for (const car of state.traffic) {
+ const kind = TRAFFIC[car.kind];
+ [...kind.art].forEach((c, i) => put(Math.round(car.x) + i, Math.round(car.y), kind.paint(c)));
+ }
+ for (const shot of state.shots) put(Math.round(shot.x), Math.round(shot.y), amber("•"));
+ if (state.over) {
+ [...("✷✷")].forEach((c, i) => put(state.car + i, CAR_ROW, danger(c)));
+ } else if (!state.grace || Math.floor(state.grace / 3) % 2) {
+ [...("▟▙")].forEach((c, i) => put(state.car + i, CAR_ROW, acid(c)));
+ }
+
+ return grid.map((row, y) => {
+ const edge = state.road[y];
+ return row.map((cell, x) => {
+ if (cell) return cell;
+ if (x < edge.left || x > edge.right) return ash("▒");
+ // The centre line, dashed, and moving — without it the road is a
+ // stationary corridor and you cannot tell you are going anywhere.
+ const middle = Math.round((edge.left + edge.right) / 2);
+ return x === middle && (y + Math.floor(state.dist)) % 4 < 2 ? dim("┆") : " ";
+ }).join("");
+ });
+ },
+};
diff --git a/src/games-stagedive.mjs b/src/games-stagedive.mjs
new file mode 100644
index 0000000..8198f10
--- /dev/null
+++ b/src/games-stagedive.mjs
@@ -0,0 +1,206 @@
+// Stagedive. You are running the barricade, the stage is coming at you, and it
+// does not stop. Hop the monitor wedges and the amp stacks, duck the
+// crowdsurfers, take the picks. One mistake is the whole run.
+//
+// A side-scroller in a terminal is really a scrolling list: the runner never
+// moves along the x axis at all. Everything else slides left past a fixed
+// column, which is why `speed` is measured in columns per tick and why the gap
+// between hazards is multiplied by it — a jump lasts a fixed number of ticks, so
+// a fair gap has to get longer as the stage gets faster.
+import { acid, amber, ash, bone, danger, dim, rgb } from "./ui.mjs";
+
+export const WIDTH = 50;
+export const HEIGHT = 9;
+
+/** The row the runner's feet are on, and the stage edge under it. */
+export const GROUND = 7;
+const FLOOR = GROUND + 1;
+
+/** The runner's column. It never changes — the stage moves, not you. */
+export const RUNNER = 8;
+
+const BASE_SPEED = 0.85;
+const MAX_SPEED = 1.75;
+const GRAVITY = 0.15;
+const JUMP = -1.3; // ~5 rows up and ~17 ticks in the air
+const DUCK_TICKS = 8; // a tap of ↓ stays crouched this long, so a repeat holds it
+const SLAM = 0.7; // ↓ in mid-air comes down early, which is how you save a bad jump
+
+const crowd = rgb(255, 120, 180);
+
+/**
+ * What is on the stage. `art` is one row's worth of cells and `rows` is which
+ * rows it fills, so its width and its hitbox are the same number by
+ * construction — a hazard that is drawn wider than it hits is the oldest bug in
+ * the genre. `death` is what the status line says when it gets you.
+ */
+export const HAZARDS = {
+ wedge: { art: "██", rows: [GROUND], paint: ash, death: "tripped over a monitor wedge" },
+ stack: { art: "███", rows: [GROUND - 1, GROUND], paint: bone, death: "ran into an amp stack" },
+ // Head height: a standing runner wears it, a crouched one does not.
+ surfer: { art: "╾●╼", rows: [GROUND - 1], paint: crowd, death: "wore a crowdsurfer" },
+};
+
+const PICK = amber("♦");
+
+/** The cells a thing covers, so a hit is decided by what the screen showed. */
+export function cells(thing) {
+ const x = Math.round(thing.x);
+ if (thing.kind === "pick") return { cols: [x, x], rows: [thing.row] };
+ const { art, rows } = HAZARDS[thing.kind];
+ return { cols: [x, x + art.length - 1], rows };
+}
+
+/** The runner: two rows standing, one crouched — which is the whole point of ↓. */
+export function runnerRows(state) {
+ const y = Math.round(state.y);
+ if (state.duck > 0 && !state.airborne) return [GROUND];
+ return [y - 1, y];
+}
+
+const hits = (thing, rows) => {
+ const { cols, rows: theirs } = cells(thing);
+ return RUNNER >= cols[0] && RUNNER <= cols[1] && theirs.some((r) => rows.includes(r));
+};
+
+/**
+ * Put the next thing on the far edge, and decide how far behind it the one
+ * after that will be. The gap scales with speed because a jump is a fixed
+ * number of ticks: without that, the stage eventually outruns the jump and the
+ * game stops being losable-by-mistake and starts being unfair.
+ */
+export function spawn(state) {
+ const { rng } = state;
+ const edge = WIDTH + 2;
+ const roll = rng();
+
+ if (roll < 0.3) {
+ // A line of picks. Low ones are free; a high arc is paid for with a jump.
+ const high = rng() < 0.55;
+ const count = 3 + Math.floor(rng() * 3);
+ for (let i = 0; i < count; i++) {
+ state.things.push({ kind: "pick", x: edge + i * 2, row: high ? (i === 0 || i === count - 1 ? 5 : 4) : GROUND });
+ }
+ state.next = (10 + rng() * 8) * state.speed;
+ return state;
+ }
+
+ const kind = roll < 0.55 ? "wedge" : roll < 0.8 ? "stack" : "surfer";
+ state.things.push({ kind, x: edge });
+ state.next = (16 + rng() * 14) * state.speed;
+ return state;
+}
+
+export const meters = (state) => Math.floor(state.dist / 2);
+
+/** One tick of stage. Exported so a test can run the whole set without a clock. */
+export function step(state) {
+ state.dist += state.speed;
+ state.speed = Math.min(MAX_SPEED, BASE_SPEED + state.dist / 2200);
+
+ if (state.airborne) {
+ state.vy += GRAVITY;
+ state.y += state.vy;
+ if (state.y >= GROUND) { state.y = GROUND; state.vy = 0; state.airborne = false; }
+ }
+ if (state.duck > 0) state.duck--;
+
+ for (const thing of state.things) thing.x -= state.speed;
+ state.things = state.things.filter((t) => !t.taken && t.x > -4);
+
+ state.next -= state.speed;
+ if (state.next <= 0) spawn(state);
+
+ const rows = runnerRows(state);
+ for (const thing of state.things) {
+ if (!hits(thing, rows)) continue;
+ if (thing.kind === "pick") { thing.taken = true; state.picks++; continue; }
+ state.over = `${HAZARDS[thing.kind].death} · ${meters(state)} m`;
+ return state;
+ }
+ return state;
+}
+
+export const STAGEDIVE = {
+ key: "stagedive",
+ aliases: ["dive", "runner", "stage"],
+ title: "STAGEDIVE",
+ blurb: "run the barricade, hop the gear, duck the crowd, take the picks",
+ keys: "↑ jump · ↓ duck (and slam) · space jump · q quit",
+ tickMs: 55,
+
+ create({ rng = Math.random } = {}) {
+ return {
+ y: GROUND,
+ vy: 0,
+ airborne: false,
+ duck: 0,
+ dist: 0,
+ speed: BASE_SPEED,
+ picks: 0,
+ things: [],
+ next: 24, // a moment of clear stage before the first thing arrives
+ over: null,
+ rng,
+ };
+ },
+
+ tick: step,
+
+ onKey(state, key) {
+ if (key === "up" || key === "space" || key === "enter") {
+ if (state.airborne) return state; // no second jump; the floor is the rule
+ state.vy = JUMP;
+ state.airborne = true;
+ state.duck = 0;
+ return state;
+ }
+ if (key === "down") {
+ // In the air this is a slam, on the ground it is a crouch. Both are the
+ // same key because both are "get low", and one key is easier to mean.
+ if (state.airborne) state.vy = Math.max(state.vy, SLAM);
+ else state.duck = DUCK_TICKS;
+ }
+ return state;
+ },
+
+ status(state) {
+ return state.over
+ ? `${state.over} · ${state.picks} picks`
+ : `${meters(state)} m · ${state.picks} picks`;
+ },
+
+ render(state) {
+ const grid = Array.from({ length: HEIGHT }, () => Array.from({ length: WIDTH }, () => null));
+ const put = (x, y, glyph) => {
+ if (x < 0 || x >= WIDTH || y < 0 || y >= HEIGHT) return;
+ grid[y][x] = glyph;
+ };
+
+ for (const thing of state.things) {
+ const { cols, rows } = cells(thing);
+ if (thing.kind === "pick") { put(cols[0], rows[0], PICK); continue; }
+ const { art, paint } = HAZARDS[thing.kind];
+ for (const row of rows) [...art].forEach((c, i) => put(cols[0] + i, row, paint(c)));
+ }
+
+ const y = Math.round(state.y);
+ if (state.over) {
+ put(RUNNER, GROUND, danger("✷"));
+ } else if (state.duck > 0 && !state.airborne) {
+ put(RUNNER, GROUND, acid("▄"));
+ } else {
+ put(RUNNER, y - 1, acid("○"));
+ // The legs alternate with the stage, so standing still looks like running.
+ put(RUNNER, y, acid(state.airborne ? "⋏" : Math.floor(state.dist) % 2 ? "⋀" : "⋏"));
+ }
+
+ return grid.map((row, ry) => row.map((cell, x) => {
+ if (cell) return cell;
+ if (ry !== FLOOR) return " ";
+ // The stage edge, with a mark every few cells so the speed is visible even
+ // when nothing else is on screen.
+ return (x + Math.floor(state.dist)) % 7 === 0 ? dim("╪") : ash("═");
+ }).join(""));
+ },
+};
diff --git a/src/games-tank.mjs b/src/games-tank.mjs
new file mode 100644
index 0000000..f9d52f3
--- /dev/null
+++ b/src/games-tank.mjs
@@ -0,0 +1,230 @@
+// Tank. Two of them in a walled yard, one shell each in the air at a time, five
+// hits and it is over.
+//
+// Everything is on the grid and turned in quarter turns, because a tank you
+// cannot line up is a tank you cannot aim, and lining up *is* the shot. Your
+// keys are one action each — a press turns you or moves you one cell — so
+// holding an arrow drives, and tapping it nudges.
+import { acid, ash, bone, danger, dim } from "./ui.mjs";
+
+export const TARGET = 5;
+
+/**
+ * The yard. `#` is wall, and the outer ring is closed — a shell that leaves the
+ * board is a shell nobody saw stop.
+ */
+export const YARD = [
+ "##########################################",
+ "# #",
+ "# #### ###### #### #",
+ "# # # # #",
+ "# # #### # #### # #",
+ "# # # # # #",
+ "# ##### # # #### # # #### #",
+ "# # # # # #",
+ "# # #### # #### # #",
+ "# # # # #",
+ "# #### ###### #### #",
+ "# #",
+ "##########################################",
+];
+
+export const isWall = (x, y) => (YARD[y]?.[x] ?? "#") === "#";
+
+// Derived from the yard rather than declared beside it, so the two can never
+// disagree about how big the board is.
+export const WIDTH = YARD[0].length;
+export const HEIGHT = YARD.length;
+
+/** Quarter turns, and the glyph a tank wears pointing that way. */
+export const HEADINGS = [
+ { dx: 0, dy: -1, glyph: "▲" },
+ { dx: 1, dy: 0, glyph: "▶" },
+ { dx: 0, dy: 1, glyph: "▼" },
+ { dx: -1, dy: 0, glyph: "◀" },
+];
+
+const SHELL_SPEED = 1; // cells per tick
+const THEM_EVERY = 4; // the machine gets a move every this many ticks
+
+const spawnYou = () => ({ x: 2, y: 6, dir: 1, cool: 0 });
+const spawnThem = () => ({ x: WIDTH - 3, y: 6, dir: 3, cool: 0 });
+
+/** Move a tank one cell if there is floor there. Walls simply refuse. */
+export function drive(tank, sign) {
+ const { dx, dy } = HEADINGS[tank.dir];
+ const x = tank.x + dx * sign;
+ const y = tank.y + dy * sign;
+ if (isWall(x, y)) return false;
+ tank.x = x;
+ tank.y = y;
+ return true;
+}
+
+export const fire = (tank, owner) => ({
+ x: tank.x + HEADINGS[tank.dir].dx,
+ y: tank.y + HEADINGS[tank.dir].dy,
+ dir: tank.dir,
+ owner,
+});
+
+/**
+ * Whether a tank can see another down the barrel: same row or column, nothing
+ * but floor in between. This is both how the machine decides to shoot and the
+ * only thing it is good at.
+ */
+export function lineOfSight(from, to) {
+ const { dx, dy } = HEADINGS[from.dir];
+ let x = from.x + dx;
+ let y = from.y + dy;
+ for (let i = 0; i < Math.max(WIDTH, HEIGHT); i++) {
+ if (isWall(x, y)) return false;
+ if (x === to.x && y === to.y) return true;
+ x += dx;
+ y += dy;
+ }
+ return false;
+}
+
+/**
+ * The next cell on the shortest way from one point to another, and the heading
+ * that gets there.
+ *
+ * This is a breadth-first search of the whole yard on every decision, which
+ * sounds extravagant for 546 cells and is not: it is the difference between a
+ * tank that comes around the block after you and one that drives into the same
+ * wall forever, which is what "just turn towards the enemy" does the moment
+ * there is anything between the two of you.
+ */
+export function stepToward(from, to) {
+ if (from.x === to.x && from.y === to.y) return null;
+ const seen = new Set([`${from.x},${from.y}`]);
+ const queue = [{ x: from.x, y: from.y, first: null }];
+ for (let head = 0; head < queue.length; head++) {
+ const cur = queue[head];
+ for (let dir = 0; dir < HEADINGS.length; dir++) {
+ const x = cur.x + HEADINGS[dir].dx;
+ const y = cur.y + HEADINGS[dir].dy;
+ const key = `${x},${y}`;
+ if (isWall(x, y) || seen.has(key)) continue;
+ seen.add(key);
+ const first = cur.first ?? { x, y, dir };
+ if (x === to.x && y === to.y) return first;
+ queue.push({ x, y, first });
+ }
+ }
+ return null;
+}
+
+/** One quarter turn from `dir` towards `want`, the short way round. */
+export const quarterTurn = (dir, want) => (dir + ((want - dir + 4) % 4 === 3 ? 3 : 1)) % 4;
+
+function hit(state, who) {
+ if (who === "you") state.yours++; else state.theirs++;
+ state.shells = [];
+ state.you = spawnYou();
+ state.them = spawnThem();
+ if (state.yours >= TARGET) state.over = `you take it ${state.yours}–${state.theirs} 🤘`;
+ else if (state.theirs >= TARGET) state.over = `the machine takes it ${state.theirs}–${state.yours}`;
+ return state;
+}
+
+/** One tick: shells first, then the machine takes its turn. */
+export function step(state) {
+ for (const shell of [...state.shells]) {
+ for (let i = 0; i < SHELL_SPEED; i++) {
+ const { dx, dy } = HEADINGS[shell.dir];
+ shell.x += dx;
+ shell.y += dy;
+ if (isWall(shell.x, shell.y)) { state.shells = state.shells.filter((s) => s !== shell); break; }
+ const target = shell.owner === "you" ? state.them : state.you;
+ if (shell.x === target.x && shell.y === target.y) {
+ state.shells = state.shells.filter((s) => s !== shell);
+ hit(state, shell.owner);
+ return state;
+ }
+ }
+ }
+ if (state.over) return state;
+
+ if (state.you.cool > 0) state.you.cool--;
+ if (state.them.cool > 0) state.them.cool--;
+
+ state.clock++;
+ if (state.clock % THEM_EVERY) return state;
+
+ // The machine: shoot if it is looking at you, otherwise turn towards you, and
+ // drive when it is already pointed the right way. It is not clever, but it is
+ // relentless, and in a yard this size that is enough.
+ const them = state.them;
+ if (lineOfSight(them, state.you)) {
+ if (!them.cool && !state.shells.some((s) => s.owner === "them")) {
+ state.shells.push(fire(them, "them"));
+ them.cool = 6;
+ }
+ return state;
+ }
+ const next = stepToward(them, state.you);
+ if (!next) return state;
+ if (them.dir !== next.dir) them.dir = quarterTurn(them.dir, next.dir);
+ else drive(them, 1);
+ return state;
+}
+
+export const TANK = {
+ key: "tank",
+ aliases: ["tanks", "combat"],
+ title: "TANK",
+ blurb: "two tanks, one yard, five hits — line it up and let go",
+ keys: "← → turn · ↑ drive · ↓ reverse · space fire · q quit",
+ tickMs: 60,
+
+ create() {
+ return {
+ you: spawnYou(),
+ them: spawnThem(),
+ shells: [],
+ yours: 0,
+ theirs: 0,
+ clock: 0,
+ over: null,
+ };
+ },
+
+ tick: step,
+
+ onKey(state, key) {
+ const you = state.you;
+ if (key === "left") you.dir = (you.dir + 3) % 4;
+ else if (key === "right") you.dir = (you.dir + 1) % 4;
+ else if (key === "up") drive(you, 1);
+ else if (key === "down") drive(you, -1);
+ else if (key === "space" || key === "enter") {
+ // One shell of yours in the air at a time, same as the machine. Two would
+ // turn a duel into a hosepipe.
+ if (you.cool || state.shells.some((s) => s.owner === "you")) return state;
+ const shell = fire(you, "you");
+ if (!isWall(shell.x, shell.y)) state.shells.push(shell);
+ you.cool = 4;
+ }
+ return state;
+ },
+
+ status(state) {
+ return state.over ? state.over : `you ${state.yours} · machine ${state.theirs}`;
+ },
+
+ render(state) {
+ const grid = YARD.map((row) => [...row].map((c) => (c === "#" ? ash("█") : null)));
+ const put = (x, y, glyph) => {
+ if (!grid[y] || x < 0 || x >= grid[y].length) return;
+ grid[y][x] = glyph;
+ };
+
+ for (const shell of state.shells) put(shell.x, shell.y, (shell.owner === "you" ? bone : danger)("•"));
+ put(state.you.x, state.you.y, acid(HEADINGS[state.you.dir].glyph));
+ put(state.them.x, state.them.y, danger(HEADINGS[state.them.dir].glyph));
+
+ return grid.map((row) => row.map((cell) => cell ?? dim("·")).join(""));
+ },
+};
diff --git a/src/games.mjs b/src/games.mjs
index 551d71d..68510e8 100644
--- a/src/games.mjs
+++ b/src/games.mjs
@@ -1,8 +1,8 @@
// The moshcode arcade — `/games` in the pit, `moshcode games` from a shell.
//
-// Eight games, one frame. Every game here is the same shape (see GAME_SHAPE
+// Twenty-two 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 eight weekend projects: a title, a status line, a boxed board, and
+// rather than twenty-two 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.
@@ -20,6 +20,20 @@ import { HANGMAN } from "./games-hangman.mjs";
import { CHESS } from "./games-chess.mjs";
import { ASTEROIDS } from "./games-asteroids.mjs";
import { BLACKJACK } from "./games-blackjack.mjs";
+import { STAGEDIVE } from "./games-stagedive.mjs";
+import { INVADERS } from "./games-invaders.mjs";
+import { BREAKOUT } from "./games-breakout.mjs";
+import { PONG } from "./games-pong.mjs";
+import { TANK } from "./games-tank.mjs";
+import { SPYHUNTER } from "./games-spyhunter.mjs";
+import { CENTIPEDE } from "./games-centipede.mjs";
+import { FROGGER } from "./games-frogger.mjs";
+import { DIGDUG } from "./games-digdug.mjs";
+import { KONG } from "./games-kong.mjs";
+import { PITFALL } from "./games-pitfall.mjs";
+import { CHOPLIFTER } from "./games-choplifter.mjs";
+import { EXCITEBIKE } from "./games-excitebike.mjs";
+import { OUTRUN } from "./games-outrun.mjs";
/**
* @typedef {object} Game — the whole contract, so a seventh game is an import.
@@ -39,7 +53,11 @@ import { BLACKJACK } from "./games-blackjack.mjs";
*/
/** The cabinet. Order is the order `/games` lists them. */
-export const GAMES = [TETRIS, SNAKE, PACMAN, ASTEROIDS, TICTACTOE, BLACKJACK, CHESS, HANGMAN];
+export const GAMES = [
+ TETRIS, SNAKE, PACMAN, INVADERS, CENTIPEDE, ASTEROIDS, BREAKOUT, PONG, TANK, DIGDUG,
+ FROGGER, KONG, PITFALL, CHOPLIFTER, SPYHUNTER, OUTRUN, EXCITEBIKE, STAGEDIVE,
+ TICTACTOE, BLACKJACK, CHESS, HANGMAN,
+];
/** Games by name, following aliases. Case- and slash-insensitive. */
export function resolveGame(name) {
diff --git a/test/games.test.mjs b/test/games.test.mjs
index de7b299..b8c20ef 100644
--- a/test/games.test.mjs
+++ b/test/games.test.mjs
@@ -19,6 +19,59 @@ import { PACMAN, MAZE, isWall, pellets, WIDTH as P_WIDTH, HEIGHT as P_HEIGHT } f
import {
ASTEROIDS, ROCKS, rock, spawnWave, span, star, WIDTH as A_WIDTH, HEIGHT as A_HEIGHT,
} from "../src/games-asteroids.mjs";
+import {
+ STAGEDIVE, HAZARDS, GROUND, RUNNER, cells, meters, runnerRows, spawn,
+ WIDTH as D_WIDTH, HEIGHT as D_HEIGHT,
+} from "../src/games-stagedive.mjs";
+import {
+ INVADERS, KINDS, ROWS, COLS, BUNKER_ROW, CANNON_ROW, alienAt, aliveCount, cadence, chip, frontLine,
+ WIDTH as I_WIDTH, HEIGHT as I_HEIGHT,
+} from "../src/games-invaders.mjs";
+import {
+ BREAKOUT, BRICK_ROWS, BRICK_COLS, BRICK_TOP, BRICK_W, PADDLE_W, PADDLE_ROW, ROW_POINTS,
+ brickAt, bricksLeft, buildWall, WIDTH as WIDTH_B, HEIGHT as HEIGHT_B,
+} from "../src/games-breakout.mjs";
+import {
+ PONG, PADDLE, YOU_COL, TARGET as PONG_TARGET, WIDTH as WIDTH_P, HEIGHT as HEIGHT_P,
+} from "../src/games-pong.mjs";
+import {
+ TANK, TARGET as TANK_TARGET, drive as driveTank, isWall as isYardWall, lineOfSight, quarterTurn, stepToward,
+ WIDTH as WIDTH_T, HEIGHT as HEIGHT_T,
+} from "../src/games-tank.mjs";
+import {
+ SPYHUNTER, CAR_ROW, CAR_W, TRAFFIC, nextRow, onRoad, openRoad, overlaps,
+ WIDTH as SH_WIDTH, HEIGHT as SH_HEIGHT,
+} from "../src/games-spyhunter.mjs";
+import {
+ CENTIPEDE, ZONE_TOP, bite, cadence as centCadence, newCentipede, spiderStep, walk,
+ WIDTH as C_WIDTH, HEIGHT as C_HEIGHT,
+} from "../src/games-centipede.mjs";
+import {
+ FROGGER, BANK, HOMES, HOME_ROW, RIVER, ROAD, homeAt, thingAt, WIDTH as F_WIDTH,
+} from "../src/games-frogger.mjs";
+import {
+ DIGDUG, SKY, fallRocks, isDug, pump, walkMonster,
+ WIDTH as DD_WIDTH, HEIGHT as DD_HEIGHT,
+} from "../src/games-digdug.mjs";
+import {
+ KONG, GIRDERS, LADDERS, TOP as TOP_K, FLOOR as FLOOR_K, newBarrel, rollBarrel, throwEvery,
+ WIDTH as WIDTH_K, HEIGHT as HEIGHT_K,
+} from "../src/games-kong.mjs";
+import {
+ PITFALL, RUNNER as PF_RUNNER, grab, span as pfSpan, spawn as spawnPitfall,
+ WIDTH as PF_WIDTH, HEIGHT as PF_HEIGHT,
+} from "../src/games-pitfall.mjs";
+import {
+ CHOPLIFTER, BASE, SEATS, WORLD, GROUND as GROUND_CH, onPad,
+ WIDTH as CH_WIDTH, HEIGHT as CH_HEIGHT,
+} from "../src/games-choplifter.mjs";
+import {
+ EXCITEBIKE, LAND_OK, RIDER, speedOf, WIDTH as EB_WIDTH, HEIGHT as EB_HEIGHT,
+} from "../src/games-excitebike.mjs";
+import {
+ OUTRUN, PLAYER_ROW, centreAt, onRoad as onTarmac, roadHalf,
+ WIDTH as OR_WIDTH, HEIGHT as OR_HEIGHT,
+} from "../src/games-outrun.mjs";
import { TICTACTOE, bestMove, emptyBoard as emptyGrid, winner } from "../src/games-tictactoe.mjs";
import {
BLACKJACK, MIN_BET, canSplit, freshDeck, handValue, isBlackjack, settle,
@@ -475,6 +528,1490 @@ test("the asteroids board is drawn to size", () => {
assert.ok(byRow.size >= 8, "a sky with no stars in it");
});
+/* --------------------------------------------------------------- invaders */
+
+/** Run a game's own tick, calling `act(state, i)` first. Used by all five below. */
+function drive(game, state, ticks, act = () => {}) {
+ for (let i = 0; i < ticks && !state.over; i++) {
+ act(state, i);
+ state = game.tick(state);
+ }
+ return state;
+}
+
+test("the fleet has room to come down before it lands", () => {
+ // The bug this replaces: five ranks two rows apart made the fleet nine rows
+ // tall on a board with eleven above the bunkers, so it "landed" after two
+ // drops and no wave was ever survivable.
+ const state = INVADERS.create({ rng: seeded(1) });
+ const bottom = alienAt(state.fleet, ROWS - 1, 0).y;
+ assert.ok(BUNKER_ROW - bottom >= 6, `only ${BUNKER_ROW - bottom} drops before the fleet lands`);
+ assert.ok(bottom < BUNKER_ROW, "the fleet starts on top of the bunkers");
+});
+
+test("a shot takes the alien it reaches, and is worth that rank", () => {
+ const state = INVADERS.create({ rng: seeded(2) });
+ state.bunkers = new Map(); // your own cover eats your own shots; not this test
+ const target = alienAt(state.fleet, ROWS - 1, 3);
+ state.cannon = target.x;
+ INVADERS.onKey(state, "space");
+ const after = drive(INVADERS, state, 12, (s) => { s.bombs = []; });
+ assert.equal(after.fleet.alive[ROWS - 1][3], false, "the alien should be gone");
+ assert.equal(after.score, KINDS[ROWS - 1].points);
+ assert.equal(after.shot, null, "and the shot is spent");
+});
+
+test("a shot moving two rows a tick cannot skip the rank it passes", () => {
+ const state = INVADERS.create({ rng: seeded(3) });
+ const alien = alienAt(state.fleet, ROWS - 1, 2);
+ // Start the shot an odd number of rows below, so a naive two-row step lands
+ // above the alien without ever standing on it.
+ state.shot = { x: alien.x, y: alien.y + 3 };
+ state.bombs = [];
+ INVADERS.tick(state);
+ INVADERS.tick(state);
+ assert.equal(state.fleet.alive[ROWS - 1][2], false, "the shot went straight through it");
+});
+
+test("the fewer are left, the faster they come", () => {
+ const state = INVADERS.create({ rng: seeded(4) });
+ const full = cadence(state.fleet);
+ for (let col = 0; col < COLS; col++) for (let row = 0; row < ROWS - 1; row++) state.fleet.alive[row][col] = false;
+ for (let col = 1; col < COLS; col++) state.fleet.alive[ROWS - 1][col] = false;
+ assert.equal(aliveCount(state.fleet), 1);
+ assert.ok(cadence(state.fleet) < full, "the last one should be the fastest");
+ assert.ok(cadence(state.fleet) >= 2, "but never faster than the clock");
+});
+
+test("the fleet turns round at the wall and drops a row", () => {
+ const state = INVADERS.create({ rng: seeded(5) });
+ state.bombs = [];
+ const startY = state.fleet.y;
+ const startDir = state.fleet.dir;
+ let turns = 0;
+ let was = startDir;
+ drive(INVADERS, state, 600, (s) => { if (s.fleet.dir !== was) { turns++; was = s.fleet.dir; } });
+ assert.ok(state.fleet.y > startY, "it should have come down a row");
+ assert.ok(turns > 0, "and turned round to do it");
+ assert.equal(state.fleet.y - startY, turns, "one row down per turn, no more");
+});
+
+test("a bunker takes two hits from either side, then it is gone", () => {
+ const state = INVADERS.create({ rng: seeded(6) });
+ const [x] = [...state.bunkers.keys()];
+ assert.equal(chip(state.bunkers, x, BUNKER_ROW), true);
+ assert.equal(state.bunkers.get(x), 1, "chipped, not gone");
+ assert.equal(chip(state.bunkers, x, BUNKER_ROW), true);
+ assert.equal(state.bunkers.has(x), false, "gone");
+ assert.equal(chip(state.bunkers, x, BUNKER_ROW), false, "and nothing left to chip");
+ assert.equal(chip(state.bunkers, x, BUNKER_ROW - 1), false, "bunkers are only on their own row");
+});
+
+test("a bomb takes a life, and the last one ends it", () => {
+ const state = INVADERS.create({ rng: seeded(7) });
+ state.bunkers = new Map();
+ state.lives = 2;
+ state.bombs = [{ x: state.cannon, y: CANNON_ROW - 1 }];
+ drive(INVADERS, state, 6, (s) => { if (!s.bombs.length && !s.over) s.bombs = [{ x: s.cannon, y: CANNON_ROW - 1 }]; });
+ assert.ok(state.lives < 2, "a bomb on your head should cost you");
+ const last = drive(INVADERS, state, 40, (s) => { if (!s.bombs.length && !s.over) s.bombs = [{ x: s.cannon, y: CANNON_ROW - 1 }]; });
+ assert.match(last.over, /out of cannons/);
+});
+
+test("clearing the fleet brings a new one, lower down", () => {
+ const state = INVADERS.create({ rng: seeded(8) });
+ for (let row = 0; row < ROWS; row++) for (let col = 0; col < COLS; col++) state.fleet.alive[row][col] = false;
+ state.fleet.alive[0][0] = true;
+ const at = alienAt(state.fleet, 0, 0);
+ const startY = state.fleet.y;
+ state.shot = { x: at.x, y: at.y + 2 };
+ INVADERS.tick(state);
+ assert.equal(state.wave, 2);
+ assert.equal(aliveCount(state.fleet), ROWS * COLS, "a whole new fleet");
+ assert.ok(state.fleet.y > startY, "and it starts closer to you");
+});
+
+test("one shot in the air at a time", () => {
+ const state = INVADERS.create({ rng: seeded(9) });
+ INVADERS.onKey(state, "space");
+ const first = state.shot;
+ INVADERS.onKey(state, "space");
+ assert.equal(state.shot, first, "the second press should do nothing");
+});
+
+test("the fleet cannot out-march somebody aiming at it", () => {
+ // A player who picks the nearest alien on the front line and stays on it
+ // until it is dead. It does not dodge a single bomb, which is exactly why the
+ // interesting result below is *how* it dies.
+ const aiming = (state) => {
+ const front = frontLine(state.fleet);
+ if (!front.length) return;
+ if (!state.aim || !state.fleet.alive[state.aim.row]?.[state.aim.col]) {
+ state.aim = front.slice().sort((a, b) => (
+ Math.abs(alienAt(state.fleet, a.row, a.col).x - state.cannon)
+ - Math.abs(alienAt(state.fleet, b.row, b.col).x - state.cannon)
+ ))[0];
+ }
+ const want = alienAt(state.fleet, state.aim.row, state.aim.col).x;
+ if (state.cannon < want) INVADERS.onKey(state, "right");
+ else if (state.cannon > want) INVADERS.onKey(state, "left");
+ else INVADERS.onKey(state, "space");
+ };
+
+ let cleared = 0;
+ for (let seed = 1; seed <= 8; seed++) {
+ const state = drive(INVADERS, INVADERS.create({ rng: seeded(seed) }), 3000, aiming);
+ // The regression that matters: every one of these runs ends with the bombs
+ // getting you, never with the fleet walking over you. When the ranks were
+ // spaced two rows apart the fleet landed on every seed instead, and no
+ // amount of shooting could stop it.
+ assert.match(state.over ?? "", /out of cannons/, `seed ${seed} ended: ${state.over}`);
+ assert.ok(state.score >= 500, `seed ${seed} only managed ${state.score} of a 720-point wave`);
+ if (state.wave > 1) cleared++;
+ }
+ assert.ok(cleared >= 4, `only ${cleared} of 8 waves fell to somebody aiming`);
+
+ for (let seed = 1; seed <= 8; seed++) {
+ const idle = drive(INVADERS, INVADERS.create({ rng: seeded(seed) }), 2500);
+ assert.ok(idle.over, `seed ${seed} survived without firing a shot`);
+ }
+});
+
+test("the invaders board is drawn to size", () => {
+ const state = drive(INVADERS, INVADERS.create({ rng: seeded(10) }), 120);
+ const rows = INVADERS.render(state);
+ assert.equal(rows.length, I_HEIGHT);
+ for (const row of rows) assert.equal(visible(row), I_WIDTH);
+});
+
+/* -------------------------------------------------------------- centipede */
+
+test("a shot to the middle leaves a mushroom and two centipedes", () => {
+ const state = CENTIPEDE.create({ rng: seeded(1) });
+ state.field = new Map();
+ state.centipede = newCentipede(6);
+ const middle = state.centipede[3];
+ state.shots = [{ x: middle.x, y: middle.y + 1 }];
+ CENTIPEDE.tick(state);
+ assert.equal(state.centipede.length, 5, "the segment is gone");
+ assert.equal(state.centipede.includes(middle), false);
+ assert.ok(state.field.size >= 1, "and it left a mushroom where it fell");
+ assert.equal(state.score >= 10, true);
+ // The pieces either side carry on independently, which is what splitting is.
+ const before = state.centipede.map((s) => s.x);
+ for (let i = 0; i < 20; i++) CENTIPEDE.tick(state);
+ assert.notDeepEqual(state.centipede.map((s) => s.x), before, "both halves should still be walking");
+});
+
+test("a mushroom takes four hits", () => {
+ const field = new Map();
+ field.set("5,5", 4);
+ assert.equal(bite(field, 5, 5), 1);
+ assert.equal(bite(field, 5, 5), 1);
+ assert.equal(bite(field, 5, 5), 1);
+ assert.equal(bite(field, 5, 5), 5, "the last hit is the one worth points");
+ assert.equal(field.has("5,5"), false);
+ assert.equal(bite(field, 5, 5), 0, "and nothing is left to shoot");
+});
+
+test("it turns and drops at a wall, at a mushroom, and off the floor", () => {
+ const state = CENTIPEDE.create({ rng: seeded(2) });
+ state.field = new Map();
+ const seg = { x: C_WIDTH - 1, y: 3, dir: 1, down: 1 };
+ walk(state, seg);
+ assert.deepEqual([seg.x, seg.y, seg.dir], [C_WIDTH - 1, 4, -1], "the wall turns it and drops it");
+
+ state.field.set(`${seg.x - 1},${seg.y}`, 4);
+ walk(state, seg);
+ assert.equal(seg.y, 5, "a mushroom does the same thing a wall does");
+
+ const floor = { x: 5, y: C_HEIGHT - 1, dir: 1, down: 1 };
+ state.field = new Map([[`6,${C_HEIGHT - 1}`, 4]]);
+ walk(state, floor);
+ assert.equal(floor.down, -1, "off the floor it starts climbing back up");
+});
+
+test("you are confined to the bottom strip", () => {
+ const state = CENTIPEDE.create({ rng: seeded(3) });
+ state.field = new Map();
+ for (let i = 0; i < 20; i++) CENTIPEDE.onKey(state, "up");
+ assert.equal(state.player.y, ZONE_TOP, "the strip is as far up as you go");
+ for (let i = 0; i < 60; i++) CENTIPEDE.onKey(state, "left");
+ assert.equal(state.player.x, 0);
+});
+
+test("a segment that reaches you costs a life, and the last one ends it", () => {
+ const state = CENTIPEDE.create({ rng: seeded(4) });
+ state.field = new Map();
+ state.lives = 1;
+ state.centipede = [{ x: state.player.x - 1, y: state.player.y, dir: 1, down: 1 }];
+ state.clock = 99;
+ CENTIPEDE.tick(state);
+ assert.match(state.over, /eaten/);
+});
+
+test("clearing it brings a faster wave", () => {
+ const state = CENTIPEDE.create({ rng: seeded(5) });
+ const slow = centCadence(state);
+ state.centipede = [];
+ CENTIPEDE.tick(state);
+ assert.equal(state.wave, 2);
+ assert.equal(state.centipede.length, 10, "a whole new one");
+ assert.ok(centCadence(state) < slow, "and it comes down quicker");
+});
+
+test("the spider crosses your strip, eats what it walks over, and leaves", () => {
+ const state = CENTIPEDE.create({ rng: seeded(7) });
+ state.spider = { x: 0, y: C_HEIGHT - 2, dx: 1, dy: 1 };
+ state.field.set(`1,${C_HEIGHT - 1}`, 4);
+ spiderStep(state);
+ assert.equal(state.field.has(`1,${C_HEIGHT - 1}`), false, "a mushroom it walks over is gone whole");
+ for (let i = 0; i < C_WIDTH + 2; i++) spiderStep(state);
+ assert.equal(state.spider, null, "and it walks out the far side rather than living there");
+
+ const shot = CENTIPEDE.create({ rng: seeded(8) });
+ shot.field = new Map();
+ shot.spider = { x: 10, y: C_HEIGHT - 2, dx: 1, dy: 1 };
+ shot.shots = [{ x: 10, y: C_HEIGHT - 1 }];
+ CENTIPEDE.tick(shot);
+ assert.equal(shot.spider, null);
+ assert.equal(shot.score, 300, "and it is the best thing on the board to shoot");
+});
+
+test("the centipede board is drawn to size", () => {
+ const state = CENTIPEDE.create({ rng: seeded(6) });
+ for (let i = 0; i < 80; i++) CENTIPEDE.tick(state);
+ const rows = CENTIPEDE.render(state);
+ assert.equal(rows.length, C_HEIGHT);
+ for (const row of rows) assert.equal(visible(row), C_WIDTH);
+});
+
+test("centipede can be cleared by shooting, and not by standing there", () => {
+ // Chase the nearest segment's column and keep firing.
+ const shooting = (state) => {
+ const near = state.centipede.slice().sort((a, b) => (
+ Math.abs(a.x - state.player.x) - Math.abs(b.x - state.player.x)))[0];
+ if (!near) return;
+ if (state.player.x < near.x) CENTIPEDE.onKey(state, "right");
+ else if (state.player.x > near.x) CENTIPEDE.onKey(state, "left");
+ CENTIPEDE.onKey(state, "space");
+ };
+ let cleared = 0;
+ for (let seed = 1; seed <= 6; seed++) {
+ const state = drive(CENTIPEDE, CENTIPEDE.create({ rng: seeded(seed) }), 2000, shooting);
+ if (state.wave > 1) cleared++;
+ }
+ assert.ok(cleared >= 5, `only ${cleared} of 6 waves fell to somebody shooting`);
+ for (let seed = 1; seed <= 6; seed++) {
+ const idle = drive(CENTIPEDE, CENTIPEDE.create({ rng: seeded(seed) }), 3000);
+ assert.ok(idle.over, `seed ${seed} survived without firing`);
+ }
+});
+
+/* ----------------------------------------------------------------- digdug */
+
+test("moving is digging, and the tunnel is wherever you have been", () => {
+ const state = DIGDUG.create({ rng: seeded(1) });
+ const { x, y } = state.player;
+ assert.equal(isDug(state.ground, x, y), true, "you start in a hole of your own");
+ assert.equal(isDug(state.ground, x, y + 1), false, "and everything under you is solid");
+ DIGDUG.onKey(state, "down");
+ assert.equal(isDug(state.ground, x, y + 1), true, "one step down is one cell dug");
+ assert.equal(state.player.dir, "down", "and you are facing the way you dug");
+});
+
+test("the harpoon only travels down a tunnel", () => {
+ const state = DIGDUG.create({ rng: seeded(2) });
+ state.monsters = [{ x: state.player.x + 3, y: state.player.y, dir: "left", pumped: 0, ghost: 0 }];
+ state.player.dir = "right";
+ pump(state);
+ assert.equal(state.harpoon, null, "three cells of solid ground stops it");
+
+ for (let i = 1; i <= 3; i++) state.ground.delete(`${state.player.x + i},${state.player.y}`);
+ pump(state);
+ assert.ok(state.harpoon, "dug out, it reaches");
+ assert.equal(state.monsters[0].pumped, 1);
+});
+
+test("three pumps pops a monster, and moving lets it go", () => {
+ const state = DIGDUG.create({ rng: seeded(3) });
+ const monster = { x: state.player.x + 1, y: state.player.y, dir: "left", pumped: 0, ghost: 0 };
+ state.monsters = [monster];
+ state.ground.delete(`${monster.x},${monster.y}`);
+ state.player.dir = "right";
+ pump(state);
+ assert.equal(monster.pumped, 1);
+ pump(state);
+ pump(state);
+ assert.equal(state.monsters.length, 0, "the third pump is the last one");
+ assert.ok(state.score >= 300);
+
+ const let_go = DIGDUG.create({ rng: seeded(4) });
+ const other = { x: let_go.player.x + 1, y: let_go.player.y, dir: "left", pumped: 0, ghost: 0 };
+ let_go.monsters = [other];
+ let_go.ground.delete(`${other.x},${other.y}`);
+ let_go.player.dir = "right";
+ pump(let_go);
+ DIGDUG.onKey(let_go, "left");
+ assert.equal(let_go.harpoon, null, "walking away drops the harpoon");
+});
+
+test("a hooked monster stops moving", () => {
+ const state = DIGDUG.create({ rng: seeded(5) });
+ const monster = { x: state.player.x + 1, y: state.player.y, dir: "left", pumped: 2, ghost: 0 };
+ state.monsters = [monster];
+ const at = { x: monster.x, y: monster.y };
+ for (let i = 0; i < 30; i++) walkMonster(state, monster);
+ assert.deepEqual({ x: monster.x, y: monster.y }, at, "being pumped is being pinned");
+});
+
+test("a rock with nothing under it falls, and lands on what is below", () => {
+ const state = DIGDUG.create({ rng: seeded(6) });
+ state.monsters = [];
+ const rock = { x: 10, y: SKY + 2, falling: false };
+ state.rocks = [rock];
+ state.ground.delete(`10,${SKY + 3}`);
+ state.monsters = [{ x: 10, y: SKY + 3, dir: "left", pumped: 0, ghost: 0 }];
+ fallRocks(state);
+ assert.equal(rock.y, SKY + 3, "it came down a row");
+ assert.equal(state.monsters.length, 0, "onto the monster underneath");
+ assert.ok(state.score >= 200);
+});
+
+test("the digdug board is drawn to size", () => {
+ const state = drive(DIGDUG, DIGDUG.create({ rng: seeded(7) }), 100);
+ const rows = DIGDUG.render(state);
+ assert.equal(rows.length, DD_HEIGHT);
+ for (const row of rows) assert.equal(visible(row), DD_WIDTH);
+});
+
+test("a level can be dug out, and standing still is not a plan", () => {
+ const hunting = (state) => {
+ const near = state.monsters.slice().sort((a, b) => (
+ Math.abs(a.x - state.player.x) + Math.abs(a.y - state.player.y)
+ - Math.abs(b.x - state.player.x) - Math.abs(b.y - state.player.y)))[0];
+ if (!near) return;
+ const dx = near.x - state.player.x;
+ const dy = near.y - state.player.y;
+ const facing = { left: dx < 0 && dy === 0, right: dx > 0 && dy === 0, up: dy < 0 && dx === 0, down: dy > 0 && dx === 0 };
+ const lined = (dx === 0 || dy === 0) && Math.abs(dx) + Math.abs(dy) <= 5;
+ if (state.harpoon || (lined && facing[state.player.dir])) DIGDUG.onKey(state, "space");
+ else if (Math.abs(dx) > Math.abs(dy)) DIGDUG.onKey(state, dx > 0 ? "right" : "left");
+ else DIGDUG.onKey(state, dy > 0 ? "down" : "up");
+ };
+ let cleared = 0;
+ for (let seed = 1; seed <= 6; seed++) {
+ const state = drive(DIGDUG, DIGDUG.create({ rng: seeded(seed) }), 4000, hunting);
+ if (state.level > 1) cleared++;
+ }
+ assert.ok(cleared >= 3, `only ${cleared} of 6 levels were dug out`);
+ for (let seed = 1; seed <= 6; seed++) {
+ const idle = drive(DIGDUG, DIGDUG.create({ rng: seeded(seed) }), 3000);
+ assert.ok(idle.over, `seed ${seed} survived standing in its hole`);
+ }
+});
+
+/* ------------------------------------------------------------------- kong */
+
+test("every girder has a way off it, and the ladders line up with the girders", () => {
+ for (const [i, girder] of GIRDERS.entries()) {
+ if (i === GIRDERS.length - 1) {
+ assert.equal(girder.ladder, null, "the floor has nowhere further down");
+ continue;
+ }
+ const down = LADDERS.filter((l) => l.top === girder.y);
+ assert.equal(down.length, 2, `girder ${girder.y} should have a barrel ladder and one of yours`);
+ for (const ladder of down) assert.equal(ladder.bottom, GIRDERS[i + 1].y, "a ladder must reach the next girder");
+ assert.equal(down.filter((l) => l.barrels).length, 1, "and only one of them is the barrel chute");
+ }
+ // The two directions alternate, which is what makes you walk into the barrels
+ // rather than after them.
+ for (let i = 1; i < GIRDERS.length; i++) {
+ assert.notEqual(GIRDERS[i].dir, GIRDERS[i - 1].dir, "girders should alternate");
+ }
+});
+
+test("a barrel crosses a girder and takes the chute down", () => {
+ const state = KONG.create({ rng: () => 0 }); // always takes the ladder
+ const barrel = newBarrel();
+ state.barrels = [barrel];
+ const top = GIRDERS[0];
+ // Roll until it has both crossed the girder and finished coming down.
+ for (let i = 0; i < WIDTH_K * 3 && (barrel.y === top.y || barrel.falling !== null); i++) {
+ rollBarrel(state, barrel);
+ }
+ assert.equal(barrel.y, GIRDERS[1].y, "it should have come down to the next girder");
+ assert.equal(barrel.x, top.ladder, "down the chute, not off the end");
+});
+
+test("a barrel that misses the chute rolls off the world", () => {
+ const state = KONG.create({ rng: () => 0.99 }); // never takes the ladder
+ const barrel = newBarrel();
+ state.barrels = [barrel];
+ for (let i = 0; i < WIDTH_K * 2 && !barrel.done; i++) rollBarrel(state, barrel);
+ assert.equal(barrel.done, true);
+});
+
+test("ladders are the only way up, and only from on one", () => {
+ const state = KONG.create({ rng: seeded(1) });
+ const start = state.player.y;
+ KONG.onKey(state, "up");
+ assert.equal(state.player.y, start, "you cannot climb thin air");
+ const ladder = LADDERS.find((l) => l.bottom === start);
+ state.player.x = ladder.x;
+ KONG.onKey(state, "up");
+ assert.equal(state.player.y, start - 1, "on a ladder you can");
+ for (let i = 0; i < 10; i++) KONG.onKey(state, "up");
+ assert.equal(state.player.y, ladder.top, "and it stops at the girder above");
+});
+
+test("a barrel flattens you unless you are over it", () => {
+ const flat = KONG.create({ rng: seeded(2) });
+ flat.barrels = [{ x: flat.player.x, y: flat.player.y, falling: null }];
+ KONG.tick(flat);
+ assert.equal(flat.lives, 2);
+
+ const jumped = KONG.create({ rng: seeded(2) });
+ jumped.barrels = [{ x: jumped.player.x, y: jumped.player.y, falling: null }];
+ KONG.onKey(jumped, "space");
+ KONG.tick(jumped);
+ assert.equal(jumped.lives, 3, "in the air it goes under you");
+ assert.equal(jumped.score, 100, "and it is worth something");
+});
+
+test("reaching the top is the next level, and faster", () => {
+ const state = KONG.create({ rng: seeded(3) });
+ const slow = throwEvery(state);
+ state.player = { x: WIDTH_K - 6, y: TOP_K, jump: 0 };
+ KONG.tick(state);
+ assert.equal(state.level, 2);
+ assert.ok(state.score >= 1000);
+ assert.equal(state.player.y, FLOOR_K, "and you start again at the bottom");
+ assert.ok(throwEvery(state) < slow, "with barrels coming quicker");
+});
+
+test("the climb can be made, and cannot be made by standing at the bottom", () => {
+ const climbing = (state, i) => {
+ if (i % 2) return;
+ const p = state.player;
+ const near = state.barrels.find((b) => b.y === p.y && Math.abs(b.x - p.x) <= 2);
+ if (near && !p.jump) { KONG.onKey(state, "space"); return; }
+ if (p.y === TOP_K) { KONG.onKey(state, "right"); return; }
+ if (!GIRDERS.some((g) => g.y === p.y)) { KONG.onKey(state, "up"); return; }
+ // Climb the ladder the barrels do not come down.
+ const up = LADDERS.filter((l) => l.bottom === p.y).sort((a, b) => a.barrels - b.barrels)[0];
+ if (!up) return;
+ if (p.x === up.x) KONG.onKey(state, "up");
+ else KONG.onKey(state, up.x > p.x ? "right" : "left");
+ };
+ const climbed = drive(KONG, KONG.create({ rng: seeded(1) }), 4000, climbing);
+ assert.ok(climbed.level > 3, `only got to level ${climbed.level}`);
+ for (let seed = 1; seed <= 5; seed++) {
+ // A barrel has to cross four girders to reach the floor, so this takes a
+ // while — but it always arrives.
+ const idle = drive(KONG, KONG.create({ rng: seeded(seed) }), 3000);
+ assert.ok(idle.over, `seed ${seed} survived at the bottom of the board`);
+ }
+});
+
+test("the kong board is drawn to size", () => {
+ const state = drive(KONG, KONG.create({ rng: seeded(4) }), 200);
+ const rows = KONG.render(state);
+ assert.equal(rows.length, HEIGHT_K);
+ for (const row of rows) assert.equal(visible(row), WIDTH_K);
+});
+
+/* ---------------------------------------------------------------- frogger */
+
+test("the road kills what it touches and the river kills what it does not", () => {
+ const flat = FROGGER.create();
+ flat.frog = { x: 0, row: ROAD[0], drift: null };
+ flat.traffic = [{ row: ROAD[0], x: 0, len: 2, kind: "car" }];
+ FROGGER.tick(flat);
+ assert.equal(flat.lives, 2, "a car you are standing on is a car that got you");
+
+ const wet = FROGGER.create();
+ wet.frog = { x: 0, row: RIVER[0], drift: 0 };
+ wet.traffic = [];
+ FROGGER.tick(wet);
+ assert.equal(wet.lives, 2, "and empty water is just as fatal");
+
+ const dry = FROGGER.create();
+ dry.frog = { x: 2, row: RIVER[0], drift: 2 };
+ dry.traffic = [{ row: RIVER[0], x: 0, len: 6, kind: "log" }];
+ FROGGER.tick(dry);
+ assert.equal(dry.lives, 3, "a log is dry land");
+});
+
+test("a log carries you, including off the end of the world", () => {
+ const state = FROGGER.create();
+ state.traffic = [{ row: RIVER[0], x: 10, len: 6, kind: "log" }];
+ state.frog = { x: 12, row: RIVER[0], drift: 12 };
+ const before = state.frog.x;
+ for (let i = 0; i < 20; i++) FROGGER.tick(state);
+ assert.notEqual(state.frog.x, before, "the river should have moved you");
+
+ const edge = FROGGER.create();
+ edge.traffic = [{ row: RIVER[0], x: F_WIDTH - 4, len: 6, kind: "log" }];
+ edge.frog = { x: F_WIDTH - 1, row: RIVER[0], drift: F_WIDTH - 1 };
+ for (let i = 0; i < 40 && edge.lives === 3; i++) FROGGER.tick(edge);
+ assert.equal(edge.lives, 2, "riding it off the edge still loses the frog");
+});
+
+test("a home is a home, and the bank between them is not", () => {
+ const state = FROGGER.create();
+ state.frog = { x: HOMES[0], row: HOME_ROW + 1, drift: null };
+ state.traffic = [];
+ FROGGER.onKey(state, "up");
+ assert.equal(state.homes[0], true);
+ assert.ok(state.score >= 100);
+ assert.equal(state.frog.row, BANK, "and you start again from the bank");
+
+ const missed = FROGGER.create();
+ missed.frog = { x: HOMES[0] + HOMES.length, row: HOME_ROW + 1, drift: null };
+ missed.traffic = [];
+ FROGGER.onKey(missed, "up");
+ assert.equal(missed.lives, 2, "landing between the homes is a loss");
+
+ const taken = FROGGER.create();
+ taken.homes[1] = true;
+ taken.frog = { x: HOMES[1], row: HOME_ROW + 1, drift: null };
+ taken.traffic = [];
+ FROGGER.onKey(taken, "up");
+ assert.equal(taken.lives, 2, "and so is one you have already filled");
+});
+
+test("five frogs home is the next level", () => {
+ const state = FROGGER.create();
+ state.traffic = [];
+ for (const home of HOMES) {
+ state.frog = { x: home, row: HOME_ROW + 1, drift: null };
+ FROGGER.onKey(state, "up");
+ }
+ assert.equal(state.level, 2);
+ assert.deepEqual(state.homes, HOMES.map(() => false), "and five empty homes again");
+});
+
+test("the lanes run on for ever", () => {
+ const state = FROGGER.create();
+ state.frog = { x: 0, row: BANK, drift: null }; // out of the way on the bank
+ for (let i = 0; i < 2000; i++) FROGGER.tick(state);
+ for (const thing of state.traffic) {
+ assert.ok(thing.x > -thing.len - 3 && thing.x < F_WIDTH + 3, `a ${thing.kind} escaped to ${thing.x}`);
+ }
+});
+
+test("hopping forwards pays, hopping back and forth does not", () => {
+ const state = FROGGER.create();
+ state.traffic = [];
+ FROGGER.onKey(state, "up");
+ const forward = state.score;
+ assert.ok(forward > 0);
+ FROGGER.onKey(state, "down");
+ FROGGER.onKey(state, "left");
+ assert.equal(state.score, forward, "only forwards is progress");
+});
+
+test("a frog can be got home, and not by hopping blind", () => {
+ // Wait for a gap in the lane ahead, then hop. That is the entire game.
+ const patient = (state) => {
+ const next = state.frog.row - 1;
+ if (next === HOME_ROW) {
+ if (homeAt(state.frog.x) >= 0 && !state.homes[homeAt(state.frog.x)]) FROGGER.onKey(state, "up");
+ else FROGGER.onKey(state, state.frog.x < HOMES[0] ? "right" : "left");
+ return;
+ }
+ const blocked = ROAD.includes(next) && thingAt(state.traffic, next, state.frog.x);
+ const wet = RIVER.includes(next) && !thingAt(state.traffic, next, state.frog.x);
+ if (!blocked && !wet) FROGGER.onKey(state, "up");
+ };
+ const state = drive(FROGGER, FROGGER.create(), 4000, patient);
+ assert.ok(state.homes.filter(Boolean).length > 0 || state.level > 1, "nobody got home at all");
+ assert.ok(state.score >= 100, `only scored ${state.score}`);
+
+ const blind = drive(FROGGER, FROGGER.create(), 400, (s) => FROGGER.onKey(s, "up"));
+ assert.ok(blind.over, "hopping without looking should not survive");
+});
+
+/* --------------------------------------------------------------- breakout */
+
+test("a brick is hit exactly where it is drawn", () => {
+ const wall = buildWall();
+ assert.equal(bricksLeft(wall), BRICK_ROWS * BRICK_COLS);
+ for (let i = 0; i < BRICK_W; i++) {
+ assert.deepEqual(brickAt(wall, i, BRICK_TOP), { row: 0, col: 0 }, "the whole width of a brick is that brick");
+ }
+ assert.deepEqual(brickAt(wall, BRICK_W, BRICK_TOP), { row: 0, col: 1 });
+ assert.equal(brickAt(wall, 0, BRICK_TOP - 1), null, "nothing above the wall");
+ assert.equal(brickAt(wall, 0, BRICK_TOP + BRICK_ROWS), null, "nothing below it");
+});
+
+test("a brick breaks, pays its row, and turns the ball around", () => {
+ const state = BREAKOUT.create({ rng: seeded(1) });
+ state.stuck = false;
+ state.ball = { x: 2, y: BRICK_TOP + BRICK_ROWS - 0.4, vx: 0.1, vy: -0.4 };
+ BREAKOUT.tick(state);
+ assert.equal(bricksLeft(state.wall), BRICK_ROWS * BRICK_COLS - 1);
+ assert.equal(state.score, ROW_POINTS[BRICK_ROWS - 1], "the bottom row is the cheap one");
+ assert.ok(state.ball.vy > 0, "and the ball comes back down");
+});
+
+test("the last brick starts the next level with a fresh wall", () => {
+ const state = BREAKOUT.create({ rng: seeded(2) });
+ state.wall = state.wall.map((row) => row.map(() => false));
+ state.wall[0][0] = true;
+ state.stuck = false;
+ state.ball = { x: 1, y: BRICK_TOP + 0.6, vx: 0, vy: -0.5 };
+ BREAKOUT.tick(state);
+ assert.equal(state.level, 2);
+ assert.equal(bricksLeft(state.wall), BRICK_ROWS * BRICK_COLS, "a whole new wall");
+ assert.equal(state.stuck, true, "and the ball is back on the paddle");
+ assert.ok(state.pace > 1, "faster than the last one");
+});
+
+test("the paddle is a steering wheel, not a wall", () => {
+ const middle = (vx) => {
+ const state = BREAKOUT.create({ rng: seeded(3) });
+ state.stuck = false;
+ state.paddle = 10;
+ state.ball = { x: 10 + (PADDLE_W - 1) / 2, y: PADDLE_ROW - 1.2, vx, vy: 0.4 };
+ BREAKOUT.tick(state);
+ return state.ball.vx;
+ };
+ const edge = () => {
+ const state = BREAKOUT.create({ rng: seeded(3) });
+ state.stuck = false;
+ state.paddle = 10;
+ state.ball = { x: 10 + PADDLE_W - 1, y: PADDLE_ROW - 1.2, vx: 0.3, vy: 0.4 };
+ BREAKOUT.tick(state);
+ return state.ball.vx;
+ };
+ assert.ok(edge() > middle(0.3), "taking it off the end should send it wider");
+});
+
+test("missing the ball costs a life, and the third ends it", () => {
+ const state = BREAKOUT.create({ rng: seeded(4) });
+ state.stuck = false;
+ state.paddle = 0;
+ state.ball = { x: WIDTH_B - 1, y: PADDLE_ROW, vx: 0, vy: 0.5 };
+ BREAKOUT.tick(state);
+ assert.equal(state.lives, 2);
+ assert.equal(state.stuck, true, "and the next ball waits on the paddle");
+
+ state.lives = 1;
+ state.stuck = false;
+ state.ball = { x: WIDTH_B - 1, y: PADDLE_ROW, vx: 0, vy: 0.5 };
+ BREAKOUT.tick(state);
+ assert.match(state.over, /out of balls/);
+});
+
+test("the ball rides the paddle until it is launched", () => {
+ const state = BREAKOUT.create({ rng: seeded(5) });
+ BREAKOUT.onKey(state, "left");
+ BREAKOUT.tick(state);
+ assert.equal(state.ball.x, state.paddle + PADDLE_W / 2, "aiming the serve is done with the paddle");
+ BREAKOUT.onKey(state, "space");
+ assert.equal(state.stuck, false);
+ assert.ok(state.ball.vy < 0, "and it goes up");
+});
+
+test("a wall can be cleared, and cannot be cleared by leaving the paddle alone", () => {
+ const tracking = (state) => {
+ if (state.stuck) BREAKOUT.onKey(state, "space");
+ const want = Math.round(state.ball.x) - Math.floor(PADDLE_W / 2);
+ if (want < state.paddle) BREAKOUT.onKey(state, "left");
+ else if (want > state.paddle) BREAKOUT.onKey(state, "right");
+ };
+ for (let seed = 1; seed <= 5; seed++) {
+ const state = drive(BREAKOUT, BREAKOUT.create({ rng: seeded(seed) }), 6000, tracking);
+ assert.ok(state.level > 1, `seed ${seed} never cleared a wall`);
+ }
+ for (let seed = 1; seed <= 5; seed++) {
+ const state = drive(BREAKOUT, BREAKOUT.create({ rng: seeded(seed) }), 3000, (s) => {
+ if (s.stuck) BREAKOUT.onKey(s, "space");
+ });
+ assert.ok(state.over, `seed ${seed} survived without touching the paddle`);
+ }
+});
+
+test("the breakout board is drawn to size", () => {
+ const state = drive(BREAKOUT, BREAKOUT.create({ rng: seeded(6) }), 60, (s) => {
+ if (s.stuck) BREAKOUT.onKey(s, "space");
+ });
+ const rows = BREAKOUT.render(state);
+ assert.equal(rows.length, HEIGHT_B);
+ for (const row of rows) assert.equal(visible(row), WIDTH_B);
+});
+
+/* ------------------------------------------------------------------- pong */
+
+test("a serve is never dead flat", () => {
+ for (let seed = 1; seed <= 20; seed++) {
+ const state = PONG.create({ rng: seeded(seed) });
+ assert.ok(Math.abs(state.ball.vy) > 0.1, "a flat serve is a rally nobody can lose");
+ assert.ok(Math.abs(state.ball.vx) > 0);
+ }
+});
+
+test("the ball stays on the table", () => {
+ const state = PONG.create({ rng: seeded(2) });
+ const seen = drive(PONG, state, 4000, (s) => {
+ assert.ok(s.ball.y >= -0.5 && s.ball.y <= HEIGHT_P - 0.5, `ball left the table at ${s.ball.y}`);
+ });
+ assert.ok(seen.yours + seen.theirs > 0, "somebody should have scored by now");
+});
+
+test("where it hits the paddle is where it goes", () => {
+ const bounce = (at) => {
+ const state = PONG.create({ rng: seeded(3) });
+ state.you = 5;
+ state.ball = { x: YOU_COL + 0.4, y: at, vx: -0.9, vy: 0 };
+ PONG.tick(state);
+ return state.ball.vy;
+ };
+ assert.ok(bounce(5) < 0, "off the top of the paddle sends it up");
+ assert.ok(bounce(8) > 0, "off the bottom sends it down");
+ assert.ok(Math.abs(bounce(6.5)) > 0, "and never dead flat, even off the middle");
+});
+
+test("a ball past the paddle is a point, and seven of them is the match", () => {
+ const state = PONG.create({ rng: seeded(4) });
+ state.you = 0;
+ state.ball = { x: 0.5, y: HEIGHT_P - 1, vx: -1, vy: 0 };
+ PONG.tick(state);
+ assert.equal(state.theirs, 1, "missing it should cost a point");
+
+ state.theirs = PONG_TARGET - 1;
+ state.you = 0;
+ state.ball = { x: 0.5, y: HEIGHT_P - 1, vx: -1, vy: 0 };
+ PONG.tick(state);
+ assert.match(state.over, /the machine takes it/);
+});
+
+test("the machine can be beaten, but not by doing nothing", () => {
+ const tracking = (state) => {
+ const want = state.ball.y - (PADDLE - 1) / 2;
+ if (want < state.you - 0.5) PONG.onKey(state, "up");
+ else if (want > state.you + 0.5) PONG.onKey(state, "down");
+ };
+ for (let seed = 1; seed <= 6; seed++) {
+ const won = drive(PONG, PONG.create({ rng: seeded(seed) }), 30000, tracking);
+ assert.match(won.over ?? "", /you take it/, `seed ${seed}: ${won.yours}–${won.theirs}`);
+ const lost = drive(PONG, PONG.create({ rng: seeded(seed) }), 30000);
+ assert.match(lost.over ?? "", /machine takes it/, "a still paddle should lose");
+ }
+});
+
+test("the pong table is drawn to size", () => {
+ const rows = PONG.render(drive(PONG, PONG.create({ rng: seeded(7) }), 50));
+ assert.equal(rows.length, HEIGHT_P);
+ for (const row of rows) assert.equal(visible(row), WIDTH_P);
+});
+
+/* ------------------------------------------------------------------- tank */
+
+test("the yard is closed on every side", () => {
+ for (let x = 0; x < WIDTH_T; x++) {
+ assert.ok(isYardWall(x, 0) && isYardWall(x, HEIGHT_T - 1), `the yard leaks at column ${x}`);
+ }
+ for (let y = 0; y < HEIGHT_T; y++) {
+ assert.ok(isYardWall(0, y) && isYardWall(WIDTH_T - 1, y), `the yard leaks at row ${y}`);
+ }
+ assert.equal(isYardWall(-1, 5), true, "and anything off the board is wall");
+});
+
+test("a tank cannot drive through a wall", () => {
+ const tank = { x: 1, y: 1, dir: 0, cool: 0 }; // pointed at the top wall
+ assert.equal(driveTank(tank, 1), false);
+ assert.deepEqual([tank.x, tank.y], [1, 1], "and it does not move a bit");
+ tank.dir = 1;
+ assert.equal(driveTank(tank, 1), true);
+ assert.equal(tank.x, 2);
+});
+
+test("a shell only carries down an open lane", () => {
+ const state = TANK.create();
+ // Both tanks start on the same row with the yard's furniture between them.
+ assert.equal(lineOfSight({ x: 1, y: 1, dir: 1 }, { x: 20, y: 1 }), true, "the top lane is open");
+ assert.equal(lineOfSight(state.you, state.them), false, "and the middle is not");
+ assert.equal(lineOfSight({ x: 1, y: 1, dir: 2 }, { x: 20, y: 1 }), false, "nor is a target you are not facing");
+});
+
+test("the machine finds its way round a block rather than into it", () => {
+ // The bug this replaces: "turn towards the enemy, then drive" grinds into the
+ // same wall for ever, and the two tanks never met at all.
+ const first = stepToward({ x: 2, y: 6 }, { x: 39, y: 6 });
+ assert.ok(first, "there should be a way across the yard");
+ assert.notEqual(first.dir, 1, "and it is not straight through the block in front");
+
+ const state = drive(TANK, TANK.create(), 4000);
+ assert.ok(state.theirs > 0 || state.over, "the machine should have come and found you");
+});
+
+test("a hit scores, resets both tanks, and five of them is the match", () => {
+ const state = TANK.create();
+ state.shells = [{ x: state.them.x - 1, y: state.them.y, dir: 1, owner: "you" }];
+ TANK.tick(state);
+ assert.equal(state.yours, 1);
+ assert.equal(state.shells.length, 0, "the shell is spent");
+ assert.deepEqual([state.you.x, state.you.y], [2, 6], "and both tanks go back to their corners");
+
+ state.yours = TANK_TARGET - 1;
+ state.shells = [{ x: state.them.x - 1, y: state.them.y, dir: 1, owner: "you" }];
+ TANK.tick(state);
+ assert.match(state.over, /you take it/);
+});
+
+test("one shell each in the air", () => {
+ const state = TANK.create();
+ state.you.dir = 0; // up the open lane, so the shell survives the first tick
+ TANK.onKey(state, "space");
+ assert.equal(state.shells.length, 1);
+ TANK.onKey(state, "space");
+ assert.equal(state.shells.length, 1, "the second press should do nothing");
+});
+
+test("the machine takes a sitting duck, and loses to somebody playing", () => {
+ const hunting = (state, i) => {
+ if (i % 3) return;
+ if (lineOfSight(state.you, state.them)) { TANK.onKey(state, "space"); return; }
+ const next = stepToward(state.you, state.them);
+ if (!next) return;
+ if (state.you.dir !== next.dir) {
+ TANK.onKey(state, quarterTurn(state.you.dir, next.dir) === (state.you.dir + 1) % 4 ? "right" : "left");
+ } else TANK.onKey(state, "up");
+ };
+ assert.match(drive(TANK, TANK.create(), 30000, hunting).over ?? "", /you take it/);
+ assert.match(drive(TANK, TANK.create(), 30000).over ?? "", /machine takes it/);
+});
+
+test("the yard is drawn to size", () => {
+ const rows = TANK.render(drive(TANK, TANK.create(), 100));
+ assert.equal(rows.length, HEIGHT_T);
+ for (const row of rows) assert.equal(visible(row), WIDTH_T);
+});
+
+/* -------------------------------------------------------------- spyhunter */
+
+test("the road always has a verge on both sides, and a width you can drive", () => {
+ let row = openRoad()[0];
+ const rng = seeded(3);
+ const centres = [];
+ for (let i = 0; i < 4000; i++) {
+ row = nextRow(row, rng);
+ assert.ok(row.left >= 1, `the road ran off the left at ${row.left}`);
+ assert.ok(row.right <= SH_WIDTH - 2, `the road ran off the right at ${row.right}`);
+ const width = row.right - row.left;
+ assert.ok(width >= 12 && width <= 25, `a road ${width} wide is not a road`);
+ centres.push((row.left + row.right) / 2);
+ }
+ // Pulled back towards the middle rather than parked against an edge — a plain
+ // random walk fails this every time.
+ const mean = centres.reduce((a, b) => a + b, 0) / centres.length;
+ assert.ok(Math.abs(mean - SH_WIDTH / 2) < 3, `the road lives at ${mean.toFixed(1)}, not the middle`);
+});
+
+test("the verge costs a life, and so does the traffic", () => {
+ const off = SPYHUNTER.create({ rng: seeded(1) });
+ off.car = 0; // hard against the left edge, off the tarmac
+ off.road = off.road.map(() => ({ left: 10, right: 28 }));
+ SPYHUNTER.tick(off);
+ assert.equal(off.lives, 2, "the verge should have cost a life");
+ assert.equal(off.grace > 0, true, "and the road ahead is cleared for a moment");
+
+ const ram = SPYHUNTER.create({ rng: seeded(2) });
+ ram.traffic = [{ kind: "civilian", x: ram.car, y: CAR_ROW, speed: 0.2 }];
+ SPYHUNTER.tick(ram);
+ assert.equal(ram.lives, 2);
+ assert.match(SPYHUNTER.status(ram), /▲▲/);
+});
+
+test("shooting the wrong car costs more than not shooting at all", () => {
+ const shoot = (kind) => {
+ const state = SPYHUNTER.create({ rng: seeded(4) });
+ state.score = 500;
+ state.grace = 999; // this test is about the gun, not the bumper
+ state.traffic = [{ kind, x: state.car, y: CAR_ROW - 4, speed: 0.2 }];
+ // A tick moves the shot up 1.6 rows, so it starts below where they meet.
+ state.shots = [{ x: state.car + 0.5, y: CAR_ROW - 2.5 }];
+ SPYHUNTER.tick(state);
+ assert.equal(state.traffic.length, 0, `the ${kind} should have been hit`);
+ return state.score - 500;
+ };
+ assert.equal(shoot("enemy"), TRAFFIC.enemy.points);
+ assert.equal(shoot("civilian"), TRAFFIC.civilian.points);
+});
+
+test("a wreck does not drop you back onto the car that got you", () => {
+ const state = SPYHUNTER.create({ rng: seeded(5) });
+ state.traffic = [
+ { kind: "enemy", x: state.car, y: CAR_ROW, speed: 0.2 },
+ { kind: "enemy", x: state.car, y: CAR_ROW - 2, speed: 0.2 },
+ ];
+ SPYHUNTER.tick(state);
+ assert.equal(state.traffic.length, 0, "the road is cleared");
+ assert.ok(onRoad(state.road[CAR_ROW], state.car), "and you are put back on the tarmac");
+});
+
+test("the road can be driven, and cannot be driven hands-off", () => {
+ const steering = (state) => {
+ const ahead = state.traffic.filter((c) => c.y > CAR_ROW - 7 && c.y <= CAR_ROW);
+ // Hold the dodge until the car is properly past, rather than steering back
+ // to the middle the moment the bumpers no longer touch — recentring early
+ // just drives you back into it.
+ const blocking = ahead.find((c) => Math.abs(c.x - state.car) <= 3);
+ const row = state.road[CAR_ROW];
+ let want = Math.round((row.left + row.right) / 2) - 1;
+ if (blocking) want = blocking.x > state.car ? state.car - 4 : state.car + 4;
+ want = Math.max(row.left, Math.min(row.right - CAR_W + 1, want));
+ if (state.car < want) SPYHUNTER.onKey(state, "right");
+ else if (state.car > want) SPYHUNTER.onKey(state, "left");
+ const enemy = ahead.find((c) => c.kind === "enemy" && overlaps(c.x, state.car));
+ const civil = ahead.find((c) => c.kind === "civilian" && overlaps(c.x, state.car) && c.y > (enemy?.y ?? -99));
+ if (enemy && !civil) SPYHUNTER.onKey(state, "space");
+ };
+ for (let seed = 1; seed <= 6; seed++) {
+ const driven = drive(SPYHUNTER, SPYHUNTER.create({ rng: seeded(seed) }), 700, steering);
+ assert.equal(driven.over, null, `seed ${seed} could not be driven 700 ticks: ${driven.over}`);
+ assert.ok(driven.miles > 10, `seed ${seed} only covered ${driven.miles} miles`);
+ const drifted = drive(SPYHUNTER, SPYHUNTER.create({ rng: seeded(seed) }), 1500);
+ assert.ok(drifted.over, `seed ${seed} survived with nobody steering`);
+ }
+});
+
+test("the road is drawn to size", () => {
+ const state = drive(SPYHUNTER, SPYHUNTER.create({ rng: seeded(8) }), 200);
+ const rows = SPYHUNTER.render(state);
+ assert.equal(rows.length, SH_HEIGHT);
+ for (const row of rows) assert.equal(visible(row), SH_WIDTH);
+});
+
+/* ---------------------------------------------------------------- pitfall */
+
+test("a jump clears a log and a pit needs the vine", () => {
+ const jumped = PITFALL.create({ rng: seeded(1) });
+ jumped.next = 1e9;
+ jumped.things = [{ kind: "log", x: PF_RUNNER + 2 }];
+ PITFALL.onKey(jumped, "space");
+ const overLog = drive(PITFALL, jumped, 12);
+ assert.equal(overLog.over, null, "a jump should cover a two-wide log");
+
+ // A pit is five wide, which is more than a jump covers. That is on purpose.
+ const short = PITFALL.create({ rng: seeded(2) });
+ short.next = 1e9;
+ short.things = [{ kind: "pit", x: PF_RUNNER + 2 }];
+ PITFALL.onKey(short, "space");
+ const fell = drive(PITFALL, short, 20);
+ assert.equal(fell.lives, 2, "jumping a pit is how you find out how deep it is");
+});
+
+test("a vine is only there to be caught, and only from the ground", () => {
+ const state = PITFALL.create({ rng: seeded(3) });
+ state.next = 1e9;
+ state.things = [{ kind: "vine", x: PF_RUNNER }];
+ PITFALL.tick(state);
+ assert.equal(state.lives, 3, "walking under one costs nothing");
+
+ grab(state);
+ assert.equal(state.swinging, true);
+ assert.ok(state.air > 0);
+
+ const airborne = PITFALL.create({ rng: seeded(4) });
+ airborne.next = 1e9;
+ airborne.things = [{ kind: "vine", x: PF_RUNNER }];
+ PITFALL.onKey(airborne, "space");
+ grab(airborne);
+ assert.equal(airborne.swinging, false, "you cannot catch one mid-jump");
+});
+
+test("a swing carries you over a whole pit", () => {
+ const state = PITFALL.create({ rng: seeded(5) });
+ state.next = 1e9;
+ // The spawner always hangs the vine three columns ahead of the pit it covers.
+ state.things = [{ kind: "vine", x: PF_RUNNER }, { kind: "pit", x: PF_RUNNER + 3 }];
+ grab(state);
+ const crossed = drive(PITFALL, state, 30);
+ assert.equal(crossed.lives, 3, "the swing should have carried you the whole way");
+});
+
+test("every pit the jungle throws has a vine over it", () => {
+ const state = PITFALL.create({ rng: seeded(6) });
+ for (let i = 0; i < 400; i++) spawnPitfall(state);
+ const pits = state.things.filter((t) => t.kind === "pit");
+ const vines = state.things.filter((t) => t.kind === "vine");
+ assert.ok(pits.length > 0, "the spawner never made a pit");
+ assert.equal(vines.length, pits.length, "a pit with no vine is a pit nobody gets past");
+});
+
+test("gold is picked up on foot, and a fall costs you daylight", () => {
+ const state = PITFALL.create({ rng: seeded(7) });
+ state.next = 1e9;
+ // A tick scrolls the jungle first, so put it one column further out.
+ state.things = [{ kind: "treasure", x: PF_RUNNER + 1 }];
+ PITFALL.tick(state);
+ assert.equal(state.treasure, 1);
+ assert.ok(state.score >= 500);
+
+ const fell = PITFALL.create({ rng: seeded(8) });
+ fell.next = 1e9;
+ fell.clock = 500;
+ fell.things = [{ kind: "scorpion", x: PF_RUNNER + 1 }];
+ PITFALL.tick(fell);
+ assert.equal(fell.lives, 2);
+ assert.ok(fell.clock < 500, "and it puts the sun down faster");
+});
+
+test("the jungle can be run, and cannot be run standing up", () => {
+ const running = (state) => {
+ const vine = state.things.find((t) => t.kind === "vine" && Math.abs(Math.round(t.x) - PF_RUNNER) <= 1);
+ const soon = state.things.find((t) => {
+ if (t.kind === "vine" || t.kind === "treasure" || t.kind === "pit") return false;
+ const lead = pfSpan(t)[0] - PF_RUNNER;
+ return lead >= 1 && lead <= 3;
+ });
+ if (vine) PITFALL.onKey(state, "up");
+ else if (soon) PITFALL.onKey(state, "space");
+ };
+ for (let seed = 1; seed <= 6; seed++) {
+ const state = drive(PITFALL, PITFALL.create({ rng: seeded(seed) }), 3000, running);
+ // Somebody who jumps and swings at the right moments is only ever beaten by
+ // the clock, never by the jungle.
+ assert.match(state.over ?? "", /out of daylight/, `seed ${seed} ended: ${state.over}`);
+ assert.ok(state.treasure > 3, `seed ${seed} only found ${state.treasure} gold`);
+ }
+ for (let seed = 1; seed <= 6; seed++) {
+ const idle = drive(PITFALL, PITFALL.create({ rng: seeded(seed) }), 800);
+ assert.ok(idle.over, `seed ${seed} walked the jungle without jumping once`);
+ }
+});
+
+test("the pitfall board is drawn to size", () => {
+ const state = drive(PITFALL, PITFALL.create({ rng: seeded(9) }), 150);
+ const rows = PITFALL.render(state);
+ assert.equal(rows.length, PF_HEIGHT);
+ for (const row of rows) assert.equal(visible(row), PF_WIDTH);
+});
+
+/* ------------------------------------------------------------- choplifter */
+
+test("the world is wider than the window, and the camera follows you", () => {
+ const state = CHOPLIFTER.create({ rng: seeded(1) });
+ assert.ok(WORLD > CH_WIDTH * 2, "a rescue you can see all of is not a rescue");
+ const home = strip(CHOPLIFTER.render(state).join("\n"));
+ state.chopper.x = WORLD - 10;
+ const away = strip(CHOPLIFTER.render(state).join("\n"));
+ assert.notEqual(home, away, "flying to the far end should show a different place");
+});
+
+test("landing in the desert fills the back, four at a time", () => {
+ const state = CHOPLIFTER.create({ rng: seeded(2) });
+ state.tanks = [];
+ state.people = Array.from({ length: 6 }, (_, i) => ({ x: 60 + i * 0.4 }));
+ state.chopper = { x: 60, y: GROUND_CH, vy: 0 };
+ CHOPLIFTER.tick(state);
+ assert.equal(state.aboard, SEATS, "it holds four and no more");
+ assert.equal(state.people.length, 2, "and leaves the rest waving");
+});
+
+test("the pad is the only place they get out", () => {
+ const desert = CHOPLIFTER.create({ rng: seeded(3) });
+ desert.tanks = [];
+ desert.people = [];
+ desert.aboard = 3;
+ desert.chopper = { x: 80, y: GROUND_CH, vy: 0 };
+ CHOPLIFTER.tick(desert);
+ assert.equal(desert.home, 0, "putting them down in the desert is not a rescue");
+
+ const pad = CHOPLIFTER.create({ rng: seeded(3) });
+ pad.tanks = [];
+ pad.aboard = 3;
+ pad.chopper = { x: BASE + 2, y: GROUND_CH, vy: 0 };
+ CHOPLIFTER.tick(pad);
+ assert.equal(pad.home, 3);
+ assert.equal(pad.aboard, 0);
+ assert.ok(onPad(BASE + 2) && !onPad(80));
+});
+
+test("a hit costs everybody in the back", () => {
+ const state = CHOPLIFTER.create({ rng: seeded(4) });
+ state.tanks = [];
+ state.aboard = 4;
+ state.chopper = { x: 60, y: 5, vy: 0 };
+ state.shells = [{ x: 60, y: 5, vy: -0.55 }];
+ CHOPLIFTER.tick(state);
+ assert.equal(state.lives, 2);
+ assert.equal(state.aboard, 0, "they were in the back");
+ assert.equal(state.home, 0);
+});
+
+test("a tank only shoots at what is overhead", () => {
+ const far = CHOPLIFTER.create({ rng: seeded(5) });
+ far.people = [];
+ far.tanks = [{ x: 100, dir: 1 }];
+ far.chopper = { x: 10, y: 4, vy: 0 };
+ drive(CHOPLIFTER, far, 200);
+ assert.equal(far.shells.length, 0, "it should not shell the far end of the map");
+
+ const over = CHOPLIFTER.create({ rng: seeded(6) });
+ over.people = [{ x: 100 }];
+ over.tanks = [{ x: 100, dir: 1 }];
+ over.chopper = { x: 100, y: 3, vy: 0 };
+ let seen = 0;
+ drive(CHOPLIFTER, over, 200, (s) => { seen = Math.max(seen, s.shells.length); s.chopper.y = 3; });
+ assert.ok(seen > 0, "and it should very much shell what is above it");
+});
+
+test("everyone out is the end of it", () => {
+ const state = CHOPLIFTER.create({ rng: seeded(7) });
+ state.tanks = [];
+ state.people = [];
+ state.aboard = 0;
+ CHOPLIFTER.tick(state);
+ assert.match(state.over, /everyone out/);
+});
+
+test("a pilot can fly the rescue, and a parked one rescues nobody", () => {
+ const flying = (state) => {
+ const chop = state.chopper;
+ const full = state.aboard >= SEATS || (!state.people.length && state.aboard);
+ const target = full ? BASE + 2 : (state.people[0]?.x ?? BASE + 2);
+ const dx = target - chop.x;
+ if (Math.abs(dx) > 1.5) {
+ CHOPLIFTER.onKey(state, dx > 0 ? "right" : "left");
+ if (chop.y > GROUND_CH - 4) CHOPLIFTER.onKey(state, "up");
+ } else if (chop.y < GROUND_CH) CHOPLIFTER.onKey(state, "down");
+ };
+ let rescued = 0;
+ for (let seed = 1; seed <= 6; seed++) {
+ rescued += drive(CHOPLIFTER, CHOPLIFTER.create({ rng: seeded(seed) }), 6000, flying).home;
+ }
+ assert.ok(rescued >= 20, `only ${rescued} people came home across six runs`);
+ for (let seed = 1; seed <= 6; seed++) {
+ const parked = drive(CHOPLIFTER, CHOPLIFTER.create({ rng: seeded(seed) }), 2000);
+ assert.equal(parked.home, 0, "nobody walks home on their own");
+ }
+});
+
+test("the choplifter board is drawn to size", () => {
+ const state = drive(CHOPLIFTER, CHOPLIFTER.create({ rng: seeded(8) }), 200);
+ const rows = CHOPLIFTER.render(state);
+ assert.equal(rows.length, CH_HEIGHT);
+ for (const row of rows) assert.equal(visible(row), CH_WIDTH);
+});
+
+/* ------------------------------------------------------------- excitebike */
+
+test("turbo is a loan, and the gauge is where it is called in", () => {
+ const state = EXCITEBIKE.create({ rng: seeded(1) });
+ EXCITEBIKE.onKey(state, "space");
+ assert.equal(state.turbo, true);
+ // Long enough to cook it, short enough to still be sitting in the seizure —
+ // it clears itself once it has cost you the time.
+ const hot = drive(EXCITEBIKE, state, 80);
+ assert.ok(hot.seized > 0, "holding it should cook the engine");
+ assert.equal(hot.turbo, false, "and take it away from you");
+ assert.ok(speedOf(hot) < 0.3, "a seized engine barely moves");
+
+ const cooling = EXCITEBIKE.create({ rng: seeded(2) });
+ const cool = drive(EXCITEBIKE, cooling, 60);
+ assert.equal(cool.heat, 0, "off the turbo it cools back down");
+});
+
+test("a ramp puts you in the air, and the nose drops all the way down", () => {
+ const state = EXCITEBIKE.create({ rng: seeded(3) });
+ state.next = 1e9;
+ state.things = [{ kind: "ramp", x: RIDER }];
+ EXCITEBIKE.tick(state);
+ assert.ok(state.air > 0, "you should be off the ground");
+ const launch = state.pitch;
+ EXCITEBIKE.tick(state);
+ assert.ok(state.pitch > launch, "and the front wheel drops on its own");
+});
+
+test("a landing is level or it is a tumble", () => {
+ const flown = (correct) => {
+ const state = EXCITEBIKE.create({ rng: seeded(4) });
+ state.next = 1e9;
+ state.things = [{ kind: "ramp", x: RIDER }];
+ return drive(EXCITEBIKE, state, 60, (s) => {
+ if (correct && s.air && s.pitch > 0.2) EXCITEBIKE.onKey(s, "up");
+ });
+ };
+ assert.equal(flown(true).spills, 0, "held level, it lands");
+ assert.equal(flown(false).spills, 1, "left alone, it lands on its face");
+ assert.ok(Math.abs(LAND_OK) > 0);
+});
+
+test("pitching on the ground does nothing at all", () => {
+ const state = EXCITEBIKE.create({ rng: seeded(5) });
+ EXCITEBIKE.onKey(state, "up");
+ EXCITEBIKE.onKey(state, "down");
+ assert.equal(state.pitch, 0, "there is nothing to pitch against");
+});
+
+test("the race can be won by riding it well, and not by holding turbo down", () => {
+ const good = (state, i) => {
+ if (state.air && state.pitch > 0.2) EXCITEBIKE.onKey(state, "up");
+ else if (state.air && state.pitch < -0.2) EXCITEBIKE.onKey(state, "down");
+ if (state.heat > 70 && state.turbo) EXCITEBIKE.onKey(state, "space");
+ if (state.heat < 25 && !state.turbo) EXCITEBIKE.onKey(state, "space");
+ if (i % 5 === 0) EXCITEBIKE.onKey(state, "right");
+ };
+ const greedy = (state, i) => {
+ if (!state.turbo) EXCITEBIKE.onKey(state, "space");
+ if (i % 5 === 0) EXCITEBIKE.onKey(state, "right");
+ };
+ let ridden = 0;
+ let mashed = 0;
+ for (let seed = 1; seed <= 5; seed++) {
+ const clean = drive(EXCITEBIKE, EXCITEBIKE.create({ rng: seeded(seed) }), 3000, good);
+ ridden += clean.race - 1;
+ assert.equal(clean.spills, 0, `seed ${seed} spilled while being ridden properly`);
+ mashed += drive(EXCITEBIKE, EXCITEBIKE.create({ rng: seeded(seed) }), 3000, greedy).race - 1;
+ }
+ assert.ok(ridden > mashed, `riding it (${ridden}) should beat mashing it (${mashed})`);
+ assert.ok(ridden >= 10, `only ${ridden} races finished in five runs`);
+});
+
+test("the excitebike board is drawn to size", () => {
+ const state = drive(EXCITEBIKE, EXCITEBIKE.create({ rng: seeded(6) }), 200);
+ const rows = EXCITEBIKE.render(state);
+ assert.equal(rows.length, EB_HEIGHT);
+ for (const row of rows) assert.equal(visible(row), EB_WIDTH);
+});
+
+/* ----------------------------------------------------------------- outrun */
+
+test("the road is narrow far away and wide under the bumper", () => {
+ assert.ok(roadHalf(0) < roadHalf(PLAYER_ROW), "perspective runs the wrong way");
+ for (let row = 1; row < OR_HEIGHT; row++) {
+ assert.ok(roadHalf(row) > roadHalf(row - 1), `row ${row} is not wider than the one above it`);
+ }
+});
+
+test("a corner bends the far end and leaves your bumper where it is", () => {
+ const straight = centreAt(PLAYER_ROW, 0);
+ assert.equal(centreAt(PLAYER_ROW, 1), straight, "the bottom row never moves");
+ assert.equal(centreAt(PLAYER_ROW, -1), straight);
+ assert.ok(centreAt(0, 1) > centreAt(0, 0), "a right-hander throws the horizon right");
+ assert.ok(centreAt(0, -1) < centreAt(0, 0), "and a left-hander throws it left");
+ // And it opens up gradually rather than kinking.
+ const offsets = Array.from({ length: OR_HEIGHT }, (_, y) => centreAt(y, 1) - centreAt(y, 0));
+ for (let y = 1; y < offsets.length; y++) assert.ok(offsets[y] <= offsets[y - 1] + 1e-9);
+});
+
+test("the grass is slow, and the tarmac is not", () => {
+ const state = OUTRUN.create({ rng: seeded(1) });
+ state.speed = 1.8;
+ state.car = 1; // hard onto the verge
+ assert.equal(onTarmac(state.car, state.curve), false);
+ OUTRUN.tick(state);
+ assert.ok(state.speed <= 0.6, "two wheels on the grass should cost you everything");
+
+ const tarmac = OUTRUN.create({ rng: seeded(1) });
+ tarmac.speed = 1.8;
+ OUTRUN.tick(tarmac);
+ assert.ok(tarmac.speed > 1.7, "and staying on it should cost you nothing");
+});
+
+test("a corner throws you at the outside of it", () => {
+ const state = OUTRUN.create({ rng: seeded(2) });
+ state.speed = 1.5;
+ state.curve = 0.8;
+ state.segment = { left: 1e9, curve: 0.8 };
+ const before = state.car;
+ OUTRUN.tick(state);
+ assert.ok(state.car > before, "a right-hander should push you left-to-right across the road");
+});
+
+test("traffic spins you, and a checkpoint buys the clock back", () => {
+ const state = OUTRUN.create({ rng: seeded(3) });
+ state.speed = 1.6;
+ state.traffic = [{ z: 0.02, lane: 0, speed: 0.4 }];
+ state.car = centreAt(PLAYER_ROW, state.curve);
+ OUTRUN.tick(state);
+ assert.equal(state.spins, 1);
+ assert.equal(state.speed, 0, "a spin costs you all of it");
+
+ const check = OUTRUN.create({ rng: seeded(4) });
+ check.dist = check.nextCheck - 0.5;
+ check.speed = 1;
+ const clock = check.clock;
+ OUTRUN.tick(check);
+ assert.equal(check.checks, 1);
+ assert.ok(check.clock > clock, "and a checkpoint hands some of the clock back");
+});
+
+test("the stage can be driven, and the clock takes anybody who doesn't", () => {
+ const driving = (state) => {
+ OUTRUN.onKey(state, "up");
+ const want = centreAt(PLAYER_ROW, state.curve);
+ if (state.car < want - 0.6) OUTRUN.onKey(state, "right");
+ else if (state.car > want + 0.6) OUTRUN.onKey(state, "left");
+ };
+ for (let seed = 1; seed <= 5; seed++) {
+ const driven = drive(OUTRUN, OUTRUN.create({ rng: seeded(seed) }), 3000, driving);
+ assert.equal(driven.over, null, `seed ${seed} ran out of road: ${driven.over}`);
+ assert.ok(driven.checks >= 8, `seed ${seed} only made ${driven.checks} checkpoints`);
+ const parked = drive(OUTRUN, OUTRUN.create({ rng: seeded(seed) }), 2000);
+ assert.match(parked.over ?? "", /time up/, "sitting still should run the clock out");
+ }
+});
+
+test("the outrun road is drawn to size", () => {
+ const state = drive(OUTRUN, OUTRUN.create({ rng: seeded(5) }), 200, (s) => OUTRUN.onKey(s, "up"));
+ const rows = OUTRUN.render(state);
+ assert.equal(rows.length, OR_HEIGHT);
+ for (const row of rows) assert.equal(visible(row), OR_WIDTH);
+});
+
+/* -------------------------------------------------------------- stagedive */
+
+/** A stage holding exactly the things a test puts on it, and nothing else. */
+const stage = (things = []) => ({
+ ...STAGEDIVE.create({ rng: seeded(1) }),
+ things,
+ next: Number.MAX_SAFE_INTEGER, // no spawner — this test is about one obstacle
+});
+
+/** Run the stage, calling `act(state, tick)` before each tick. */
+function run(state, ticks, act = () => {}) {
+ for (let i = 0; i < ticks && !state.over; i++) {
+ act(state, i);
+ state = STAGEDIVE.tick(state);
+ }
+ return state;
+}
+
+/** How far in front of the runner something is, in columns. */
+const lead = (thing) => (thing ? cells(thing).cols[0] - RUNNER : Infinity);
+
+/** A player with reflexes: jump the gear, duck the crowd. */
+const reflexes = (state) => {
+ for (const thing of state.things) {
+ if (thing.kind === "pick") continue;
+ const gap = lead(thing);
+ if (thing.kind === "surfer") { if (gap >= 1 && gap <= 5) STAGEDIVE.onKey(state, "down"); }
+ else if (gap >= 4 && gap <= 8) STAGEDIVE.onKey(state, "up");
+ }
+};
+
+test("every hazard hits exactly as wide as it is drawn", () => {
+ for (const [kind, hazard] of Object.entries(HAZARDS)) {
+ const { cols } = cells({ kind, x: 20 });
+ assert.equal(cols[1] - cols[0] + 1, [...hazard.art].length, `${kind} lies about its width`);
+ assert.ok(hazard.rows.every((r) => r <= GROUND), `${kind} floats above the stage`);
+ assert.ok(hazard.death.length, `${kind} kills you without saying so`);
+ }
+});
+
+test("the runner never moves — the stage does", () => {
+ const state = run(stage([{ kind: "wedge", x: 40 }]), 12);
+ assert.equal(state.things[0].x < 40, true, "the wedge should have come closer");
+ assert.ok(state.dist > 0, "and the distance run should have gone up");
+ const drawn = STAGEDIVE.render(state).map(strip);
+ assert.ok(drawn.some((row) => row[RUNNER] && row[RUNNER] !== " "), "the runner is always in its column");
+});
+
+test("a wedge you do not jump is a wedge you trip over", () => {
+ const tripped = run(stage([{ kind: "wedge", x: RUNNER + 8 }]), 20);
+ assert.match(tripped.over, /monitor wedge/);
+
+ const cleared = run(stage([{ kind: "wedge", x: RUNNER + 8 }]), 20, (s) => {
+ if (lead(s.things[0]) === 6) STAGEDIVE.onKey(s, "up");
+ });
+ assert.equal(cleared.over, null, "a jump at six columns should clear it");
+});
+
+test("an amp stack takes the height of the jump, not the start of it", () => {
+ // Jumped in time: the runner is two rows up before the stack arrives.
+ const cleared = run(stage([{ kind: "stack", x: RUNNER + 20 }]), 40, (s) => {
+ if (lead(s.things[0]) === 6) STAGEDIVE.onKey(s, "up");
+ });
+ assert.equal(cleared.over, null);
+
+ // Jumped one column late is still a jump, and still a wreck.
+ const late = run(stage([{ kind: "stack", x: RUNNER + 20 }]), 40, (s) => {
+ if (lead(s.things[0]) === 0) STAGEDIVE.onKey(s, "up");
+ });
+ assert.match(late.over, /amp stack/);
+});
+
+test("a crowdsurfer is ducked, not jumped into", () => {
+ const worn = run(stage([{ kind: "surfer", x: RUNNER + 8 }]), 20);
+ assert.match(worn.over, /crowdsurfer/);
+
+ const ducked = run(stage([{ kind: "surfer", x: RUNNER + 8 }]), 20, (s) => {
+ if (lead(s.things[0]) === 3) STAGEDIVE.onKey(s, "down");
+ });
+ assert.equal(ducked.over, null, "crouching should pass under it");
+ // Crouched is one row; standing is two, and the second row is the one that
+ // wears a crowdsurfer.
+ assert.deepEqual(runnerRows({ y: GROUND, duck: 4, airborne: false }), [GROUND]);
+ assert.deepEqual(runnerRows({ y: GROUND, duck: 0, airborne: false }), [GROUND - 1, GROUND]);
+});
+
+test("there is no second jump, and ↓ in the air is a slam", () => {
+ const state = stage();
+ STAGEDIVE.onKey(state, "up");
+ const climbing = state.vy;
+ STAGEDIVE.onKey(state, "up");
+ assert.equal(state.vy, climbing, "a second jump would be a different game");
+ STAGEDIVE.onKey(state, "down");
+ assert.ok(state.vy > 0, "↓ in the air should send you down");
+ assert.equal(state.duck, 0, "and it is not a crouch until you land");
+});
+
+test("picks are collected rather than crashed into", () => {
+ const state = run(stage([{ kind: "pick", x: RUNNER + 6, row: GROUND }]), 20);
+ assert.equal(state.picks, 1);
+ assert.equal(state.over, null, "a pick is not a hazard");
+ assert.equal(state.things.length, 0, "and it is off the stage once taken");
+ assert.match(STAGEDIVE.status(state), /1 picks/);
+});
+
+test("the stage speeds up, and the gaps grow with it", () => {
+ const state = STAGEDIVE.create({ rng: seeded(2) });
+ const opening = state.speed;
+ const far = run(state, 3000, reflexes);
+ assert.ok(far.speed > opening, "the stage should get faster");
+
+ // The gap is set in columns but a jump is fixed in ticks, so the gap has to
+ // scale with speed or the game stops being playable at the far end.
+ const slow = { ...STAGEDIVE.create({ rng: seeded(3) }), things: [] };
+ const fast = { ...STAGEDIVE.create({ rng: seeded(3) }), things: [], speed: 1.75 };
+ spawn(slow);
+ spawn(fast);
+ assert.ok(fast.next > slow.next * 1.9, "a faster stage should leave more room");
+});
+
+test("a player with reflexes can run the whole set", () => {
+ for (let seed = 1; seed <= 25; seed++) {
+ const state = run(STAGEDIVE.create({ rng: seeded(seed) }), 3000, reflexes);
+ assert.equal(state.over, null, `seed ${seed} died at ${meters(state)} m: ${state.over}`);
+ assert.ok(state.picks > 10, `seed ${seed} only found ${state.picks} picks in 3000 ticks`);
+ }
+});
+
+test("a player with none cannot", () => {
+ for (let seed = 1; seed <= 15; seed++) {
+ // Standing still, and holding the jump key down — the two ways nobody
+ // should be able to play a runner.
+ const idle = run(STAGEDIVE.create({ rng: seeded(seed) }), 600);
+ assert.ok(idle.over, `seed ${seed} survived doing nothing`);
+ const masher = run(STAGEDIVE.create({ rng: seeded(seed) }), 1500, (s) => STAGEDIVE.onKey(s, "up"));
+ assert.ok(masher.over, `seed ${seed} survived on jump alone`);
+ }
+});
+
+test("the stagedive board is drawn to size, with the stage under it", () => {
+ const state = run(STAGEDIVE.create({ rng: seeded(9) }), 200, reflexes);
+ const rows = STAGEDIVE.render(state);
+ assert.equal(rows.length, D_HEIGHT);
+ for (const row of rows) assert.equal(visible(row), D_WIDTH, "a ragged row would tear the frame");
+ assert.match(strip(rows[GROUND + 1]), /^[═╪]+$/, "the stage edge runs the whole width");
+});
+
/* -------------------------------------------------------------- tictactoe */
test("three in a row is spotted in every direction", () => {