cd /news/ai-agents/claude-code-mods-but-does-it-run-doo… · home › topics › ai-agents › article
[ARTICLE · art-145469] src=dev.to ↗ pub= topic=ai-agents verified=true sentiment=↑ positive

Claude Code mods: but does it run Doom?

A developer built a Claude Code mod that runs Freedoom on the doomgeneric engine inside a terminal pane beside the Claude transcript, spawning a native binary via $.process.spawn and communicating over a Unix socket or a token-protected localhost HTTP connection. The mod ships six prebuilt engines cross-compiled with zig cc, though only the Linux x86_64 build has actually been run, and it works around Claude Code's roughly three-frames-per-second redraw rate with a 30 ms heartbeat client.

by read10 min views1 publishedOct 5, 2026

Yes ... Yes it does.

The Minesweeper was the warm-up. A pane that can draw pixels and take the keyboard raises the only question the internet ever asks of a new screen, so the second game in the arcade is Doom: Freedoom on the doomgeneric engine, in a pane beside the transcript, while Claude works.

/doom opens the pane and starts the game. In kitty the screen is a real picture at Doom's own 320×200 (Ghostty speaks the same protocol, but I haven't tried it); in any other terminal it is drawn in quadrant block characters, four pixels a cell. Walk with w and s, turn with a and d or by dragging sideways on the game, fire with space or the right mouse button.

Getting a picture on screen took an afternoon. The controls took the rest of the week, because a terminal never tells you that a key was let go.

A mod's hooks run in a sandbox: no Node, no DOM, no require, and deliberately no WebAssembly. The typings say what to do instead: run the heavy thing as a program of its own. So the game is doomgeneric compiled to a native binary, and the mod starts it with $.process.spawn:

const child = $.process.spawn({ argv: engineArgs(binary, root, placement), env: token !== null ? { DOOM_CLAUDE_TOKEN: token } : {} })

A spawned child lives as long as the mod reads its output and ends when the mod unloads, so there is no daemon to clean up. The engine prints doom-claude listening once it is ready, and from then on the mod talks to it with $.http.fetch: over a Unix socket on Linux and macOS ( socketPath), and over 127.0.0.1 with a random 64-digit token on Windows, where every request without the token gets a 403.

There is no compiler on the player's machine to count on, so the arcade ships six prebuilt engines, cross-compiled with zig cc from one Linux box: Linux, macOS and Windows, each on x86_64 and arm64. Honest status: only the Linux x86_64 one has run. The macOS and Windows builds are compiled and checked (file says Mach-O and PE32+, the arm64 Mach-O carries its ad-hoc signature), and nobody has started one yet.

In kitty and Ghostty, Claude Code's Image element can take a file instead of bytes. The engine writes each frame as raw RGB to a file beside its socket (written aside, then renamed over the old one, so it is never read half-written), and the mod points the Image at it:

export function imageSource(path: string, generation: number): ImageSource {
  return { file: path, format: 'rgb', width: IMAGE.width, height: IMAGE.height, generation }
}

Claude Code hands kitty the path (a=T,…,t=f in the graphics protocol), kitty reads the pixels itself, and a new generation makes it read the file again. Not one pixel goes through Claude Code.

Every other terminal gets a Raster, and a Raster has opinions. It rounds every colour to 4 bits a channel. It paints at most 1024 colour pairs and snaps the rest, which speckles a photo-like picture. And it leaves blank cells at the end of a row undrawn, so a dark corridor came out with black holes in it. The engine pre-encodes each frame into cells: a 32-colour palette per scene (32 × 32 is 1024), each cell the quadrant glyph and the two colours that fit its four pixels best, a cell reusing its left neighbour's colours when they fit nearly as well, and no blank cells at all.

The first live run froze for 300 ms at a time. $.ui.blit repaints a Raster at Claude Code's next frame, and with nothing else on screen moving, Claude Code draws only about three frames a second. The game was sending 30.

