Living in the Terminal

The second tool was a small roguelike — a character that walks a map, picks up gold, and flees a wandering ghost. It was chosen to force one capability the runtime lacked: reading a single keypress the instant it happens, without waiting for a line. The scoping decision that made it work was to keep the game turn-based, which turned an interactive program into one that could be tested by piping keystrokes at it — a game that runs in continuous integration.

meredogfoodterminaltestinglanguage-design

The second tool went in a deliberately different direction: not out to other programs but inward, to the terminal the program was already running in. It was a small roguelike — mrog — a single @ walking a map of walls, picking up gold, fleeing a ghost that wanders. Nothing about the game mattered; what mattered was that an interactive terminal program cannot exist without the power to read a single keypress the moment a key is pressed, and the language could only read a whole line at a time, after the user pressed Enter. That is a fine way to read a name or a number and a useless way to steer a character. The gap was raw terminal input, and a game was the most honest way to walk into it.

Scoping the tool so the gap stays small

Before writing a line there was a design decision that shaped everything after it: keep the game strictly turn-based. Nothing moves until you press a key; the ghost takes its step only when you take yours. This sounds like a limitation on the game, and it is, but its real purpose was to limit the language work. A real-time game would have needed non-blocking reads, timers, and an event loop that never sleeps — a whole cluster of capabilities, each its own investigation. A turn-based game needs exactly one new thing: a blocking read of one key. By refusing every feature that would have pulled in a second gap, the tool kept its aim on a single target. This is a habit worth naming on its own: the value of a dogfood is highest when it forces one missing capability cleanly, and scoping the toy is how you keep the lesson from blurring.

Raw mode, and putting the terminal back

The capability itself was three small builtins: one to put the terminal into raw mode, one to restore it, and one to read a single key. Raw mode is the terminal setting where keys arrive immediately instead of being buffered into lines, and echo is turned off so the pressed key does not print itself. Two details in how it was built turned out to matter more than the feature. The first was to leave the interrupt signal alive in raw mode, so that Control-C still stops a wedged game rather than being swallowed as just another keypress — a program that captures the terminal owes the user a way out. The second was that when there is no real terminal attached — when input is coming from a pipe rather than a person — raw mode simply does nothing. That second choice looks like a minor robustness nicety. It was the thing that made the whole tool testable, and it deserves its own section.

A game that runs in continuous integration

Because raw mode degrades to a no-op when input is piped, the finished game could be driven entirely from a script: feed it the keystrokes “right, right, right, quit” and it plays those moves and prints its final state. That means the interactive program — the one whose entire reason for existing was real-time keyboard input — could be run non-interactively, its output captured, and checked byte-for-byte against the interpreter running the same input. A game became a regression test. Three canned sequences — one that collects the gold, one that walks into a wall and stops, one that mixes moves — pinned the behaviour across the interpreter and the native binary. It is a small irony worth savouring: the tool built to exercise the most stubbornly interactive capability the language had is also one of the most mechanically testable programs in the whole collection, precisely because the raw input was designed to disappear when no human was watching.

The interpreter-only family, again

The native build surfaced the same kind of seam the task runner had. Printing without a trailing newline worked in the interpreter but had never been implemented in the C backend; so did generating a random number for the ghost’s wandering. Both type-checked, both ran interpreted, and both were missing only once the program was compiled — the same failure shape as the missing error-channel and file-check from the tool before. By now this was a recognizable family: a handful of small runtime facilities that had quietly lived only in the interpreter, each waiting for the first native program that needed it. The roguelike found two more of them. There is nothing clever in the individual fixes — each is a few lines wiring the backend up to what the interpreter already did — but the pattern is the real result: building native programs of genuinely different shapes is how you drain a reservoir of parity bugs you would otherwise never have known were there.

By the end the game was playable as a native binary — move, collide, collect, and a ghost that ends the run if it catches you — and it drove three releases, each one a capability that now belongs to every program. But the deeper takings were two lessons about method: that scoping a tool tightly keeps it pointed at a single gap, and that designing a feature to degrade gracefully can make even an interactive program testable. The next tool would need neither the terminal nor other programs. It would read raw bytes — and the bytes it chose to read were the ones the compiler itself emits.

← Back to Mere: Building a Language