The Mills · Games

pac-vt100

Pac in the terminal — mills/pac-vt100

Pac-Man drawn with VT100 escapes

Summary

pac-vt100 is Pac in a terminal: a 39×25 maze of walls, dots, and cherries, four ghosts, and nothing on screen that isn't a character. Eat every dot to win. A cherry makes the ghosts edible for a few seconds. There is no window and no scribbler. The screen is any VT100/ANSI terminal — a terminal that understands a standard set of control codes for moving the cursor. The vt100 machine paints it with escape strings — short character sequences that carry those control codes. Keys arrive through the InKey builtin, which is non-blocking (it never stops to wait for a key) and unechoed (the key is not printed back to the screen). WASD, the arrow keys, or the numeric keypad move. Q or Escape quits. The menu's C toggles colour. With colour off, the game only ever emits cursor addressing, never a colour code — exactly as in the C original.

The Original

PAC-MAN is a trademark of Bandai Namco Entertainment Inc. This mill is an independent educational homage, not affiliated with or endorsed by the trademark owner. See THIRD-PARTY-NOTICES.md.

Pac-Man itself needs no introduction — Namco, 1980, the most recognizable maze in software. This mill is a rewrite of a compact C terminal rendition. (The original C source is not redistributed here; see THIRD-PARTY-NOTICES.md.) It keeps the same board[25][39] maze, the same ghost direction scheme, and the same colour-toggle menu. What changed in the crossing is the interesting part. The C original mutates one global board — changes it in place — and lets its read loop run as fast as the computer allows. The Shoddy version instead threads an immutable state record — a bundle of values that is never changed, only replaced by a new one — through a tick that fires at a fixed rate. Its timing constants are therefore re-tuned rather than copied. The C original's EDIBLE_TIMEOUT 300 loop iterations become 80 engine ticks, and so on. The header of pac-core.shoddy shows the arithmetic.

Running It

bin/mill run mills/pac-vt100/pac.shoddy

or, from mills/pac-vt100/, use the wrapper — ./build.sh, or ./build.ps1 on Windows:

./build.sh          # run the game (also: ./build.sh run)
./build.sh test     # run the headless simulation smoke check

It wants a real ANSI-capable terminal at least 80×26 (Windows Terminal, or any Unix terminal, is fine). The game runs one tick per frame at 100 ms, paced by the clock machine.

It also ships on the Shoddy Reckoner's games shelf. There, the app's terminal control supplies the 80×26 ANSI screen and feeds keystrokes down a pipe. The woven program is the same one, unmodified.

How It's Built

The pure model is pac-core.shoddy. It holds the maze, the state record, the per-tick simulation — ghost AI, timers, collisions, eating, winning and losing — and the input actions. Coordinates are 0-based cells: counting starts at zero, not one. Directions use the C original's scheme (0 none, 1 up, 2 right, 3 down, 4 left). The board is an Array of 25 row Strings holding # walls, . dots, + cherries, and eaten spaces. PutCell returns a new board; nothing mutates. The core contains no cursor addressing at all, so it runs headless — with no screen attached. That lets it be unit-tested that way, twice over: by test.shoddy in the folder and by src/Shoddy.Tests/PacTests.cs in the golden suite.

The terminal half is pac.shoddy: painting, the menu, and the loop. It has one sly trick. Print appends a newline, so each frame is assembled into a single string that ends by parking the cursor at row 1. The newline then lands harmlessly on row 2 and can never scroll the maze away. GameLoop, MenuWait, and WaitKey are recursive — each one calls itself — and every branch makes that call directly, as the last thing it does. Those are genuine self tail calls, which the compiler turns into loops.

Why More Than One File

The C original is one file. It mutates one global board inside one free-running read loop, with simulation, drawing, and input interleaved line by line. That mix is exactly what makes such programs hard to test. The port cuts along the effect line instead. pac-core.shoddy is everything the game is — maze, state record, ghost AI, timers, collisions, input actions — and emits not one cursor movement. pac.shoddy is everything the game shows, and decides nothing.

That split is why the core can be tested twice over — by test.shoddy in the folder and by src/Shoddy.Tests/PacTests.cs in the golden suite. Both drive the simulation tick by tick with no terminal anywhere. The split also made the timing retune honest. When the free-running loop's constants had to become fixed-rate tick counts, every change landed in the core, where a test could hold it still. None landed in the paint code, where a change would only show up as feel.

The Machines It Uses

MachineWhy
seqIn the core: Any, All, Contains, and Flatten — the questions the simulation asks of the maze and the ghosts (any dot left? is a ghost on this cell?).
strIn the core: the board is 25 row Strings, and Replace does the row surgery. PutCell rebuilds a row around one changed cell instead of mutating it.
mathIn the core: RandInt and Pick are the ghosts' dice.
vt100Both directions of the terminal protocol. The direction the core needs is the surprise: it uses EvalKey to classify raw InKey input, because arrow and keypad presses arrive as escape sequences and decoding them is part of the pure input model. The shell uses the emit side — cursor addressing and clearing. (The machine stops at attributes, so the ANSI colour codes live in the shell itself.)
clockIn the shell: SleepUntil paces the loop to one tick per 100 ms frame without drift.