The fix is a heartbeat: the red strip under the game is a Client, and it redraws itself every 30 ms, swapping one blank character for another. That keeps Claude Code drawing, and it costs far less than redrawing the whole pane from the hooks module. One more trap on the way: a blit resolves when it has been painted, not when it has been taken, so the frame loop must not await it before fetching the next frame.

On a machine with other work running (load 5–6 on 8 cores), the picture changed 35–40 times a second in a plain terminal and 31–32 times a second in kitty. The block picture costs Claude Code about a full core; the kitty picture about a third of one.

Doom wants to know when a key goes down and when it comes up. A terminal reports the press, then, after its repeat delay, the same press again every 30 ms or so, and never the release. Claude Code turns on kitty's keyboard protocol with flags 5 only, so a mod gets no release events and can't even tell a repeat from a new press. The terminal also repeats only the newest key: hold w, press a, and w goes quiet whether your finger is still on it or not.

So every terminal Doom fakes the release, and my first attempt was a guess. A turn key held for 220 ms, so a tap would be a small turn. Then the terminal's first repeat came at 500 ms, and a held turn key stopped dead for 308 ms before it started turning for real. I measured it by reading the player's facing out of the engine every 10 ms. Then I patched it, and patched the patch.

What fixed it was reading how the others do it:

port fakes the release how
doom-cli learns the terminal's repeat delay and rate per key; a fresh press holds until the first repeat is due, a repeat until the next is due
intermission 550 ms after a fresh press, 120 ms while repeating; turning is mouse-only
claude-doom a 160 ms pulse per press
doom-braille released after 100 ms with no bytes

doom-cli's model is the one that holds up, so the engine now does the same. A movement key holds until its next repeat is due. The repeat delay is learnt from the gap between a press and its first repeat (the median of the last five, capped at 600 ms), so it fits GNOME's 500 ms as well as anyone's faster setting:

static uint32_t freshHold(int key)
{
    return isMovement(key) ? s_RepeatDelay + DELAY_MARGIN_MS : OTHER_HOLD_MS;
}

That has a cost doom-cli's own source admits in a TODO: a tap now lasts as long as the repeat delay, so one tap of d turned 61.5°. The TODO also names the fix: "just turn more slowly outside of state repeat? so it's still possible to do some precision aiming". So a turn key in play doesn't press Doom's arrow key at all. It turns through Doom's mouse, slowly until the terminal starts repeating it, then at the arrow keys' full speed, with the same half-speed first six tics Doom gives a held key:

    if (!k->isRepeating)
    {
        s_KeyTurnTics = 0;
        return sign * TAP_TURN;
    }
    return sign * (s_KeyTurnTics++ < SLOW_TURN_TICS ? KEY_TURN / 2 : KEY_TURN);

A tap of d now turns 10.5°, about what a quick tap gives in Doom with a real keyboard, and a held d never stops for longer than 28 ms. In a menu, a or the title demo, the arrows stay arrow keys, because menu sliders want them.

A pointer is the one input that does report its release. So dragging sideways on the game turns, and letting go stops at once. It only turns: walking stays on w and s, and the keys and the mouse work together, so you can walk and turn at the same time, which the keyboard alone can't do.

The first version of the stick walked too, and turned up to 2.5 times faster than the keys the further you dragged. Now any sideways drag past a small dead zone turns exactly as fast as a and d:

export function stickOf(pad: Pad | null): Stick {
  if (!pad) return { turn: 0, forward: 0, buttons: 0 }
  const dx = pad.isHeld ? pad.dx : 0
  const turn = Math.abs(dx) > STICK.dead ? Math.sign(dx) * STICK.turn : 0
  const buttons = (pad.isFiring ? STICK.fire : 0) | (pad.isHeld && pad.isStrafing ? STICK.strafe : 0)
  return { turn, forward: 0, buttons }
}

