DEV Community

Erkan DOGAN
Erkan DOGAN

Posted on

Playing DOOM in Claude Code's Statusline (and Fighting Its Renderer to Keep It There)

Playing DOOM in Claude Code's Statusline

I made DOOM run inside Claude Code. Real DOOM — the 1993 id Software engine, rendering live in the bottom of the Claude Code window, right there in the status-bar area. You move the player by typing w/a/s/d/f into the chat prompt, the same way you'd type a message to Claude. Anything that isn't a recognized key falls through to Claude as a normal chat turn. And Claude itself can play the game, because I exposed the inputs and game state through an MCP server.

No patched binary. No VS Code extension. No electron window. Just four of Claude Code's own extension surfaces — statusline, UserPromptSubmit hook, MCP server, slash command — composed in a way that wasn't obviously intended but turns out to work.

The gameplay architecture took a few hundred lines of code. The rest of this post is about the ten hours of debugging that came after, because Claude Code's Ink-based terminal renderer really, really did not want to hold a live DOOM frame steady in its statusline.

How it actually works

A background daemon runs the doomgeneric DOOM engine and writes each rendered frame as 24-bit ANSI to /tmp/doom-in-claude/frame.ansi. Claude Code's statusline cats that file on every refresh. A UserPromptSubmit hook interprets w/a/s/d/f typed in the prompt as DOOM keys. An MCP server exposes doom_look / doom_move / doom_state so Claude itself can play.

