Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 22 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` <br>`serve` | hosting | install web-server config for a Moshpit name |
| `moshcode template` <br>`templates` | hosting | scaffold a stack for a Moshpit-hosted service |
| `moshcode games` <br>`game` `arcade` | arcade | the moshcode arcade — six games, no menus |
| `moshcode games` <br>`game` `arcade` | arcade | the moshcode arcade — twenty-two games, no menus |
| `moshcode pwd` <br>`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 |
Expand Down Expand Up @@ -584,7 +584,7 @@ the composer URL instead.

## The arcade (`/games`)

Six 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
Expand All @@ -611,7 +611,23 @@ moshcode games --json # the roster, for a machine
| `tetris` | stack the bricks, clear the lines, outrun gravity |
| `snake` | eat, grow, and try not to eat yourself |
| `pacman` | eat the dots, dodge the ghosts, `✳` makes them edible |
| `invaders` | forty of them, and the last one moves fastest |
| `centipede` | shoot it in the middle and now there are two of them |
| `asteroids` | turn, thrust, shoot — every rock you break becomes two |
| `breakout` | dig a channel up the side and let the ball do the rest |
| `pong` | first to seven, and the angle is all in where you hit it |
| `tank` | two tanks, one yard, five hits — line it up and let go |
| `digdug` | dig the tunnels, pump the monsters, drop rocks on the rest |
| `frogger` | the road kills what it touches, the river kills what it doesn't |
| `kong` | five girders, four ladders, and a barrel with your name on it |
| `pitfall` | jump the logs, swing the pits, and get the gold before dark |
| `choplifter` | fly out, land, fill the back, and get them home |
| `spyhunter` | keep it on the tarmac, shoot the ones shooting back |
| `outrun` | a road that bends, traffic that doesn't, and a clock that always wins |
| `excitebike` | turbo until it cooks, and land the way you took off |
| `stagedive` | run the barricade, hop the gear, duck the crowd, take the picks |
| `tictactoe` | three in a row against an opponent that cannot be beaten |
| `blackjack` | hit, stand, double, split — dealer stands on 17, and pays 3:2 |
| `chess` | full rules — castling, en passant, promotion — and it plays back |
| `hangman` | six wrong letters and you are done for |

Expand All @@ -620,6 +636,10 @@ another, and the controls are written along the bottom of the game itself. Each
draws in place rather than on the alternate screen, so the board you finished on
stays in your scrollback.

The two games that read letters — `blackjack`, where `h` is hit, and `hangman`,
where it is a guess — keep their letters: `h j k l` are not arrows there, and `r`
only starts another once the game is over.

Playing needs a real terminal, because they read single keypresses — `moshcode
games` on its own lists them anywhere, including a pipe.

