From 273691d39fb5c37f3bae4064bfd11dcf2125fe03 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Tue, 11 Aug 2026 12:17:25 +0000 Subject: [PATCH 1/6] =?UTF-8?q?feat(games):=20stagedive=20=E2=80=94=20the?= =?UTF-8?q?=20arcade=20gets=20a=20side-scroller?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `/games stagedive` (also `dive`, `runner`, `stage`). You are running the barricade: hop the monitor wedges, clear 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. That 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. Without that the stage eventually outruns the jump and the game stops being losable-by-mistake and starts being unfair. Three things to read and two ways to answer. Wedges and stacks are jumped, and the stack is two rows tall so it takes the height of the jump rather than the start of it. A crowdsurfer sits at head height: crouch and it goes over you, because a crouched runner is one row and a standing one is two. `↓` in mid-air is a slam instead, which is how you save a jump you took too early. Every hazard's hitbox is its own art, so a hazard cannot hit wider than it is drawn — the oldest bug in the genre. The tests play it: a jump at six columns clears a wedge and a jump one column late still wrecks on a stack, and a player with reflexes runs 25 seeded stages to the end while standing still and holding jump both die on every one of them. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 5 +- src/cli-schema.mjs | 5 +- src/games-stagedive.mjs | 206 ++++++++++++++++++++++++++++++++++++++++ src/games.mjs | 7 +- test/games.test.mjs | 151 +++++++++++++++++++++++++++++ 5 files changed, 367 insertions(+), 7 deletions(-) create mode 100644 src/games-stagedive.mjs diff --git a/README.md b/README.md index f078a9a..531d1f2 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 — nine 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 +Nine 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 @@ -612,6 +612,7 @@ moshcode games --json # the roster, for a machine | `snake` | eat, grow, and try not to eat yourself | | `pacman` | eat the dots, dodge the ghosts, `✳` makes them edible | | `asteroids` | turn, thrust, shoot — every rock you break becomes two | +| `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..5a807f0 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 — nine 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,7 @@ 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 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 +933,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, snake, pac-man, asteroids, stagedive, blackjack 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-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.mjs b/src/games.mjs index 551d71d..1a00128 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 +// Nine 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 nine 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,7 @@ 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"; /** * @typedef {object} Game — the whole contract, so a seventh game is an import. @@ -39,7 +40,7 @@ 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, ASTEROIDS, 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..1d32688 100644 --- a/test/games.test.mjs +++ b/test/games.test.mjs @@ -19,6 +19,10 @@ 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 { TICTACTOE, bestMove, emptyBoard as emptyGrid, winner } from "../src/games-tictactoe.mjs"; import { BLACKJACK, MIN_BET, canSplit, freshDeck, handValue, isBlackjack, settle, @@ -475,6 +479,153 @@ test("the asteroids board is drawn to size", () => { assert.ok(byRow.size >= 8, "a sky with no stars in it"); }); +/* -------------------------------------------------------------- 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", () => { From a8db9134e68c47087ae8dac2eaed866776513c81 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Tue, 11 Aug 2026 12:39:33 +0000 Subject: [PATCH 2/6] feat(games): invaders, breakout, pong, tank and spy hunter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five more for the cabinet, on the same frame, decoder and driver as the rest. Space Invaders is the one with the joke the original hardware told by accident: cadence() divides by the number left alive, so forty of them crawl and the last one sprints. Bunkers take two hits a cell from either side — including yours, which is the whole reason stepping out from under your own cover is a skill. Breakout's paddle is a steering wheel rather than a wall: where the ball lands on it decides the angle it leaves at, which is what lets you dig a channel up the side. Pong's machine waits until the ball crosses the halfway line and then moves too slowly to reach a corner from the middle, so a flat return it always gets and a sharp one it never does. Tank searches the yard rather than facing you and driving: breadth-first over 546 cells, every decision. "Turn towards the enemy" grinds into the same block for ever, and the first version of this duel finished 0–0 after twenty thousand ticks with neither tank ever having found the other. Spy Hunter's road is generated a row at a time from the row before it, with the bend pulled back towards the middle in proportion to how far out it already is. A plain random walk parks the road against one edge and leaves it there, which reads as a bug rather than a road. Three bugs worth naming, all found by playing them without a terminal: - The invader ranks were two rows apart, which made the fleet nine rows tall on a board with eleven above the bunkers. It "landed" after two drops and no wave was survivable by anybody. One row per rank, and an aiming player now always dies to the bombs instead — which is the test. - Shots that move faster than their target is tall skipped straight through it. Both the cannon and the car now travel in sub-steps and check what they pass. - The tank yard's size was declared next to the map it described, and disagreed with it. It is derived from the map now. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 9 +- src/cli-schema.mjs | 5 +- src/games-breakout.mjs | 186 ++++++++++++++ src/games-invaders.mjs | 258 ++++++++++++++++++++ src/games-pong.mjs | 147 +++++++++++ src/games-spyhunter.mjs | 230 +++++++++++++++++ src/games-tank.mjs | 230 +++++++++++++++++ src/games.mjs | 14 +- test/games.test.mjs | 528 ++++++++++++++++++++++++++++++++++++++++ 9 files changed, 1600 insertions(+), 7 deletions(-) create mode 100644 src/games-breakout.mjs create mode 100644 src/games-invaders.mjs create mode 100644 src/games-pong.mjs create mode 100644 src/games-spyhunter.mjs create mode 100644 src/games-tank.mjs diff --git a/README.md b/README.md index 531d1f2..db05fa4 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 — nine games, no menus | +| `moshcode games`
`game` `arcade` | arcade | the moshcode arcade — fourteen 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`) -Nine games, in the pit or straight from a shell. There are no menus, no options +Fourteen 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,12 @@ 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 | | `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 | +| `spyhunter` | keep it on the tarmac, shoot the ones shooting back | | `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 | diff --git a/src/cli-schema.mjs b/src/cli-schema.mjs index 5a807f0..f3d4062 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 — nine games, no menus", + description: "the moshcode arcade — fourteen 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,7 @@ 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"], @@ -933,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, stagedive, blackjack and more" }, + description: "the arcade — tetris, invaders, pac-man, breakout, pong, tank, 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-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-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-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 1a00128..1825778 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. // -// Nine games, one frame. Every game here is the same shape (see GAME_SHAPE +// Fourteen 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 nine weekend projects: a title, a status line, a boxed board, and +// rather than fourteen 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. @@ -21,6 +21,11 @@ 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"; /** * @typedef {object} Game — the whole contract, so a seventh game is an import. @@ -40,7 +45,10 @@ import { STAGEDIVE } from "./games-stagedive.mjs"; */ /** The cabinet. Order is the order `/games` lists them. */ -export const GAMES = [TETRIS, SNAKE, PACMAN, ASTEROIDS, STAGEDIVE, TICTACTOE, BLACKJACK, CHESS, HANGMAN]; +export const GAMES = [ + TETRIS, SNAKE, PACMAN, INVADERS, ASTEROIDS, BREAKOUT, PONG, TANK, SPYHUNTER, 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 1d32688..5542f9d 100644 --- a/test/games.test.mjs +++ b/test/games.test.mjs @@ -23,6 +23,25 @@ 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 { TICTACTOE, bestMove, emptyBoard as emptyGrid, winner } from "../src/games-tictactoe.mjs"; import { BLACKJACK, MIN_BET, canSplit, freshDeck, handValue, isBlackjack, settle, @@ -479,6 +498,515 @@ 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); +}); + +/* --------------------------------------------------------------- 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); +}); + /* -------------------------------------------------------------- stagedive */ /** A stage holding exactly the things a test puts on it, and nothing else. */ From 040ee7d03c2129f1922531fb5b338d663ea7b86d Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Tue, 11 Aug 2026 12:45:23 +0000 Subject: [PATCH 3/6] feat(games): centipede and frogger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Centipede's body is not a linked list, it is a bag of segments that each obey the same rule: walk sideways, and when something is in the way drop a row and turn round. Modelled that way a shot to the middle needs no surgery — you delete one segment and the ones behind it carry on, which is exactly what splitting looks like. Every piece you shoot leaves a mushroom where it fell, so the field thickens as you play and the next wave is harder for free. The spider is in there because without it the bottom of the board is safe: the centipede only reaches your row on the sweeps it happens to end on, and a player who never moves at all survived three thousand ticks. It comes in from the side, bounces diagonally through your strip eating mushrooms, and is worth more than anything else on the board. Standing still now loses on every seed, which is the test. Frogger is the inversion: on the road, being on something is death; on the river, being on nothing is. The log carries you, including off the end of the world, which is the lesson every player learns twice. Landing on the bank between the homes is a loss and so is a home you have already filled. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 6 +- src/cli-schema.mjs | 2 +- src/games-centipede.mjs | 241 +++++++++++++++++++++++++++++++++++++++ src/games-frogger.mjs | 194 ++++++++++++++++++++++++++++++++ src/games.mjs | 9 +- test/games.test.mjs | 243 ++++++++++++++++++++++++++++++++++++++++ 6 files changed, 689 insertions(+), 6 deletions(-) create mode 100644 src/games-centipede.mjs create mode 100644 src/games-frogger.mjs diff --git a/README.md b/README.md index db05fa4..d43d95a 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 — fourteen games, no menus | +| `moshcode games`
`game` `arcade` | arcade | the moshcode arcade — sixteen 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`) -Fourteen games, in the pit or straight from a shell. There are no menus, no options +Sixteen 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 @@ -612,10 +612,12 @@ moshcode games --json # the roster, for a machine | `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 | +| `frogger` | the road kills what it touches, the river kills what it doesn't | | `spyhunter` | keep it on the tarmac, shoot the ones shooting back | | `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 | diff --git a/src/cli-schema.mjs b/src/cli-schema.mjs index f3d4062..6520d41 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 — fourteen games, no menus", + description: "the moshcode arcade — sixteen games, no menus", synopsis: [ ["moshcode games", "the cabinet, and what each one is"], ["moshcode games ", "play it, right here in the terminal"], 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-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.mjs b/src/games.mjs index 1825778..5e19a2f 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. // -// Fourteen games, one frame. Every game here is the same shape (see GAME_SHAPE +// Sixteen 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 fourteen weekend projects: a title, a status line, a boxed board, and +// rather than sixteen 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. @@ -26,6 +26,8 @@ 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"; /** * @typedef {object} Game — the whole contract, so a seventh game is an import. @@ -46,7 +48,8 @@ import { SPYHUNTER } from "./games-spyhunter.mjs"; /** The cabinet. Order is the order `/games` lists them. */ export const GAMES = [ - TETRIS, SNAKE, PACMAN, INVADERS, ASTEROIDS, BREAKOUT, PONG, TANK, SPYHUNTER, STAGEDIVE, + TETRIS, SNAKE, PACMAN, INVADERS, CENTIPEDE, ASTEROIDS, BREAKOUT, PONG, TANK, FROGGER, + SPYHUNTER, STAGEDIVE, TICTACTOE, BLACKJACK, CHESS, HANGMAN, ]; diff --git a/test/games.test.mjs b/test/games.test.mjs index 5542f9d..f55b7bc 100644 --- a/test/games.test.mjs +++ b/test/games.test.mjs @@ -42,6 +42,13 @@ 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 { TICTACTOE, bestMove, emptyBoard as emptyGrid, winner } from "../src/games-tictactoe.mjs"; import { BLACKJACK, MIN_BET, canSplit, freshDeck, handValue, isBlackjack, settle, @@ -654,6 +661,242 @@ test("the invaders board is drawn to size", () => { 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`); + } +}); + +/* ---------------------------------------------------------------- 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", () => { From 3e285d533417fc1ec21343f088ee76884a5b989e Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Tue, 11 Aug 2026 12:53:39 +0000 Subject: [PATCH 4/6] feat(games): dig dug and kong MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dig Dug has no pathfinding, and that is the design: 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 one straight line and they queue up behind you. Once in a while one gives up on the tunnels and comes straight through the ground, because without that a player who digs a single deep hole is untouchable. The harpoon only travels down open tunnel, three pumps burst a monster, and walking away drops it — so the pump is a commitment, not a button. Kong's girders are flat. The originals slope, and a slope is the one thing a terminal row cannot honestly draw; faking it with half-blocks makes a board that looks like the arcade and plays like a bug report. What the slope *does* is kept: each girder has a direction, they alternate, and the barrel chute sits at the far end of each one, so barrels cross a whole girder before they drop. Two rounds of that layout were unplayable before this one, both found by playing it without a terminal: - Barrels flipped direction when they landed, which sent them back the way they came and straight off the board. Nothing ever reached the lower girders and a player who never moved could not be hit at all. - With one ladder per girder, the only way up was the chute the barrels come down — a coin toss you cannot jump out of. Each girder has a second ladder now, and a climbing player reaches level 21 before the pace catches them. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 6 +- src/cli-schema.mjs | 2 +- src/games-digdug.mjs | 267 +++++++++++++++++++++++++++++++++++++++++++ src/games-kong.mjs | 191 +++++++++++++++++++++++++++++++ src/games.mjs | 10 +- test/games.test.mjs | 221 +++++++++++++++++++++++++++++++++++ 6 files changed, 690 insertions(+), 7 deletions(-) create mode 100644 src/games-digdug.mjs create mode 100644 src/games-kong.mjs diff --git a/README.md b/README.md index d43d95a..7bd7192 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 — sixteen games, no menus | +| `moshcode games`
`game` `arcade` | arcade | the moshcode arcade — eighteen 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`) -Sixteen games, in the pit or straight from a shell. There are no menus, no options +Eighteen 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 @@ -617,7 +617,9 @@ moshcode games --json # the roster, for a machine | `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 | | `spyhunter` | keep it on the tarmac, shoot the ones shooting back | | `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 | diff --git a/src/cli-schema.mjs b/src/cli-schema.mjs index 6520d41..266c6fe 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 — sixteen games, no menus", + description: "the moshcode arcade — eighteen games, no menus", synopsis: [ ["moshcode games", "the cabinet, and what each one is"], ["moshcode games ", "play it, right here in the terminal"], 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-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.mjs b/src/games.mjs index 5e19a2f..b3f7c88 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. // -// Sixteen games, one frame. Every game here is the same shape (see GAME_SHAPE +// Eighteen 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 sixteen weekend projects: a title, a status line, a boxed board, and +// rather than eighteen 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. @@ -28,6 +28,8 @@ 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"; /** * @typedef {object} Game — the whole contract, so a seventh game is an import. @@ -48,8 +50,8 @@ import { FROGGER } from "./games-frogger.mjs"; /** The cabinet. Order is the order `/games` lists them. */ export const GAMES = [ - TETRIS, SNAKE, PACMAN, INVADERS, CENTIPEDE, ASTEROIDS, BREAKOUT, PONG, TANK, FROGGER, - SPYHUNTER, STAGEDIVE, + TETRIS, SNAKE, PACMAN, INVADERS, CENTIPEDE, ASTEROIDS, BREAKOUT, PONG, TANK, DIGDUG, + FROGGER, KONG, SPYHUNTER, STAGEDIVE, TICTACTOE, BLACKJACK, CHESS, HANGMAN, ]; diff --git a/test/games.test.mjs b/test/games.test.mjs index f55b7bc..d9ff17b 100644 --- a/test/games.test.mjs +++ b/test/games.test.mjs @@ -49,6 +49,14 @@ import { 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 { TICTACTOE, bestMove, emptyBoard as emptyGrid, winner } from "../src/games-tictactoe.mjs"; import { BLACKJACK, MIN_BET, canSplit, freshDeck, handValue, isBlackjack, settle, @@ -785,6 +793,219 @@ test("centipede can be cleared by shooting, and not by standing there", () => { } }); +/* ----------------------------------------------------------------- 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", () => { From 74b6e9274684be527f251b1213652f27a8b6f962 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Tue, 11 Aug 2026 12:59:19 +0000 Subject: [PATCH 5/6] feat(games): pitfall and choplifter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pitfall's vine is what stops it being another jumping game. A jump is short and you commit to it the instant you press it; a swing is long and can only be started where a vine hangs. The spawner never puts down a pit without a vine over it, because a pit is five columns wide and a jump covers about five — a pit with no vine is a pit nobody gets past, and that is a test rather than a promise. Tuning it was the usual business of playing it without a terminal: the first swing was sixteen ticks, which carried you over all of a pit except its last column, and the first jump was seven, which cleared a log and landed you on its back half. Long enough now that a player who jumps and swings at the right moments is only ever beaten by the clock — which is the game Pitfall actually is. Choplifter keeps its world in world columns and only turns them into screen columns in render(). The map is three screens wide with the pad at the far left end, so "get them home" is a journey rather than a step, and the camera follows you. Four seats in the back, and a hit costs everybody in them. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 6 +- src/cli-schema.mjs | 2 +- src/games-choplifter.mjs | 179 +++++++++++++++++++++++++++++++ src/games-pitfall.mjs | 211 +++++++++++++++++++++++++++++++++++++ src/games.mjs | 8 +- test/games.test.mjs | 221 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 621 insertions(+), 6 deletions(-) create mode 100644 src/games-choplifter.mjs create mode 100644 src/games-pitfall.mjs diff --git a/README.md b/README.md index 7bd7192..1f9810c 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 — eighteen games, no menus | +| `moshcode games`
`game` `arcade` | arcade | the moshcode arcade — twenty 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`) -Eighteen games, in the pit or straight from a shell. There are no menus, no options +Twenty 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 @@ -620,6 +620,8 @@ moshcode games --json # the roster, for a machine | `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 | | `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 | diff --git a/src/cli-schema.mjs b/src/cli-schema.mjs index 266c6fe..12282c9 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 — eighteen games, no menus", + description: "the moshcode arcade — twenty games, no menus", synopsis: [ ["moshcode games", "the cabinet, and what each one is"], ["moshcode games ", "play it, right here in the terminal"], 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-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.mjs b/src/games.mjs index b3f7c88..1864c9a 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. // -// Eighteen games, one frame. Every game here is the same shape (see GAME_SHAPE +// Twenty 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 eighteen weekend projects: a title, a status line, a boxed board, and +// rather than twenty 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. @@ -30,6 +30,8 @@ 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"; /** * @typedef {object} Game — the whole contract, so a seventh game is an import. @@ -51,7 +53,7 @@ import { KONG } from "./games-kong.mjs"; /** The cabinet. Order is the order `/games` lists them. */ export const GAMES = [ TETRIS, SNAKE, PACMAN, INVADERS, CENTIPEDE, ASTEROIDS, BREAKOUT, PONG, TANK, DIGDUG, - FROGGER, KONG, SPYHUNTER, STAGEDIVE, + FROGGER, KONG, PITFALL, CHOPLIFTER, SPYHUNTER, STAGEDIVE, TICTACTOE, BLACKJACK, CHESS, HANGMAN, ]; diff --git a/test/games.test.mjs b/test/games.test.mjs index d9ff17b..38589b9 100644 --- a/test/games.test.mjs +++ b/test/games.test.mjs @@ -57,6 +57,14 @@ 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 { TICTACTOE, bestMove, emptyBoard as emptyGrid, winner } from "../src/games-tictactoe.mjs"; import { BLACKJACK, MIN_BET, canSplit, freshDeck, handValue, isBlackjack, settle, @@ -1471,6 +1479,219 @@ test("the road is drawn to size", () => { 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); +}); + /* -------------------------------------------------------------- stagedive */ /** A stage holding exactly the things a test puts on it, and nothing else. */ From 645b555ca1bb11fa1c4d8546d2309f0a82377e3a Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Tue, 11 Aug 2026 13:04:49 +0000 Subject: [PATCH 6/6] feat(games): excitebike and outrun MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Excitebike is two mechanics and a side-scroller to hang them on. Turbo is free until it isn't — heat climbs while it is held and the engine seizes at the top of the gauge, so a fast race is one that cools off in the right places. And a jump is only as good as its landing: the nose drops on its own all the way down, and you pitch against it. That last part was inert in the first version. The ramp set your pitch and nothing changed it, so whatever you left the ramp with you landed on, and the landing — the second half of the game — could be ignored entirely. A rider who holds turbo and never touches the pitch now spills two hundred times over eight runs, where one who rides it properly spills none. That difference is the test. OutRun is the only board 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 a row. roadHalf() and centreAt() are the entire renderer, and the same two functions decide what you have hit — so what is drawn and what is solid cannot drift apart. The bend is squared against distance, which is why it opens up towards the horizon and closes to nothing under your bumper, and why a corner throws you at the outside of itself. The clock is the opponent. The first checkpoint handed back 420 ticks for 260 of road, which meant a competent driver's clock only ever grew; it is 220 for 320 now, which is about fifty ticks of slack at full speed and none at all if you spin. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 6 +- src/cli-schema.mjs | 4 +- src/games-excitebike.mjs | 206 ++++++++++++++++++++++++++++++++++++++ src/games-outrun.mjs | 208 +++++++++++++++++++++++++++++++++++++++ src/games.mjs | 8 +- test/games.test.mjs | 173 ++++++++++++++++++++++++++++++++ 6 files changed, 598 insertions(+), 7 deletions(-) create mode 100644 src/games-excitebike.mjs create mode 100644 src/games-outrun.mjs diff --git a/README.md b/README.md index 1f9810c..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 — twenty 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`) -Twenty 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 @@ -623,6 +623,8 @@ moshcode games --json # the roster, for a machine | `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 | diff --git a/src/cli-schema.mjs b/src/cli-schema.mjs index 12282c9..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 — twenty 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"], @@ -934,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, invaders, pac-man, breakout, pong, tank, 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-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-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.mjs b/src/games.mjs index 1864c9a..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. // -// Twenty 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 twenty 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. @@ -32,6 +32,8 @@ 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. @@ -53,7 +55,7 @@ import { CHOPLIFTER } from "./games-choplifter.mjs"; /** The cabinet. Order is the order `/games` lists them. */ export const GAMES = [ TETRIS, SNAKE, PACMAN, INVADERS, CENTIPEDE, ASTEROIDS, BREAKOUT, PONG, TANK, DIGDUG, - FROGGER, KONG, PITFALL, CHOPLIFTER, SPYHUNTER, STAGEDIVE, + FROGGER, KONG, PITFALL, CHOPLIFTER, SPYHUNTER, OUTRUN, EXCITEBIKE, STAGEDIVE, TICTACTOE, BLACKJACK, CHESS, HANGMAN, ]; diff --git a/test/games.test.mjs b/test/games.test.mjs index 38589b9..b8c20ef 100644 --- a/test/games.test.mjs +++ b/test/games.test.mjs @@ -65,6 +65,13 @@ 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, @@ -1692,6 +1699,172 @@ test("the choplifter board is drawn to size", () => { 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. */