daemon (doomgeneric)  →  frame.ansi / frame.png / state.json
         ↑                                  ↓
    input.fifo                           statusline (display)
         ↑                                  ↓
    UserPromptSubmit hook  ←   you type `wwwf` in the prompt
                                            ↓
                                      MCP server (Claude's tools)
Enter fullscreen mode Exit fullscreen mode

Four existing Claude Code extension points, a 900-line C wrapper, and a tiny statusline shell script. That's the feature.

The rest of this post is about what happened when I tried to make it render cleanly. None of the complexity is in the gameplay pipeline. All of it is in getting the multi-line 24-bit-ANSI stream past Claude Code's Ink-based TUI renderer without visual drift.

Repo: https://github.com/erkandogan/doom-in-claude-code


The symptom

DOOM starts. First frame — perfect. You walk forward. Second frame — perfect. You walk a few more, start seeing enemies, fire the pistol. Around frame 20 or 30, walls start showing wrong colors. By frame 50, the image is scrambled noise. Quitting /doom stop doesn't fix it. Only a full Claude Code restart clears it.

My first guess was the daemon — maybe doomgeneric's framebuffer was being overwritten mid-read. No: the frame file was atomic-rename-written, byte counts were consistent when I inspected them after-the-fact. Whatever was corrupting the rendering lived after we wrote to disk.

Ink keeps its own mental model of the screen

Claude Code's TUI is built on Ink — React for terminals. Ink maintains a virtual-DOM-like cell grid, and when the statusline updates, Ink doesn't re-emit the whole thing — it computes a diff from its cached previous state and emits only the changed cells. This is fast and flicker-free for text, and it's the cause of our drift.

A live DOOM frame has ~3,000 cells (120 × 28), every one emitting a 24-bit foreground/background SGR on each refresh. That's ~6,000 SGR escape sequences and ~129 KB of bytes per frame, refreshed multiple times per second. Ink's diff engine is perfectly competent at this volume of text but occasionally miscounts cell widths, and the miscounts compound. Each frame, Ink's mental model drifts a little further from the actual terminal. After ~30 frames, cells its model thinks are at column 42 are physically at column 43. Colors get painted on wrong glyphs. Your image becomes noise.


Hypothesis 1: Stabilize the bytes

First theory: maybe Ink's miscounting comes from variable byte-structure between frames. Each cell emits something like \x1b[38;2;R;G;B;48;2;R;G;Bm<glyph>\x1b[0m — where R, G, B are 1 to 3 digits. A red value of 5 is 1 byte, 255 is 3 bytes. So even in a static scene where the glyphs don't change, color values drifting by tiny dithering amounts produced different byte offsets between frames.

Fix: a perl normalizer between chafa and the atomic-rename write, which:

  1. Pads every RGB triple to 3 digits: 38;2;255;60;60m38;2;255;060;060m
  2. Replaces 1-byte ASCII spaces (chafa emits these for blank cells) with 3-byte U+2800 Braille blanks — identical visual, stable byte count
  3. Strips DEC private modes (cursor hide/show) that Ink doesn't track
  4. Collapses duplicate \x1b[0m resets
  5. Ensures each line ends with exactly one \x1b[0m\n

Result: every byte position in the frame is identical frame-to-frame except the RGB digit values themselves. 97% byte-identical between successive frames. Drift should be impossible.

It still drifted. Less, but it still drifted.

Hypothesis 2: I was fixing the wrong render path

Embarrassingly: the user was running the sextant renderer (our fallback native C path), not chafa. My perl normalizer only ran in the chafa path. Half a day of byte-stability debugging was invisibly routed around.

The comment in my own code had warned: Unicode 13 sextant glyphs (U+1FB00..U+1FB3B) aren't recognized by most string-width libraries, so Ink can't measure their width, so every sextant-heavy cell nudges Ink's column tracker a fractional cell. The existing sextant renderer was structurally incompatible with Ink regardless of byte stability.

Fix: change default render mode to chafa-if-available-else-quadrant (quadrant uses only U+2580–U+259F block glyphs, which every string-width library handles correctly).

Hypothesis 3: The HUD line I wasn't stabilizing

The top line of the DOOM statusline is a rich HUD: DOOM E1M1 HP 100 ARM 0 PISTOL 50/200 KILLS 0 ITEMS 0 SECRETS 0. Every field used %d — so when HP dropped from 100 to 65, the line's byte count shrank by 1. Weapon names were 4–8 chars, so switching from FIST to CHAINGUN shifted bytes. And there were TWO different format strings depending on whether the weapon had ammo.

Fix: one unified format string, every field padded to its maximum width:

"%sHP %3d%s  %sARM %3d%s  %s%-8s%s %s%s%s  %sKILLS %4d  ITEMS %4d  SECRETS %3d"
Enter fullscreen mode Exit fullscreen mode

HUD went from variable byte count to a rock-solid 244 bytes in every possible game state. Only then was the whole frame byte-stable.

The regex bug that only fired on palette-2

With 256-color chafa output, one SGR in every frame came out 3 bytes longer than its neighbors:

\x1b[38;5;2;48;5;22m    (raw chafa)
\x1b[38;5;002;048;005;022m   (after my padding — 048 is wrong!)
Enter fullscreen mode Exit fullscreen mode

My RGB-padding regex was ;2;(\d+);(\d+);(\d+) — which happens to match ;2;48;5;22 inside a 256-palette SGR where the fg palette index is 2. The regex thought 48 was a red value and padded it to 048.

Fix: anchor the regex with (?<![0-9])(38|48);2; so padding only fires when 38 or 48 is at the start of an SGR parameter list. Classic "regex fires on subsequence inside another structure" bug.

Hypothesis 4: Maybe all this byte work is missing the point

10 action frames captured during heavy play. All exactly 81,282 bytes. 97% byte-identical. Drift still visible under damage flashes.

That was the moment I actually read Claude Code's statusline docs. Buried in the troubleshooting section:

Complex escape sequences (ANSI colors, OSC 8 links) can occasionally cause garbled output if they overlap with other UI updates.

Multi-line status lines with escape codes are more prone to rendering issues than single-line plain text.

Claude Code's own docs warn about exactly this. The drift isn't a solvable bug in our bytes; it's a known architectural limitation of multi-line ANSI statuslines.


Stress reduction, then real fixes

Smaller frames

  • Dropped frame rate from 6.7 Hz → 3.3 Hz (half the byte throughput into Ink).
  • Static black 8-pixel border around the DOOM image — edge cells never change, so Ink's diff emits nothing for them. Free stable anchors at every line boundary.
  • 256-color palette instead of TrueColor (32-byte SGR → 16-byte SGR, half the volume). Doom's native palette is 256 colors anyway, so it's not a semantic loss.

~270 KB/s into Ink instead of ~860 KB/s. Drift slowed.

Reading Ink's source

Ink uses log-update internally. log-update's model is dead simple:

let lastOutput = '';
function update(newOutput) {
  const lines = lastOutput.split('\n').length;
  process.stdout.write(`\x1b[${lines}A\x1b[0J`);  // cursor up N, clear to end
  process.stdout.write(newOutput);
  lastOutput = newOutput;
}
Enter fullscreen mode Exit fullscreen mode

lastOutput is state. If log-update's model of "previous was 29 lines" gets out of sync with reality, subsequent updates clear the wrong region. This is the drift.

There's no external API to invalidate log-update's cache. rerender(), clear(), unmount() are methods on the Ink render instance inside the app — you can't reach them from a shell script writing to the statusline.

The only lever is lastOutput.split('\n').length. If you emit fewer lines, log-update moves up that smaller count, clears that smaller region, and your N−M previous lines are dropped from its cache.

The short-frame trick

Every 20th statusline refresh, the script emits one line ([doom] — refreshing...) instead of the 29-line frame. Ink's log-update now thinks "previous output was 1 line." Next refresh, we emit 29 lines — log-update treats them as fresh content, re-anchoring its cache.

It works. You see a brief text flash every 20 seconds, then DOOM is back with drift reset.

The multiplexer discovery

Trying different multiplexers side-by-side:

Multiplexer Drift TrueColor Notes
tmux (properly configured) gone yes re-parses ANSI into its own grid, re-emits everything
Zellij still drifts yes modern, TrueColor by default, doesn't normalize aggressively enough
Screen drifts and goes monochrome no worst of both
plain iTerm2 / Kitty / Ghostty / Alacritty drifts yes no protection

tmux is architecturally special for this use case. It parses all output into a canonical cell grid and re-synthesizes everything when emitting to the outer terminal. Ink's drifty cursor-up-and-clear instructions get absorbed; tmux emits its own clean state. Zellij and Screen nominally do the same but not aggressively enough.

Running Claude Code inside tmux made drift disappear entirely, with zero code changes on our end.

The undocumented Claude Code env var

tmux fixed drift but broke colors — DOOM came out in muted primary colors. Gradient tests showed tmux WAS passing TrueColor through correctly. The problem was Claude Code itself.

A user on GitHub had filed issue #46146: Claude Code contains a defensive 256-color clamp whenever $TMUX is set, specifically to guard against broken tmux configurations. The escape hatch is CLAUDE_CODE_TMUX_TRUECOLOR=1. Must be exported in the shell; settings.json is too late because the clamp runs at module load.

With that env var, Claude Code's own UI emits TrueColor, our DOOM frame still uses 256-palette (for drift resistance), and we get the best of both: vivid Claude TUI, drift-resistant DOOM.

The gamma fix

Final symptom: "walls and corridors are too dark, I can't tell what I'm looking at." Not a drift or palette issue — a dynamic-range issue.

DOOM's dark scenes use the entire RGB range 0–95 for shadow detail. Xterm's 256-color cube has only two levels per channel in that range (0 and 95). Everything darker than mid-gray collapses into palette index 0 (pitch black). Brick textures, corridor shading, enemy silhouettes in shadow — all collapsed into monochrome mud.

Fix: a gamma curve applied to pixel values before quantization, via a 256-entry LUT:

for (int i = 0; i < 256; i++) {
  GAMMA_LUT[i] = pow(i / 255.0, GAMMA_PRE) * 255.0;
}
Enter fullscreen mode Exit fullscreen mode

At GAMMA_PRE=0.7, pixel value 64 maps to 95. Now shadow-range pixels land on cube entries 58 (muted olive), 95 (dark red), rather than all snapping to 0. Hue information returns to the render. Corridors are legible again.

Zero extra bytes per frame (LUT is a table lookup, not per-frame work). No drift penalty.


The final ship config

# Plain iTerm2/Kitty/Ghostty/etc., no tmux:
/doom start --gamma 0.7

# That's it. All the internal mitigations (byte-stable cells, static
# black border, 256-palette default, fixed-width HUD, ordered dither,
# regex-anchor fix, halved frame rate) are on by default. Occasional
# drift under heavy damage-flash scenes is possible but rare.

# If you want stability GUARANTEED under any scene change, run Claude
# Code inside tmux:
# ~/.tmux.conf
set -g default-terminal "tmux-256color"
set -sa terminal-overrides ",xterm*:Tc"
set -sa terminal-overrides ",iterm*:Tc"
set-environment -g COLORTERM "truecolor"

# ~/.zshrc (to restore TrueColor for Claude's own UI inside tmux)
export CLAUDE_CODE_TMUX_TRUECOLOR=1
Enter fullscreen mode Exit fullscreen mode

The plain setup is good enough for most play. tmux is the bulletproof version. Every other fix I listed above is still in the code as supporting infrastructure — without them, even tmux wouldn't be enough.


Lessons transferable to anything Ink-hosted

  1. Byte stability is not render stability when your bytes pass through someone else's stateful renderer. We got our output to 97% byte-identical. Drift still happened.
  2. Test the paths you think are hit, not the paths you think you wrote. Half of round one was me byte-stabilizing the chafa code path while the user was running sextant.
  3. string-width is a surprisingly load-bearing library for any Ink-based TUI. Unicode 13 glyphs it doesn't recognize will silently break cell accounting. Stick to U+2580–U+259F for any terminal graphics work.
  4. Shared state files between multiple instances will bite you. 15 Claude Code instances racing a single tick-counter file diluted our periodic repaint to nothing until we keyed it per-claude-pid.
  5. Regex anchors on SGR parameters matter. (?<![0-9]) saved us from 38;5;2 being mis-parsed as a TrueColor RGB prefix.
  6. Not all terminal multiplexers are created equal. tmux is uniquely good at drift repair; Zellij and Screen aren't. If you write a terminal app and expect to run under Ink-heavy tools, tmux is the only multiplexer you can rely on.
  7. Tools you're running on top of have their own defensive assumptions. Claude Code's CLAUDE_CODE_TMUX_TRUECOLOR=1 is an env var the docs don't mention but whose presence is required if you want 24-bit color inside tmux. You find these by reading GitHub issues, not docs.
  8. "Palette too coarse" is often "dynamic range compressed into one bucket." When dark walls came out as monochrome mud, the answer wasn't a custom palette — it was applying a gamma curve so the pixel values occupied more of the cube's existing range. Stretch before you redefine.

Bonus — let Claude play it

A prompt that reliably gets Claude through E1M1, using the MCP tool surface:

You are playing DOOM. Finish E1M1 by reaching the exit switch.

Tools: doom_state (json), doom_look (png), doom_move (keys), doom_burst (sequences),
doom_wait (ticks), doom_macro (named macros).

Play loop:
1. doom_state + doom_look → perceive the scene
2. One paragraph: what you see, HP/ammo, next objective
3. ONE short action (≤6 keys)
4. doom_wait 20 ticks
5. Repeat

Combat: HP<30 + enemy → retreat. Strafe-fire imps. Corner-peek zombiemen.
Stop when state.map changes to 2, HP=0, stuck 60s, or 50 cycles no progress.
Don't call /doom stop. One-line progress every 10 cycles.
Start now.
Enter fullscreen mode Exit fullscreen mode

It works. The same Claude Code whose defensive rendering clamp we spent 10 hours working around is the one that plays through the level.


Repo

https://github.com/erkandogan/doom-in-claude-code

The installer sets up the statusline, hook, slash command, MCP server, and downloads Freedoom WADs. Tested on macOS; should work on Linux. brew install chafa tmux is the only dependency story.

Issues, PRs, questions welcome. Happy debugging.

Top comments (0)