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
18 changes: 16 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 — eight 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`)

Eight games, in the pit or straight from a shell. There are no menus, no options
Twenty-two games, in the pit or straight from a shell. There are no menus, no options
screens and no difficulty prompts — `/games tetris` is already playing.

```sh
Expand All @@ -611,7 +611,21 @@ moshcode games --json # the roster, for a machine
| `tetris` | stack the bricks, clear the lines, outrun gravity |
| `snake` | eat, grow, and try not to eat yourself |
| `pacman` | eat the dots, dodge the ghosts, `✳` makes them edible |
| `invaders` | forty of them, and the last one moves fastest |
| `centipede` | shoot it in the middle and now there are two of them |
| `asteroids` | turn, thrust, shoot — every rock you break becomes two |
| `breakout` | dig a channel up the side and let the ball do the rest |
| `pong` | first to seven, and the angle is all in where you hit it |
| `tank` | two tanks, one yard, five hits — line it up and let go |
| `digdug` | dig the tunnels, pump the monsters, drop rocks on the rest |
| `frogger` | the road kills what it touches, the river kills what it doesn't |
| `kong` | five girders, four ladders, and a barrel with your name on it |
| `pitfall` | jump the logs, swing the pits, and get the gold before dark |
| `choplifter` | fly out, land, fill the back, and get them home |
| `spyhunter` | keep it on the tarmac, shoot the ones shooting back |
| `outrun` | a road that bends, traffic that doesn't, and a clock that always wins |
| `excitebike` | turbo until it cooks, and land the way you took off |
| `stagedive` | run the barricade, hop the gear, duck the crowd, take the picks |
| `tictactoe` | three in a row against an opponent that cannot be beaten |
| `blackjack` | hit, stand, double, split — dealer stands on 17, and pays 3:2 |
| `chess` | full rules — castling, en passant, promotion — and it plays back |
Expand Down
6 changes: 4 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 — eight games, no menus",
description: "the moshcode arcade — twenty-two games, no menus",
synopsis: [
["moshcode games", "the cabinet, and what each one is"],
["moshcode games <game>", "play it, right here in the terminal"],
Expand All @@ -409,6 +409,8 @@ export const CORE_CLI_COMMANDS = [
["moshcode games pacman", "dots, ghosts, three lives"],
["moshcode games asteroids", "turn, thrust, shoot"],
["moshcode games 21", "blackjack, 100 chips, 3:2"],
["moshcode games invaders", "forty of them, coming down"],
["moshcode games stagedive", "jump the gear, take the picks"],
],
seeAlso: ["help"],
note: "every game works the same way: arrows move, q quits, r starts another. "
Expand Down Expand Up @@ -932,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, asteroids, blackjack, chess and more" },
description: "the arcade — tetris, invaders, pac-man, frogger, kong, outrun, chess and more" },
{ name: "socials", aliases: ["social"], pitOnly: true,
description: "list social networks available for posting" },
{ name: "post", args: '<social> "message"', pitOnly: true,
Expand Down
186 changes: 186 additions & 0 deletions src/games-breakout.mjs
Original file line number Diff line number Diff line change
@@ -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(""));
},
};
Loading