Expand Down
8 changes: 6 additions & 2 deletions src/cli-schema.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -396,7 +396,7 @@ export const CORE_CLI_COMMANDS = [
{
name: "games",
group: "arcade",
description: "the moshcode arcade — six games, no menus",
description: "the moshcode arcade — twenty-two games, no menus",
synopsis: [
["moshcode games", "the cabinet, and what each one is"],
["moshcode games <game>", "play it, right here in the terminal"],
Expand All @@ -407,6 +407,10 @@ export const CORE_CLI_COMMANDS = [
["moshcode games tetris", ""],
["moshcode games chess", "real rules, and it plays back"],
["moshcode games pacman", "dots, ghosts, three lives"],
["moshcode games asteroids", "turn, thrust, shoot"],
["moshcode games 21", "blackjack, 100 chips, 3:2"],
["moshcode games invaders", "forty of them, coming down"],
["moshcode games stagedive", "jump the gear, take the picks"],
],
seeAlso: ["help"],
note: "every game works the same way: arrows move, q quits, r starts another. "
Expand Down Expand Up @@ -930,7 +934,7 @@ export const PIT_COMMANDS = [
{ name: "plugin", aliases: ["plugins"], args: "<verb> [name]", cli: "plugin",
description: "install moshcode's slash commands into Claude Code" },
{ name: "games", aliases: ["game", "arcade", "play"], args: "[game]", cli: "games",
description: "the arcade — tetris, snake, pac-man, tic-tac-toe, chess, hangman" },
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: '<social> "message"', pitOnly: true,
Expand Down
265 changes: 265 additions & 0 deletions src/games-asteroids.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,265 @@
// Asteroids. Turn, thrust, shoot, and watch the rocks you shot become two
// smaller rocks that are somehow worse.
//
// The one thing the 1979 cabinet did not have to worry about: a terminal cell is
// about twice as tall as it is wide. Everything here measures distance in
// columns, so a row counts double (see `span`) — without that, every rock is an
// egg and the collisions land in places the screen never showed you.
import { acid, amber, ash, bone, danger, dim, rgb } from "./ui.mjs";

export const WIDTH = 44;
export const HEIGHT = 18;

/** A row is worth this many columns. Half of everything vertical follows. */
export const ASPECT = 0.5;

const TURN = Math.PI / 8; // 16 headings, which is as many as 8 glyphs can tell apart
const THRUST = 0.14;
const DRAG = 0.97; // space has none; a 55ms terminal very much needs some
const MAX_SPEED = 1.15;
const BULLET_SPEED = 1.6;
const BULLET_LIFE = 22;
const BULLETS = 4; // in flight at once — the arcade limit, and it is the game
const COOLDOWN = 2;
const INVULN = 30; // ticks of blinking after a life is lost
const LIVES = 3;

/** Radius in columns, and what a hit is worth. Small ones pay most. */
export const ROCKS = {
3: { r: 3.2, glyph: "█", points: 20, speed: 0.16 },
2: { r: 2.1, glyph: "▓", points: 50, speed: 0.24 },
1: { r: 1.1, glyph: "▒", points: 100, speed: 0.34 },
};

const HEADINGS = ["→", "↘", "↓", "↙", "←", "↖", "↑", "↗"];
const FLAME = ["◄", "◤", "▲", "◥", "►", "◢", "▼", "◣"];

export const wrap = (v, max) => ((v % max) + max) % max;

/**
* The starfield: fixed, scattered, and free. Hashed from the coordinates rather
* than stored or rolled, so the sky is the same on every repaint — and hashed
* rather than `(x * 7 + y * 23) % 37`, which is a diagonal line, not a sky.
*/
export const star = (x, y) => ((((x * 73856093) ^ (y * 19349663)) >>> 4) % 53) === 0;

/** The shorter way round a wrapping axis — the screen has no edges. */
const delta = (a, b, max) => {
const d = a - b;
return d > max / 2 ? d - max : d < -max / 2 ? d + max : d;
};

/** Distance between two things, in columns, the short way round. */
export function span(a, b) {
const dx = delta(a.x, b.x, WIDTH);
const dy = delta(a.y, b.y, HEIGHT) / ASPECT;
return Math.hypot(dx, dy);
}

const drift = (thing) => {
thing.x = wrap(thing.x + thing.vx, WIDTH);
thing.y = wrap(thing.y + thing.vy, HEIGHT);
};

/** A rock of `size`, moving somewhere, starting where it is put. */
export function rock(size, x, y, rng) {
const angle = rng() * Math.PI * 2;
const speed = ROCKS[size].speed * (0.7 + rng() * 0.6);
return {
size, x, y,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed * ASPECT,
};
}

/**
* A wave. Rocks arrive at the edges of the screen and never on top of the ship —
* spawning one on the ship is the cheapest way to make a game feel broken.
*/
export function spawnWave(wave, ship, rng) {
const rocks = [];
for (let i = 0; i < 3 + wave; i++) {
let x = 0;
let y = 0;
let tries = 0;
do {
x = rng() * WIDTH;
y = rng() * HEIGHT;
tries++;
} while (span({ x, y }, ship) < 14 && tries < 60);
rocks.push(rock(3, x, y, rng));
}
return rocks;
}

const newShip = () => ({ x: WIDTH / 2, y: HEIGHT / 2, vx: 0, vy: 0, angle: -Math.PI / 2 });

/**
* One tick: everything moves, then everything that touched something else
* finds out. Exported so a test can fly a whole game without a clock.
*/
export function step(state) {
const { rng } = state;
const ship = state.ship;

ship.vx *= DRAG;
ship.vy *= DRAG;
const speed = Math.hypot(ship.vx, ship.vy / ASPECT);
if (speed > MAX_SPEED) {
ship.vx *= MAX_SPEED / speed;
ship.vy *= MAX_SPEED / speed;
}
drift(ship);

for (const bullet of state.bullets) { drift(bullet); bullet.life--; }
state.bullets = state.bullets.filter((b) => b.life > 0);
for (const r of state.rocks) drift(r);

if (state.cooldown > 0) state.cooldown--;
if (state.thrusting > 0) state.thrusting--;
if (state.invuln > 0) state.invuln--;

// Bullets first: a rock shot on the same tick it reaches you does not also
// get to take a life.
for (const bullet of [...state.bullets]) {
const hit = state.rocks.find((r) => span(bullet, r) <= ROCKS[r.size].r + 0.4);
if (!hit) continue;
state.bullets = state.bullets.filter((b) => b !== bullet);
state.rocks = state.rocks.filter((r) => r !== hit);
state.score += ROCKS[hit.size].points;
if (hit.size > 1) {
state.rocks.push(rock(hit.size - 1, hit.x, hit.y, rng), rock(hit.size - 1, hit.x, hit.y, rng));
}
}

if (!state.invuln) {
const struck = state.rocks.find((r) => span(ship, r) <= ROCKS[r.size].r + 0.9);
if (struck) {
state.lives--;
if (state.lives <= 0) {
state.lives = 0;
state.over = `wrecked on wave ${state.wave}`;
return state;
}
state.ship = newShip();
state.bullets = [];
state.invuln = INVULN;
// The rock that got you is pushed clear rather than deleted, so the
// respawn is not immediately fatal a second time.
struck.x = wrap(struck.x + WIDTH / 2, WIDTH);
struck.y = wrap(struck.y + HEIGHT / 2, HEIGHT);
}
}

if (!state.rocks.length) {
state.wave++;
state.rocks = spawnWave(state.wave, state.ship, rng);
state.invuln = Math.max(state.invuln, 12);
}
return state;
}

export const ASTEROIDS = {
key: "asteroids",
aliases: ["rocks", "asteroid"],
title: "ASTEROIDS",
blurb: "turn, thrust, shoot — every rock you break becomes two",
keys: "← → turn · ↑ thrust · ↓ retro · space fire · q quit",
tickMs: 55,

create({ rng = Math.random } = {}) {
const ship = newShip();
return {
ship,
bullets: [],
rocks: spawnWave(1, ship, rng),
score: 0,
lives: LIVES,
wave: 1,
cooldown: 0,
thrusting: 0,
invuln: INVULN,
over: null,
rng,
};
},

tick: step,

onKey(state, key) {
const ship = state.ship;
if (key === "left") ship.angle -= TURN;
else if (key === "right") ship.angle += TURN;
else if (key === "up" || key === "down") {
// Retro is half power, because a reverse as strong as the throttle turns
// the ship into something that cannot drift, and drifting is the game.
const push = key === "up" ? THRUST : -THRUST / 2;
ship.vx += Math.cos(ship.angle) * push;
ship.vy += Math.sin(ship.angle) * push * ASPECT;
if (key === "up") state.thrusting = 3;
} else if (key === "space" || key === "enter") {
if (state.cooldown > 0 || state.bullets.length >= BULLETS) return state;
state.bullets.push({
// Out of the nose, not the middle, or the ship shoots itself in the face
// at close range and the muzzle flash lands inside the hull.
x: wrap(ship.x + Math.cos(ship.angle) * 1.6, WIDTH),
y: wrap(ship.y + Math.sin(ship.angle) * 1.6 * ASPECT, HEIGHT),
vx: ship.vx + Math.cos(ship.angle) * BULLET_SPEED,
vy: ship.vy + Math.sin(ship.angle) * BULLET_SPEED * ASPECT,
life: BULLET_LIFE,
});
state.cooldown = COOLDOWN;
}
return state;
},

status(state) {
return state.over
? `${state.over} · ${state.score} points`
: `score ${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) => {
const col = wrap(Math.round(x), WIDTH);
const row = wrap(Math.round(y), HEIGHT);
grid[row][col] = glyph;
};

for (const r of state.rocks) {
const { r: radius, glyph } = ROCKS[r.size];
const paint = r.size === 3 ? ash : r.size === 2 ? rgb(150, 158, 150) : rgb(190, 196, 188);
// A disc, measured in columns — hence the row doubling, which is the only
// reason these come out round instead of squashed.
for (let dy = -Math.ceil(radius * ASPECT); dy <= Math.ceil(radius * ASPECT); dy++) {
for (let dx = -Math.ceil(radius); dx <= Math.ceil(radius); dx++) {
if (Math.hypot(dx, dy / ASPECT) > radius) continue;
put(r.x + dx, r.y + dy, paint(glyph));
}
}
}

for (const bullet of state.bullets) put(bullet.x, bullet.y, amber("•"));

const heading = ((Math.round(state.ship.angle / (Math.PI / 4)) % 8) + 8) % 8;
if (state.thrusting) {
put(
state.ship.x - Math.cos(state.ship.angle) * 1.4,
state.ship.y - Math.sin(state.ship.angle) * 1.4 * ASPECT,
danger(FLAME[heading]),
);
}
if (!state.over) {
// Blinking is the only tell that the ship cannot be hit yet.
const shield = state.invuln && state.invuln % 6 < 3;
put(state.ship.x, state.ship.y, (shield ? bone : acid)(HEADINGS[heading]));
} else {
put(state.ship.x, state.ship.y, danger("✷"));
}

return grid.map((row, y) => row.map((cell, x) => (
cell ?? (star(x, y) ? dim("·") : " ")
)).join(""));
},
};
Loading