80 is Doom's mouse turning at 640 units a tic, the same as a held arrow key, and the engine gives a drag the same half-speed start. A drag and a held d stayed within a tic of each other at every point I measured: 51.0° against 52.8° at half a second, 114.3° against 112.5° at one.

A Client gets the keyboard only after a click, and Escape always hands it back to Claude Code's prompt. No mod and no keybinding can take Escape: it never reaches the mod at all. So the menu is m or backspace. Before the first click, space and the digits land in the prompt too.

While you play, then, the prompt is Doom's. A prompt.edit hook takes game keys out of the prompt and hands them to the game, and drops the rest:

    state.isPromptOurs = true
    const keys = e.key ? [e.key] : Array.from(String(e.inputText ?? '')).map((ch) => ({ key: ch }))
    const codes = keys.map(doomKeyOf).filter((code) => code !== null)
    if (codes.length > 0) {
      state.gameInputAt = now
      state.queuedKeys.push(...codes)
    }
    state.promptCheckAt = now + PROMPT_CHECK_MS
    return { text: '', cursor: 0 }

Answering { text, cursor } without calling next consumes the key. The hook decides without a single $ call, because Claude Code sometimes lets the key through anyway when the hook is slow. Type / and the prompt is yours again at once, ten seconds without a game key gives it back too, and a draft you had typed before is never touched.

This makes Doom different from the Minesweeper in one way that matters: it hooks the prompt. claude plugin validate lists it before any of the mod's code runs:

❯ ./register.ts hooks: session.start, command.run{command=doom}, ui.render{component=Pane}, ui.message, prompt.edit, ui.close, session.end
❯ ./register.ts calls: $.clock.after (via fitPane, focusSoon, watchEngine), $.clock.every (via startPulling), $.command.register, $.env.get (via readEnv), $.fs.exists (via findEngine), $.fs.write (via watchEngine), $.http.fetch (via pullFrame, sendKeys, sendStick, stopEngine), $.process.run (via findEngine, startEngine), $.process.spawn (via startEngine), $.prompt.fill (via clearLeakedPrompt), $.prompt.read (via clearLeakedPrompt), $.session.surfaces, $.ui.blit (via paint), $.ui.close, $.ui.invalidate, $.ui.open (via fitPane, openPane), $.ui.resolve

It reads nothing the model sees and adds nothing to it, and its only network traffic is to its own engine on your machine.

The mod has 20 tests under claude plugin test, typechecked under the same strict config as the code. A review still found two bugs in them that a player would have hit in the first minute:

/doom again, and the hooks module forgot the numbers while the pane kept counting: the new engine got the last 24 keys again, menu and quit included. The first one is a lesson about the kit. The new test for it failed with the bug in place only once it stepped the frame clock one frame at a time: the kit delivers one post per ui.advance, so a two-second advance collapsed six re-sends into one and passed anyway.

In a Claude Code session:

/plugin marketplace add reporails/arcade
/plugin install doom@reporails-arcade

Then start Claude Code in the fullscreen layout, which the mouse needs, and run /doom:

CLAUDE_CODE_NO_FLICKER=1 claude

Mods load by default from Claude Code 2.1.287; on 2.1.285 and 2.1.286, add CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1. Run it in kitty for the real picture (Ghostty should work the same, untested); every other terminal gets the blocks, and so does Windows, where no terminal the mod detects speaks kitty's picture protocol. It has been played on Linux. If you run it on a Mac or on Windows, tell me how it went.

The mod's code is MIT, in reporails/arcade on GitHub. The engine is doomgeneric, GPL-2.0-or-later, and the game data is Freedoom, BSD. "Doom" is id Software's name; this plays Freedoom and contains nothing of id's.

── more in #ai-agents 4 stories · sorted by recency
── more on @claude code 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
→ Live at https://your-agent.zahid.host ✓
Get free account → Pricing
from €0/mo · no card required
LIVE [news/claude-code-mods-but…] indexed:0 read:10min 2026-10-05 · —