← All writing

Building the Chrome Dino game for the terminal

termrex is Chrome’s offline dinosaur, rebuilt to run in a terminal. I made it for fun and to learn: could a small real-time game work with no game engine and no terminal library, just C++ and the escape codes every terminal already understands? It turns out it can, and the hardest part wasn’t the drawing.

17 seconds of termrex in a terminal.

The rules I set

No ncurses, no SDL, no game engine. Only the C++17 standard library, POSIX calls and ANSI escape codes, and it had to run anywhere a terminal does, including the bare Linux console with no desktop at all. That left about 4,000 lines of C++ to write, a quarter of them sprites.

Taking over the terminal

A terminal normally waits for a whole line before a program sees any input, and echoes every key you press. A game needs the opposite: each key the moment it’s pressed, nothing printed, and reads that never block the frame. That’s raw mode, set with termios:

struct termios raw = original;
raw.c_lflag &= ~(ICANON | ECHO);  // keys one at a time, not echoed
raw.c_cc[VMIN] = 0;               // read() returns at once,
raw.c_cc[VTIME] = 0;              // even with nothing to read
tcsetattr(STDIN_FILENO, TCSAFLUSH, &raw);

On top of that, the game switches to the terminal’s alternate screen (\x1b[?1049h), the same trick vim and less use, so your shell history is untouched when you quit, and hides the cursor. Everything after that is moving the cursor and printing characters: \x1b[row;colH jumps to a cell, and whatever is printed there is the picture.

Sprites made of block characters

The dinosaur is drawn with Unicode block elements — ▟ █ ▙ ▜ ▀ ▄ — so each character cell holds a couple of “pixels”. The simple way to print a sprite is line by line, spaces included. But spaces aren’t transparent: they paint over whatever is behind them, so a jumping dinosaur would cut holes in the ground and the cacti.

So the sprites never print spaces. A small Python script converts each drawing, replacing every run of spaces with a cursor move over those cells, and ends the line with a move back to the start of the next row:

// Row 0 of the running dinosaur: skip 8 cells, draw 6, return.
"\x1b[8C▟■███▙\x1b[14D\x1b[1B",
"\x1b[8C███▄▄\x1b[13D\x1b[1B",
"▙\x1b[5C▄▟█▙▄\x1b[11D\x1b[1B",

The cursor hops over the gaps, so whatever was already on screen shows through. It’s transparency, done with the cursor. The same script also writes each sprite’s collision mask: a grid of 1s where the sprite has ink and 0s where it doesn’t.

Collisions that feel fair

Chrome’s dinosaur only dies when it actually touches a cactus, not when their bounding boxes overlap. termrex does the same in two steps. First a cheap rectangle test; if the boxes overlap, it walks just the overlapping cells and checks both masks:

for (int r = rowStart; r <= rowEnd; ++r)
  for (int c = colStart; c <= colEnd; ++c)
    if (a.mask[r - a.top][c - a.left] && b.mask[r - b.top][c - b.left])
      return true;   // real ink on both sides
return false;

Jumping close over a cactus’s arm and clearing it by a cell feels right, because it is.

The game loop

The loop runs at a fixed 45 frames a second. Each frame it reads input, updates everything by the time that actually passed (dt), checks for a hit, and draws:

dt = seconds(now - last);
if (dt < FRAME_TIME) { sleep(FRAME_TIME - dt); continue; }

takeInput();
update();              // physics, obstacles, ground, score, speed
if (hit()) break;
draw();
last = now;

Because movement is scaled by dt, the game runs at the same speed on a slow terminal as on a fast one. The world starts at 90 columns a second and speeds up to 220. The ground is stitched from random chunks so it never visibly repeats, cacti come at random gaps, and pterodactyls (at two heights) only start once the game has sped up, which is roughly when Chrome introduces them too. The best score is saved to ~/.termrex_highscore.

The hard part: letting go of a key

Ducking under a pterodactyl means holding ↓ and letting go. That turned out to be the most interesting problem in the whole game, because a traditional terminal never tells a program that a key was released. It only sends presses, plus repeats while you hold one down.

The fallback works on every terminal: treat repeats as “still held”, and stand the dinosaur up when they stop arriving. How long to wait depends on your keyboard’s repeat rate, so it’s a flag, --keyrepeat, defaulting to 200 ms. It works, but it always lags a little behind your finger.

Newer terminals such as kitty, foot, Ghostty and Rio support the Kitty keyboard protocol, which can report press, repeat and release. A program turns it on by pushing a mode onto the terminal’s keyboard-mode stack, and pops it on exit:

printf("\x1b[>2u");   // push: report event types (press, repeat, release)
...
printf("\x1b[<u");    // pop it again when the game exits

With that on, letting go of ↓ arrives as \x1b[1;1:3B: the arrow key (B) with event type 3, “released”. The dinosaur stands up the instant you lift your finger.

The game still has to know whether the terminal supports the protocol before relying on it. It sends two questions at once: “what keyboard mode are you in?” (\x1b[?u) and the ancient “what kind of terminal are you?” (\x1b[c), which every terminal answers. If the first answer comes back before the second, the protocol is supported. If only the second does, it isn’t, and the game uses the key-repeat fallback. Either way it knows within a tenth of a second.

Resizing and the bare console

Terminals can be resized mid-game. The kernel sends SIGWINCH; termrex re-reads the size, pauses with a “make the window bigger” notice if the play area no longer fits, and otherwise re-centres the frame and moves every sprite with it.

The Linux virtual console (the text screen you get with Ctrl+Alt+F3) has no glyphs for most of these block characters, so termrex ships a second set of sprites in plain ASCII. --ascii-only switches to it, and the dinosaur becomes a slightly lumpier, very 1980s version of itself.

Shipping it

It builds with a plain Makefile into a single binary with no dependencies. For Fedora I wrote an RPM spec and published it on COPR, so installing it is two commands:

sudo dnf copr enable satya-pr-dahal/termrex
sudo dnf install termrex

The bug that closed my terminal

One bug is worth telling. My exit code popped the keyboard mode more times than it had pushed it. Most terminals quietly ignore a pop on an empty stack, so I never noticed — until I played in Rio, quit, and the whole terminal window vanished.

The bug was mine, and I fixed it in termrex the same day (v1.0.2). But a program misbehaving shouldn’t be able to take the terminal down with it, so I also sent Rio a three-line guard against popping an empty stack; it was merged in January 2026.

What I took away

  • A terminal is a perfectly good game screen. Escape codes are a small, old, surprisingly complete drawing API.
  • Transparency doesn’t need an alpha channel: skipping a cell is enough.
  • Input is harder than output. Terminals were built for typing, not for holding keys.
  • Testing in more than one terminal finds bugs in your code, and sometimes in theirs.

The code is on GitHub. If you’re on Fedora, dnf install termrex and see how far you get.