The idea was simple and a little absurd: what if, every time Claude Code was running a tool or thinking through a task, a mini-game popped up in your terminal? Not an app. Not a tab. Right there in the same terminal where your code lives — play for a few seconds, Claude finishes, game disappears.
I called it claude-arcade. It's a Python package you install with pipx install claude-arcade, and it currently ships three games: Bird Hunt, Pong, and Road Racer. This is the story of how it got built, what broke along the way, and what I learned.
The idea
I use Claude Code a lot. It's genuinely good at long multi-step tasks — refactoring, writing tests, debugging build failures. But those tasks take time, and I found myself context-switching: checking a Slack message, opening Twitter, losing the thread. The downtime wasn't the problem. The aimless drift was.
I wanted something that kept me in the terminal mentally, even when Claude was doing the work. A game felt right — something you could pick up for fifteen seconds and put down cleanly. The constraint was interesting too: it had to live entirely in the terminal, using nothing but ASCII/Unicode characters, with no external dependencies, installable with a single command.
How Claude Code hooks work
Claude Code has a hooks system. You can register shell commands that fire on specific events — PreToolUse (before Claude runs a tool), PostToolUse (after), and Stop (when Claude finishes). These live in ~/.claude/settings.json.
The plan: on PreToolUse, start a game. On PostToolUse and Stop, stop it. The game process needed to be completely independent of Claude's process — it couldn't block Claude, couldn't read from the same stdin, and had to clean up without leaving any mess behind.
The communication mechanism I landed on was the simplest possible thing: a stop file. When the hook fires to stop the game, it writes a file to /tmp/.claude-arcade-stop. The game loop checks for that file every frame. When it sees it, it deletes the file and exits cleanly. No sockets, no signals (well, signals too for good measure), just a file on disk.
The terminal access problem
Here's something I didn't anticipate: when a hook fires, Claude Code has already captured stdin and stdout. The hook subprocess inherits those — which means you can't just call curses.wrapper() and get a working terminal. It fails immediately with [Errno 6] Device not configured.
The fix involves /dev/tty. On Unix systems, /dev/tty is a direct path to the terminal attached to the process, bypassing whatever redirection is in place. You open it, then dup2 the file descriptor onto stdin, stdout, and stderr before initializing curses:
tty_fd = os.open("/dev/tty", os.O_RDWR)
for fd in (0, 1, 2):
os.dup2(tty_fd, fd)
if tty_fd > 2:
os.close(tty_fd)
When even that fails — some terminal emulators don't expose /dev/tty reliably — the fallback is to use osascript on macOS to open a fresh Terminal.app or iTerm2 window and run the game there. Less seamless, but it works.
Game one: Bird Hunt
I started with something visually interesting — birds flying across the screen, shot with a crosshair. The entire visual is Unicode block characters. A sparrow looks like this:
frames_r: ["▄▀ ", "*██>"] # wings up
["▀▄ ", "*██>"] # wings down
Three bird types — Sparrow, Seagull, Pterodactyl — each with different speeds, sizes, and point values. The crosshair moves with arrow keys, spacebar shoots. Birds animate between two frames at different rates depending on type. The whole thing runs in a fixed 30 FPS loop using a timestep accumulator rather than time.sleep(0.033) — the latter drifts, especially under load.
The fixed timestep pattern looks like this: you track real elapsed time, accumulate it, and consume it in fixed FRAME_DT-sized chunks. Physics and game logic run on those fixed steps regardless of how fast the CPU is running. Rendering happens once per pass. It gives you consistent, deterministic behavior even when frames occasionally take longer.
Game two: Pong — and the problem of an unbeatable CPU
Pong seemed obvious as game two. Paddle, ball, score. The implementation came together fast — the hard part wasn't the mechanics, it was the AI.
My first version of the CPU paddle tracked the ball position perfectly every frame. It never missed. I tried capping its speed — it still never missed, it just got there by the last possible moment. The problem is that a CPU with perfect information is effectively unbeatable no matter how slow you make it, because it always knows exactly where the ball will be.
The fix required making the CPU imperfect in a realistic way. I ended up combining four techniques:
Reaction delay. The CPU doesn't respond to the ball's position immediately. It has a 280ms window of inertia before it starts moving. This mirrors human reaction time and creates natural windows where the ball slips past.
Positional error. The CPU aims for the ball's position plus a random offset that scales with ball speed. Fast balls → more error. The paddle often overshoots or undershoots slightly.
Blind spot. When the ball is moving away from the CPU, it stops tracking entirely. It just holds position. This means a return shot to the opposite corner often catches it flatfooted.
Speed gap. The player moves at 22 characters/second. The CPU is capped at 12. No matter how well the CPU reads the ball, it physically can't cover the full paddle height in time if you hit a wide enough angle.
The combination makes the CPU feel like a real opponent — competent enough to return most shots, but genuinely beatable with patience and placement.
The nested curses bug
For a while, claude-arcade play would show the game menu, you'd select a game, and it would just... exit. No error. No crash. Back to the terminal prompt.
The root cause was nested curses.wrapper() calls. The menu ran inside one curses.wrapper(). When a game was selected, the code called curses.wrapper(game_fn) again. The inner wrapper called endwin() when it finished, tearing down the entire curses session — including the outer one. The terminal state was corrupted. The outer wrapper then returned, thinking everything was fine.
The fix: one curses.wrapper() per process, period. The menu and the game both receive the same stdscr object. The menu returns a game key, not a running game. The caller passes stdscr directly to the game function. No nested sessions, no competing cleanup.
def _inner(stdscr):
game_key = show_menu(stdscr) # menu runs, returns key
if game_key is None:
return
fn = _game_fn(game_key)
stdscr.clear()
fn(stdscr) # game runs in same session
curses.wrapper(_inner) # one session, always
The stale stop file bug
Another subtle bug: the first time you launched a game after a hook had run, the game would exit immediately. The second time it worked fine.
The stop file. When the hook fires to end a game, it writes /tmp/.claude-arcade-stop. If no game was running at that moment — maybe you quit manually before the hook fired — the file sits on disk. Next time you launch a game, the very first iteration of the game loop sees the file, deletes it, and breaks out. The game exits in under a frame.
The fix is simple: at the top of every game function, delete the stop file if it exists before entering the loop.
Game three: Road Racer
For the third game I wanted something visually richer — movement, speed, a sense of danger. A top-down road racer fit. Three lanes, oncoming traffic, increasing speed with no ceiling on difficulty.
The road is entirely Unicode. Grass is ▓. Road edges are ║. Lane dividers are ┊, scrolling at a rate proportional to your current speed so the visual feedback of acceleration is immediate. Trees (▲) scroll in the grass strips at 60% of road speed, giving depth through parallax.
Enemy cars are five characters wide and four rows tall. There are three types — sedan, truck, sports car — each a different colour, each with a distinct sprite made entirely of block characters:
Sedan (red): ["▐▄▓▄▌", "▐▓░▓▌", "▐▓░▓▌", "▐▀▓▀▌"]
Truck (yellow): ["▐███▌", "▐█ █▌", "▐█ █▌", "▐███▌"]
Sports (white): ["▐▄▄▄▌", "▐░░░▌", "▐░░░▌", "▐▀▀▀▌"]
Lane changes are animated — the player car slides smoothly across at 22 columns/second rather than teleporting. The smooth slide comes from clamping a per-frame step toward the target position: player_x = target if abs(diff) <= step else player_x + copysign(step, diff). Simple, but it makes the game feel responsive rather than mechanical.
After a collision you get two seconds of invincibility — the car flashes by toggling visibility eight times per second (int(invincible * 8) % 2 == 0). It's a classic pattern from arcade games and it works because it communicates the invincibility window clearly without any UI chrome.
Packaging and distribution
The package uses hatchling as the build backend and is distributed on PyPI. One design constraint I cared about: the GitHub repo structure should be readable. Each game lives in its own folder under claude_arcade/games/ with its own README.md describing controls, sprites, and mechanics. Someone landing on the repo should be able to understand what each game is without reading code.
I ran into the classic PyPI 403 on first upload — wrong API token format. The token has to include the pypi- prefix and be entered exactly. After that, python3 -m build && twine upload dist/* is all it takes.
The splash screen — the CLAUDE ARCADE title that appears when you run claude-arcade play — is rendered using a hand-built 5×5 block font. Each letter is defined as five rows of five characters using only █. It's impractical and I enjoyed making it.
What's next
Three games in, the foundation is solid. Each game has its own folder, its own README, its own difficulty curve. The hook integration works. The package is on PyPI.
The games I'm thinking about next: a snake variant, a mini roguelike (one floor, procedural rooms, one life), and something that uses your actual shell history as content — the idea of a game that knows what commands you run is interesting. A JavaScript port is also on the table once there are four or five Python games worth porting.
The constraint that made this fun to build was also what made it hard: no graphics library, no browser, no external dependencies. Just Python, curses, and Unicode. It forces you to think carefully about what you can represent with a handful of block characters and how much game feel you can create with timing alone.
The source is on GitHub. Install it with pipx install claude-arcade, run claude-arcade setup to wire up the hooks, and the next time Claude is thinking you'll have something to do.