Doom in a Claude Code pane: Freedoom on the doomgeneric engine, a real picture in kitty and Ghostty and block characters elsewhere, played with the keys and…

Doom in a Claude Code pane. A mod, shipped as a plugin: /doom opens a pane and plays Freedoom on the doomgeneric engine while Claude works. In kitty or Ghostty the screen is a real picture at Doom's own 320×200; in any other terminal it is drawn in quadrant block characters, four pixels a cell. It reads nothing the model sees and adds nothing to it. Unlike the other arcade games it does hook the prompt box, and only while you play: game keys that land there go to Doom instead (type / to have it back). It runs the engine as a child process and talks to it on this machine only, over a Unix socket or 127.0.0.1.

It carries a prebuilt engine for Linux, macOS and Windows, each on x86_64 and arm64, so no compiler is needed. Only the Linux x86_64 engine has been run so far (see What has been verified).
In a Claude Code session:
/plugin marketplace add reporails/arcade
/plugin install doom@reporails-arcade
Then start Claude Code in the fullscreen layout and run /doom:
CLAUDE_CODE_NO_FLICKER=1 claude
On 2.1.285 or 2.1.286, where mods are early access, add CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1; from 2.1.287 mods load by default. It has been played on 2.1.285 only. From a checkout: claude --plugin-dir ./doom.
| Platform | Engine | Picture |
|---|---|---|
| Linux x86_64 / arm64 | engine/bin/linux-*/doom-claude, static (musl): any distribution | kitty or Ghostty: the picture; elsewhere blocks |
| macOS Apple silicon / Intel | engine/bin/macos-*/doom-claude | kitty or Ghostty: the picture; Terminal.app and iTerm2: blocks |
| Windows x86_64 / arm64 | engine/bin/windows-*/doom-claude.exe | blocks (no Windows terminal speaks kitty's picture protocol that the mod detects) |
| Anything else with a C compiler | built on first /doom with make | as above |
Under WSL, Claude Code is a Linux program and uses the Linux engine.
Run it in kitty or Ghostty for the full picture; the mod finds them by TERM / TERM_PROGRAM, and falls back to blocks if Claude Code refuses the picture anyway (as it does inside tmux). In any other terminal the screen's size follows the terminal's height: the dock is as tall as the space above the prompt, the screen is 3/8 as many rows as columns (Doom's 4:3), and the pane narrows to the screen's width so the transcript keeps the rest. On a 126×38 terminal that is about 72×27 to 85×32 cells, 144×54 to 170×64 pixels. More rows (a smaller font, or a taller window) give a sharper picture.
CLAUDE_CODE_NO_FLICKER=1 turns on the fullscreen layout: the pane docks on the right and takes clicks. Without it the pane sits above the prompt and Doom is played with the button keys alone.
| Input | What it does |
|---|---|
/doom | Opens the pane and starts Doom. If Doom is running, brings the pane back. |
/doom quit | Ends Doom and closes the pane. |
Esc | Gives the keyboard back to Claude Code's prompt (Claude Code keeps Escape for that; no mod can take it). Game keys you go on pressing still reach Doom: while you play, the mod takes them out of the prompt and hands them to the game. |
| Ctrl+X then X | Closes the pane, which ends Doom. |
/doom is an immediate command, so it works while Claude is mid-turn.
The mouse turns and the keys walk: a terminal tells when a mouse button goes down and when it comes up, which it never does for a key, so turning, which needs to stop exactly, is the mouse's job.
| Mouse, on the game | Doom |
|---|---|
| Hold the left button and drag left or right | Turn, exactly as fast as a and d (half speed for the first sixth of a second, as Doom turns a held key), however far you drag; an up or down drag does nothing |
| …with shift held | Strafe instead of turn |
| Let go | Stop turning, at once |
| Hold the right button | Fire, for as long as it is held |
The drag is measured from where the button went down, and keeps working past the edge of the game once the button is down. The red strip under the game takes the same drags and says what the stick is doing ("◉ turn right"). Walk with w and s while you drag: the keys and the mouse work at once. A click on the game or the strip also gives Doom the keyboard:
| Key | Doom |
|---|---|
| Space | Fire |
e | Use (doors, switches) |
1 to 7 | Weapons |
m or backspace | Menu |
| Return | Pick in a menu; yes to Doom's questions (quit, new game, nightmare) |
Arrows, w a s d | Move and turn, held the way a terminal allows (below) |
, . | Strafe |
| Tab | Map |
p | Pause |
y n | Yes (sends Return), no |
Esc | Hands the keyboard back to the prompt; game keys still reach Doom while you play (below) |
Without a click, the buttons under the strip answer their letters: m menu, o ok, w a s d, e use; and Return presses ok, which holds the pane's focus ring. Every other game key (space, the digits, , .) lands in Claude Code's prompt, and so does every key after Esc. So while you play (the game had input in the last 10 seconds), the prompt is Doom's (a prompt.edit hook): a game key that lands there goes to Doom, any other key is dropped, and nothing stays in the prompt. Type / to have it back at once (for /doom quit, or delete it and write to Claude), or leave the game alone for 10 seconds. A draft you had typed before is never touched. The arrows never reach Doom without a click: in the prompt they recall earlier prompts. m, then Return three times, starts a new game on the default skill. The engine makes Enter Doom's confirm key, so Return answers yes; n answers no. The mouse needs the fullscreen layout (CLAUDE_CODE_NO_FLICKER=1).
During the title demo any key opens the menu (Doom's own rule), and m (Escape) closes it again when it is open.
A terminal sends no key-up, only a press and then its auto-repeat (half a second later on GNOME, then every 30 ms), and it repeats only the newest key: once a is pressed while w is held, w goes quiet whether it is still held or not. So, as doom-cli does, a key counts as held until its next repeat is due: a press of a movement or turn key holds it until the first repeat could come (the terminal's repeat delay, which the engine learns from the repeats it sends, plus 60 ms), each repeat for 160 ms more, so a held key never stops and a key let go stops a sixth of a second after its last repeat. A turn key in play turns through Doom's mouse instead: slowly until the terminal repeats it, then as fast as Doom's arrow keys (half speed for their first sixth of a second, as Doom ramps a held key), so a tap turns about 10°, as a quick tap does in Doom with a real keyboard, and a held key never stops (doom-cli's own source suggests this: "just turn more slowly outside of state repeat"). In a menu, the title demo or a pause the turn keys stay keys, so menu sliders still take them. Fire and use hold 120 ms, so a tap is one shot. Movement keys take turns: pressing w a s d, an arrow, , or . lets go of every other movement key at once, so a turn key only turns and walking stops the moment you turn. Walking and turning together is a walking key plus a sideways drag of the mouse. Fire, use and weapons are held on their own and let go of nothing: space while walking fires and keeps walking until the walking key's hold runs out.
doom/
├── .claude-plugin/plugin.json
├── hooks/
│ ├── hooks.json points at register.ts
│ ├── register.ts the hooks: the command, the pane, the frame pull, the keys
│ ├── pad.ts the surface module: the strip that takes the keyboard
│ └── lib.ts pure functions: key map, screen size, URLs, engine arguments
├── engine/
│ ├── doomgeneric/ the doomgeneric engine, unchanged (GPL-2.0)
│ ├── doomgeneric_claude.c its platform layer: HTTP on a Unix socket or 127.0.0.1 instead of a window
│ ├── bin/<os>-<arch>/ prebuilt engines: linux, macos, windows × x86_64, arm64
│ ├── build-all.sh builds bin/ for every platform with zig cc
│ └── Makefile builds ./doom-claude for this machine; the mod runs it when no prebuilt engine fits
├── wad/freedoom1.wad Freedoom Phase 1, 0.13.0 (BSD; wad/COPYING.freedoom)
├── data/ made at first run: Doom's config, saves, engine.log (the engine's last run)
├── tests/doom.test.ts runs with `claude plugin test`
└── README.md
How it fits together:
/doom works out the machine (%OS% and %PROCESSOR_ARCHITECTURE% on Windows, uname -sm elsewhere), picks engine/bin/<os>-<arch>/doom-claude, falls back to one built here with make (never on Windows), and starts it with $.process.spawn. The engine runs for as long as the mod reads its output: Claude Code ends it when the mod unloads. The mod waits for the line doom-claude listening the engine prints once it listens; whatever the engine writes is kept, and written to data/engine.log when it ends, the last line shown in the pane if it ends badly.XDG_RUNTIME_DIR), else the user's temporary directory (TMPDIR, as on macOS), else data/: the first whose path fits a socket (about 100 bytes). On Windows, or when no path fits, the engine listens on 127.0.0.1 on a free port it prints (doom-claude listening port=N), and takes only requests carrying the X-Doom-Token header with a random 64-digit token the mod hands it in its environment (DOOM_CLAUDE_TOKEN); anything else is refused with 403. DOOM_CLAUDE_TRANSPORT=tcp makes Linux and macOS use 127.0.0.1 too, which is how that path was tried on Linux./image?since=…. When there is a newer frame the engine writes it as raw 320×200 RGB to a file in the same private directory as its socket (written beside it and renamed over it, so it is never read half-written) and answers with its number. The hooks module swaps the keyed Image to { file, format: 'rgb', width: 320, height: 200, generation }. Claude Code hands kitty the file's path and kitty reads the pixels itself, so no pixel passes through Claude Code. Ten refused swaps in a row (the Image showing its alt text) switch the screen to blocks./frame?c=…&r=… with $.http.fetch. The engine answers with the screen already encoded as Raster cells, and the hooks module blits that text onto the mounted Raster with $.ui.blit. A frame not newer than the last one painted (the engine counts a frame only when the picture changed) is answered with an empty 204.Raster paints at most 1024 colour pairs and snaps the rest to the nearest, which speckles the picture, and 32 by 32 is 1024. A cell takes its left neighbour's colours when they fit nearly as well, because Claude Code's paint gets slower the more often the colour changes along a row (measured: 12–14 frames a second with a 64-colour palette and no reuse, 20–35 with this, on the same loaded machine). No cell is a blank, because Claude Code leaves blanks at the end of a row undrawn.Client) is the only thing that gets the pointer, and it cannot draw a Raster or an Image. So a second instance of pad.ts lies over the screen in a position: "absolute" Box the screen's size, drawing nothing, so the picture shows through while it takes the pointer and the keys.pad.ts posts what it takes to the hooks module: the keys go to /key, the stick to /stick?t=…&f=0&b=… (it only turns, so its forward move is always 0). A surface module may post once a frame, and a later post replaces one not yet delivered, so each post carries the last 24 keys, numbered per instance (the hooks module keeps the ones it has not seen), and the stick as it is now. The engine posts the stick to Doom as one mouse event a tic, its sideways move turning (or strafing) and its forward move walking, its buttons held until the next /stick; a held stick is sent again every 300 ms, and one not heard of for 1.5 s is let go. The buttons send their key directly./doom quit and the session's end each send /quit, and end the engine's output stream should it not answer. The engine also exits on its own after 30 seconds with no request, so nothing is left running if Claude Code dies. A fatal error inside Doom prints and exits (the engine passes -nogui), rather than opening a dialog box nobody would see.The engine also answers /stats (frames drawn and served, keys taken, the longest wait between frames served, holds that ended while the key was still down, the keys down, the stick, the player's facing in degrees and the repeat delay learnt; ?reset=1 starts the counts again), which is how the figures below were measured.
Frame delivery, re-measured with all six engines rebuilt (Claude Code 2.1.285, the title demo, load 5–6 on 8 cores, other sessions running):
Since a turn key turns slowly until it repeats (the linux-x86_64 engine only so far):
d in a level turned 10.5° (61.5° before). Held 1.5 s with GNOME's timing it never stopped for longer than 28 ms: 9.0° at 0.5 s, 55.8° at 1 s, 136.6° at 1.5 s. On the title demo d still opened the menu, and with a menu open it turned the player 0°.Since keys hold until their next repeat is due, as doom-cli's do:
/stats about every 10 ms. With GNOME's timing (first repeat at 500 ms, then every 30 ms): held 1.5 s, it never stopped turning for longer than 26 ms and let go 160 ms after the last repeat; a tap turned 61.5°. With the old 220 ms turn hold the same held key stopped for 308 ms, and a tap turned 19.3°.a pressed lets w go; space while walking keeps both down), and claude plugin test passes 19 of 19.d (GNOME's repeat timing) turned 5.3° and 7.1° at 100 ms, 51.0° and 52.8° at 500 ms, 114.3° and 112.5° at 1 s: the same within a tic. A held w stayed down in 71 of 71 samples while the drag turned the view.Since the engine became a spawned child with prebuilt binaries:
claude plugin validate passes on 2.1.285, and claude plugin test passes 17 of 17: the platform and engine choice, where the engine listens, the listening line, the spawned engine's frames, the token on every request over 127.0.0.1, an engine that ends (the pane says so, data/engine.log keeps its output), one that cannot start (the pane shows its last line), the make fallback, and Windows with no engine.build-all.sh): static ELF for Linux, Mach-O for macOS, PE32+ console programs for Windows./stats and /image answer, the frame file is 192,000 bytes, /quit removes the socket and the file) and 127.0.0.1 (403 without the token or with a wrong one, 200 with it, an 80×30 frame is 38,400 bytes of base64; no port without a token: exit 2). A missing WAD exits at once (255, in 17 ms) with its message, and no dialog.linux-x86_64 engine: over the Unix socket the game drew in the pane, the engine ran as a child of claude (no fork), the m button reached it, Esc ended it (exit 0, socket and frame file gone, data/engine.log written). With DOOM_CLAUDE_TRANSPORT=tcp: the engine listened on 127.0.0.1 only, refused a request without the token, and served the pane's fetches. Killing claude with SIGKILL: the engine was gone a second later, its files removed.Before that change, on the daemon build:
claude plugin validate passes on 2.1.285./stats: w held then a held keeps w down (carried) the whole time a is held and lets both go 160 ms after; w held then a tapped keeps w down for 560 ms after the tap; s after w lets w go at once; w alone held has no gap.claude plugin test passes, 13 of 13, on 2.1.285 with the early-access switch. The tests cover the key map, the screen size, the key numbering across replaced posts, the engine's command line, the frame pull and blit, keys from the strip and the buttons, the engine going away, /doom quit, fitting the docked pane to the screen (and keeping that width), the picture in kitty (the engine's file, swapped generation by generation), the fall back to blocks when the picture is refused, the stick (its curve, and its drag, release and fire on the game and on the strip), and a surface with no terminal.script): Claude Code sent kitty the frame file by path (a=T,U=1,f=24,s=320,v=200,t=f,c=90,r=33 with /run/user/1000/claude-doom-….rgb). During the demo 33 frames a second were served and 34 picture swaps a second reached kitty (Doom runs at 35), the longest wait between frames 58 ms, and Claude Code used 7% of a core.TERM=xterm-kitty, Claude Code drew no picture and the mod fell back to blocks, as designed.TMUX unset and COLORTERM=truecolor, which is how the sessions above were run.m. Holds are timed: a movement or turn key holds from a press until the terminal's first repeat is due (its repeat delay, learnt, at most 600 ms, plus 60 ms), anything else 120 ms, and each repeat extends the hold by 160 ms, so a key let go stops within about a sixth of a second; a turn key turns slowly until its repeats come, so a tap turns about 10° and a held turn takes half a second to reach full speed.Image is untried.hooks/register.ts 721 lines1// doom: Doom in a pane.
2//
3// The game runs in a process of its own (engine/doom-claude, doomgeneric with
4// Freedoom), prebuilt per platform under engine/bin, run with $.process.spawn
5// for as long as the session lasts, and talked to over a Unix socket (Linux,
6// macOS) or 127.0.0.1 (Windows). The hooks here start it, pull each new frame
7// as Raster cells and blit them onto the screen, and hand it the keys the
8// strip (pad.js) and the buttons take. No hook touches what the model reads.
9//
10// The host reads on(...) and $.noun.method(...) from source, so they are
11// spelled literally, and helpers that take $ are top-level functions here.
12
13import type { EngineInterface, HookStream, ProcessSpawnChunk, ProcessSpawnResult, Register, Timer } from 'claude-code'
14
15import type { PadProps } from './pad'
16import {
17 BUTTONS,
18 GAME_KEYS_MS,
19 IMAGE,
20 blankCells,
21 cellsLength,
22 connectionOf,
23 drawsImages,
24 engineArgs,
25 engineCandidates,
26 frameUrl,
27 doomKeyOf,
28 freshKeys,
29 imageSource,
30 promptKeyRoute,
31 imageUrl,
32 keyUrl,
33 listeningOf,
34 newToken,
35 padOf,
36 paneRequest,
37 parseArgs,
38 placementOf,
39 platformOf,
40 quitUrl,
41 sameStick,
42 screenSize,
43 stickOf,
44 stickText,
45 stickUrl,
46} from './lib'
47import type { Connection, Platform, Size, Stick } from './lib'
48
49type Status = 'off' | 'building' | 'starting' | 'running' | 'exited'
50type Child = HookStream<ProcessSpawnChunk, ProcessSpawnResult>
51type Env = {
52 OS: string | undefined
53 PROCESSOR_ARCHITECTURE: string | undefined
54 PROCESSOR_ARCHITEW6432: string | undefined
55 XDG_RUNTIME_DIR: string | undefined
56 TMPDIR: string | undefined
57 TERM: string | undefined
58 TERM_PROGRAM: string | undefined
59 DOOM_CLAUDE_TRANSPORT: string | undefined
60}
61
62const PANE = 'doom'
63const SCREEN = 'screen'
64const PAD = 'pad'
65const LOOK = 'look'
66const FRAME_MS = 28
67const FOCUS_DELAY_MS = 250
68const START_TIMEOUT_MS = 15000
69const FIT_SLACK = 2
70const IMAGE_REFUSALS = 10
71const BUILD_TIMEOUT_MS = 600000
72const LOG_MAX = 65536
73const PROMPT_CHECK_MS = 250
74const NO_SCREEN = 'Doom draws its screen in the terminal only.'
75
76// The game as the hooks know it. In memory; the engine holds the rest.
77const state: {
78 status: Status
79 note: string
80 mode: 'image' | 'cells'
81 imagePath: string | null
82 imageFrame: number | null
83 imageRefusals: number
84 conn: Connection | null
85 child: Child | null
86 run: number
87 size: Size
88 frame: number
89 cells: string | null
90 cellsSize: Size | null
91 timer: Timer | null
92 isPulling: boolean
93 isPainting: boolean
94 isPaintWaiting: boolean
95 seenKeys: Record<string, number>
96 stick: Stick | null
97 stickText: string | null
98 isArmed: boolean
99 isStickSending: boolean
100 stickWaiting: Stick | null
101 fittedTo: number | null
102 gameInputAt: number
103 queuedKeys: number[]
104 promptCheckAt: number
105 isPromptOurs: boolean
106} = {
107 status: 'off',
108 note: '',
109 // `image`: the screen is an Image the terminal reads from the engine's file
110 // (kitty, Ghostty); `cells`: a Raster of quadrant blocks, anywhere else.
111 mode: 'cells',
112 imagePath: null,
113 imageFrame: null,
114 imageRefusals: 0,
115 // How to reach the engine (connectionOf), null while none runs; the
116 // spawned engine's stream, and which start it belongs to.
117 conn: null,
118 child: null,
119 run: 0,
120 size: { columns: 120, rows: 45 },
121 frame: -1,
122 cells: null,
123 cellsSize: null,
124 timer: null,
125 isPulling: false,
126 isPainting: false,
127 isPaintWaiting: false,
128 seenKeys: {},
129 stick: null,
130 stickText: null,
131 isArmed: false,
132 isStickSending: false,
133 stickWaiting: null,
134 fittedTo: null,
135 // When the game last had input: a key, a click or drag, a button.
136 gameInputAt: 0,
137 // Keys taken out of the prompt, for the frame pull to send, and when to
138 // look at the prompt once more for one that slipped in at the end.
139 queuedKeys: [],
140 promptCheckAt: 0,
141 // Whether the prompt is Doom's: empty when play took it, holding nothing of
142 // the person's since.
143 isPromptOurs: false,
144}
145
146export const register: Register = (on) => {
147 on('session.start', async ($, e, next) => {
148 const result = await next(e)
149 try {
150 await $.command.register({
151 name: 'doom',
152 description: 'Play Doom (Freedoom) in a pane',
153 argumentHint: '[quit]',
154 immediate: true,
155 })
156 } catch {
157 // The name is taken; nothing else opens the pane.
158 }
159 return result
160 })
161
162 on('command.run', { command: 'doom' }, async ($, e) => {
163 const asked = parseArgs(e.args)
164 if ('error' in asked) return { text: asked.error }
165 if (asked.action === 'quit') {
166 await stopEngine($)
167 try {
168 await $.ui.close({ id: PANE })
169 } catch {
170 // Not open.
171 }
172 return { text: 'Doom has quit.' }
173 }
174 const surfaces = await $.session.surfaces()
175 if (!surfaces.includes('terminal')) return { text: NO_SCREEN }
176 await openPane($)
177 focusSoon($)
178 if (state.status !== 'running' && state.status !== 'starting' && state.status !== 'building') {
179 void startEngine($)
180 }
181 return {}
182 })
183
184 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
185 if (e.requestId !== PANE) return next(e)
186 const elements = $.ui.resolve(e)
187 // The surface decides; the `in` checks only tell the types what it decided.
188 if (e.surface !== 'terminal' || !('Raster' in elements) || !('Image' in elements) || !('Client' in elements)) {
189 return elements.Text({ children: [NO_SCREEN] })
190 }
191 const { Box, Text, Button, Raster, Image, Client } = elements
192 const props = e.props
193 const size = screenSize(props.bodyColumns, props.scroll.bodyRows, state.mode === 'image' ? IMAGE.maxColumns : undefined)
194 if (size.columns !== state.size.columns || size.rows !== state.size.rows) {
195 state.size = size
196 state.frame = -1
197 }
198 // A dock wider than the screen the height allows: ask for the width the
199 // screen needs, once per size, so the transcript keeps the rest.
200 if (props.placement === 'dock' && props.bodyColumns > size.columns + FIT_SLACK && state.fittedTo !== size.columns) {
201 state.fittedTo = size.columns
202 fitPane($, size.columns)
203 }
204 const isShown = state.cells !== null && state.cellsSize !== null && state.cellsSize.columns === size.columns && state.cellsSize.rows === size.rows
205 const buttons = BUTTONS.map((b) =>
206 Button({
207 key: 'key-' + b.hotkey,
208 label: b.label,
209 hotkey: b.hotkey,
210 plain: true,
211 dimColor: true,
212 ...(b.autoFocus ? { autoFocus: true } : {}),
213 onPress: () => {
214 playing()
215 return sendKeys($, [b.code])
216 },
217 }),
218 )
219 return Box({
220 flexDirection: 'column',
221 children: [
222 // The screen, and over it the pointer's layer, which draws nothing.
223 Box({
224 width: size.columns,
225 height: size.rows,
226 children: [
227 state.mode === 'image'
228 ? state.imageFrame === null
229 ? Box({ height: size.rows, width: size.columns, children: [Text({ dimColor: true, children: ['Starting Doom…'] })] })
230 : Image({ key: SCREEN, source: imageSource(state.imagePath ?? '', state.imageFrame), columns: size.columns, rows: size.rows, alt: 'Doom' })
231 : Raster({ key: SCREEN, columns: size.columns, rows: size.rows, cells: isShown && state.cells !== null ? state.cells : blankCells(size.columns, size.rows) }),
232 Box({
233 position: 'absolute',
234 top: 0,
235 left: 0,
236 width: size.columns,
237 height: size.rows,
238 children: [Client({ key: LOOK, module: './pad.ts', props: { role: 'look' } satisfies PadProps, width: size.columns, height: size.rows })],
239 }),
240 ],
241 }),
242 Client({ key: PAD, module: './pad.ts', props: { role: 'strip', status: statusLine(), stick: state.stickText, isArmed: state.isArmed } satisfies PadProps }),
243 Box({ flexDirection: 'row', columnGap: 2, children: buttons }),
244 ],
245 })
246 })
247
248 // The strip posts the keys it took.
249 on('ui.message', async ($, e, next) => {
250 const result = await next(e)
251 if (e.requestId !== PANE || (e.element !== PAD && e.element !== LOOK)) return result
252 playing()
253 // Each instance numbers its own keys.
254 const fresh = freshKeys(e.data, state.seenKeys[e.element] ?? 0)
255 state.seenKeys[e.element] = fresh.seen
256 if (fresh.codes.length > 0) await sendKeys($, fresh.codes)
257 if (!state.isArmed) {
258 state.isArmed = true
259 $.ui.invalidate('ui.render')
260 }
261 const pad = padOf(e.data)
262 if (pad !== null) {
263 const text = stickText(pad)
264 if (text !== state.stickText) {
265 state.stickText = text
266 $.ui.invalidate('ui.render')
267 }
268 const stick = stickOf(pad)
269 // Held, it is sent again now and then, so the engine keeps it.
270 if (!sameStick(stick, state.stick) || stick.turn || stick.forward || stick.buttons) await sendStick($, stick)
271 }
272 return result
273 })
274
275 // Keys that land in Claude Code's prompt while Doom is played (after Esc,
276 // or before a click) are Doom's (promptKeyRoute). Decided with no call on
277 // $: the editor waits on this answer, and a slow answer is a key let
278 // through. The frame pull sends the queued keys.
279 on('prompt.edit', async (_$, e, next) => {
280 const now = Date.now()
281 const route = promptKeyRoute({
282 isRunning: state.status === 'running' && state.conn !== null,
283 text: e.text,
284 isOurs: state.isPromptOurs,
285 msSinceGame: now - state.gameInputAt,
286 typed: e.key ? e.key.key : String(e.inputText ?? ''),
287 hasModifier: Boolean(e.key && (e.key.ctrl || e.key.meta)),
288 })
289 if (route === 'pass') {
290 state.isPromptOurs = false
291 return next(e)
292 }
293 if (route === 'release') {
294 // The `/` goes into an empty prompt, whatever play left there.
295 state.isPromptOurs = false
296 state.gameInputAt = 0
297 return next({ ...e, text: '', cursor: 0, start: 0, end: 0 })
298 }
299 state.isPromptOurs = true
300 const keys = e.key ? [e.key] : Array.from(String(e.inputText ?? '')).map((ch) => ({ key: ch }))
301 const codes = keys.map(doomKeyOf).filter((code) => code !== null)
302 if (codes.length > 0) {
303 state.gameInputAt = now
304 state.queuedKeys.push(...codes)
305 }
306 state.promptCheckAt = now + PROMPT_CHECK_MS
307 return { text: '', cursor: 0 }
308 })
309
310 on('ui.close', async ($, e, next) => {
311 if (e.id === PANE) await stopEngine($)
312 return next(e)
313 })
314
315 on('session.end', async ($, e, next) => {
316 await stopEngine($)
317 return next(e)
318 })
319}
320
321function statusLine(): string {
322 if (state.status === 'building') return 'Building the engine (first run only, about ten seconds)…'
323 if (state.status === 'starting') return 'Starting Doom…'
324 if (state.status === 'exited') return state.note || 'Doom has quit. /doom starts it again.'
325 if (state.status === 'off') return 'Doom is not running. /doom starts it.'
326 return 'Freedoom 0.13 · doomgeneric · /doom quit ends it'
327}
328
329// A key that slipped into the prompt last, with no key after it to take it
330// back out: clear it, while the prompt is still Doom's. Not sent on: that
331// late, a move would outlast the key.
332async function clearLeakedPrompt($: EngineInterface): Promise<void> {
333 if (!state.isPromptOurs || Date.now() - state.gameInputAt >= GAME_KEYS_MS) return
334 try {
335 const box = await $.prompt.read()
336 if (box.text !== '' && !box.text.startsWith('/')) await $.prompt.fill({ text: '' })
337 } catch {
338 // No box (a -p run): nothing to clear.
339 }
340}
341
342// The game had input (a key, a click or drag, a button press).
343function playing(): void {
344 state.gameInputAt = Date.now()
345}
346
347function setStatus($: EngineInterface, status: Status, note?: string): void {
348 state.status = status
349 state.note = note ?? ''
350 $.ui.invalidate('ui.render')
351}
352
353// Open the pane, or bring the open one forward, with the keyboard if it can;
354// at the width it was fitted to, once it has been.
355async function openPane($: EngineInterface): Promise<void> {
356 const size = paneRequest()
357 try {
358 // No closeOnEscape: Esc is Doom's menu key by habit, and with it a second
359 // Esc would close the pane and end the game. /doom quit or Ctrl+X X close it.
360 await $.ui.open({ id: PANE, title: 'Doom', focus: true, rows: size.rows, columns: state.fittedTo ?? size.columns })
361 } catch {
362 // Refused; the pane stays as it was.
363 }
364 $.ui.invalidate('ui.render')
365}
366
367function fitPane($: EngineInterface, columns: number): void {
368 try {
369 $.clock.after(0, async () => {
370 try {
371 await $.ui.open({ id: PANE, title: 'Doom', columns })
372 } catch {
373 // Refused; the pane keeps its width.
374 }
375 })
376 } catch {
377 // No timer; the pane keeps its width.
378 }
379}
380
381// A pane takes the keyboard only over an empty prompt, and the command's own
382// text is still in it while the command runs: ask again once it has returned.
383function focusSoon($: EngineInterface): void {
384 try {
385 $.clock.after(FOCUS_DELAY_MS, () => void openPane($))
386 } catch {
387 // No timer; a click on the pane gives it the keyboard.
388 }
389}
390
391// Find the engine for this machine (building it when there is none), start
392// it, wait for the line that says it listens, and pull its frames.
393async function startEngine($: EngineInterface): Promise<void> {
394 const root = $.plugin.root
395 const run = ++state.run
396 try {
397 const env = await readEnv($)
398 let uname: string | undefined
399 if (env.OS !== 'Windows_NT') {
400 try {
401 uname = (await $.process.run(['uname', '-sm'])).stdout
402 } catch {
403 // No uname: only an engine built here will do.
404 }
405 }
406 const platform = platformOf({ os: env.OS, processorArch: env.PROCESSOR_ARCHITECTURE, processorArch6432: env.PROCESSOR_ARCHITEW6432, uname })
407 const binary = await findEngine($, root, platform)
408 if (binary === null) return
409 if (run !== state.run) return
410
411 setStatus($, 'starting')
412 const placement = placementOf({
413 platform,
414 runtimeDir: env.XDG_RUNTIME_DIR,
415 tmpDir: env.TMPDIR,
416 dataDir: `${root}/data`,
417 id: Math.floor(Math.random() * 1e9).toString(36),
418 prefer: env.DOOM_CLAUDE_TRANSPORT,
419 })
420 const token = placement.transport === 'tcp' ? newToken() : null
421 const child = $.process.spawn({ argv: engineArgs(binary, root, placement), env: token !== null ? { DOOM_CLAUDE_TOKEN: token } : {} })
422 state.child = child
423 const listening = await watchEngine($, child, run, root)
424 if (run !== state.run) return
425 if (listening === null) return
426 state.conn = connectionOf(placement.transport, { socketPath: placement.socketPath, port: listening.port, token })
427 state.imagePath = placement.imagePath
428 state.imageFrame = null
429 state.mode = drawsImages(env.TERM, env.TERM_PROGRAM) ? 'image' : 'cells'
430 state.frame = -1
431 state.stick = null
432 state.stickText = null
433 // The game is in front of the person now: their keys are for it.
434 state.isPromptOurs = false
435 playing()
436 setStatus($, 'running')
437 startPulling($)
438 } catch (error) {
439 if (run === state.run) setStatus($, 'exited', 'Doom did not start: ' + messageOf(error))
440 }
441}
442
443// The variables startEngine reads, each undefined when unset or unread.
444// $.env.get takes its name as a literal: the host reads them off the source.
445async function readEnv($: EngineInterface): Promise<Env> {
446 const [OS, PROCESSOR_ARCHITECTURE, PROCESSOR_ARCHITEW6432, XDG_RUNTIME_DIR, TMPDIR, TERM, TERM_PROGRAM, DOOM_CLAUDE_TRANSPORT] = await Promise.all([
447 $.env.get('OS').catch(() => undefined),
448 $.env.get('PROCESSOR_ARCHITECTURE').catch(() => undefined),
449 $.env.get('PROCESSOR_ARCHITEW6432').catch(() => undefined),
450 $.env.get('XDG_RUNTIME_DIR').catch(() => undefined),
451 $.env.get('TMPDIR').catch(() => undefined),
452 $.env.get('TERM').catch(() => undefined),
453 $.env.get('TERM_PROGRAM').catch(() => undefined),
454 $.env.get('DOOM_CLAUDE_TRANSPORT').catch(() => undefined),
455 ])
456 return { OS, PROCESSOR_ARCHITECTURE, PROCESSOR_ARCHITEW6432, XDG_RUNTIME_DIR, TMPDIR, TERM, TERM_PROGRAM, DOOM_CLAUDE_TRANSPORT }
457}
458
459// The first engine that is there for this machine; one built by `make` when
460// none is (not on Windows, which has no compiler to count on). Null, with the
461// pane saying why, when there is none.
462async function findEngine($: EngineInterface, root: string, platform: Platform): Promise<string | null> {
463 const candidates = engineCandidates(root, platform)
464 for (const path of candidates) {
465 if (await $.fs.exists(path)) {
466 if (platform.os !== 'windows') {
467 // An install that dropped the execute bit would refuse to run it.
468 await $.process.run(['chmod', '+x', path]).catch(() => undefined)
469 }
470 return path
471 }
472 }
473 const where = platform.os && platform.arch ? `${platform.os}-${platform.arch}` : 'this machine'
474 if (platform.os === 'windows') {
475 setStatus($, 'exited', `No engine for ${where}: engine/bin/ has none.`)
476 return null
477 }
478 setStatus($, 'building')
479 const built = await $.process.run(['make', '-C', `${root}/engine`], { timeoutMs: BUILD_TIMEOUT_MS })
480 if (built.exitCode !== 0) {
481 setStatus($, 'exited', `No engine for ${where}, and it did not build: ` + lastLine(built.stderr || built.stdout))
482 return null
483 }
484 return candidates.at(-1) ?? null
485}
486
487// Read the engine's output for as long as it runs. Resolves with
488// listeningOf's `{ port }` once the engine listens, or null when it ends or
489// stays silent past START_TIMEOUT_MS first; when it ends, the pane says so
490// and data/engine.log keeps what it wrote.
491function watchEngine($: EngineInterface, child: Child, run: number, root: string): Promise<{ port: number | null } | null> {
492 return new Promise((resolve) => {
493 let isSettled = false
494 const settle = (value: { port: number | null } | null): void => {
495 if (isSettled) return
496 isSettled = true
497 resolve(value)
498 }
499 let timer: Timer | null = null
500 try {
501 timer = $.clock.after(START_TIMEOUT_MS, () => {
502 if (isSettled) return
503 settle(null)
504 if (run === state.run) setStatus($, 'exited', 'Doom did not start: the engine did not answer.')
505 child.return({ code: null, signal: null }).catch(() => undefined)
506 })
507 } catch {
508 // No timer: the engine's own exit still ends the wait.
509 }
510 void (async () => {
511 let output = ''
512 let ending: ProcessSpawnResult | null = null
513 try {
514 for (;;) {
515 const step = await child.next()
516 if (step.done) {
517 ending = step.value
518 break
519 }
520 output = (output + step.value.text).slice(-LOG_MAX)
521 if (!isSettled) {
522 const listening = listeningOf(output)
523 if (listening !== null) {
524 if (timer) timer.cancel()
525 settle(listening)
526 }
527 }
528 }
529 } catch (error) {
530 output += `\n${messageOf(error)}\n`
531 }
532 if (timer) timer.cancel()
533 const wasListening = isSettled
534 settle(null)
535 const how = ending ? (ending.signal ? `signal ${ending.signal}` : `exit code ${ending.code}`) : 'did not run'
536 try {
537 await $.fs.write(`${root}/data/engine.log`, output + `[doom-claude ended: ${how}]\n`)
538 } catch {
539 // No log; the pane still says how it ended.
540 }
541 if (run !== state.run || state.child !== child) return
542 state.child = null
543 stopPulling()
544 state.conn = null
545 state.cells = null
546 const isClean = ending && ending.code === 0 && !ending.signal
547 if (!wasListening) setStatus($, 'exited', 'Doom did not start: ' + lastLine(output))
548 else setStatus($, 'exited', isClean ? '' : `Doom stopped (${how}): ` + lastLine(output))
549 })()
550 })
551}
552
553function lastLine(text: string | undefined): string {
554 const lines = String(text ?? '').trim().split('\n')
555 return lines[lines.length - 1] || 'no output'
556}
557
558function startPulling($: EngineInterface): void {
559 if (state.timer !== null) state.timer.cancel()
560 state.timer = $.clock.every(FRAME_MS, () => void pullFrame($))
561}
562
563function stopPulling(): void {
564 if (state.timer !== null) state.timer.cancel()
565 state.timer = null
566}
567
568// Fetch the newest frame, if there is one since the last, and paint it.
569async function pullFrame($: EngineInterface): Promise<void> {
570 if (state.queuedKeys.length > 0 && state.conn !== null) {
571 void sendKeys($, state.queuedKeys.splice(0))
572 }
573 if (state.promptCheckAt !== 0 && Date.now() >= state.promptCheckAt) {
574 state.promptCheckAt = 0
575 void clearLeakedPrompt($)
576 }
577 if (state.isPulling || state.conn === null) return
578 state.isPulling = true
579 const size = state.size
580 const conn = state.conn
581 try {
582 if (state.mode === 'image') {
583 // The engine writes the frame to its file; the terminal reads it there.
584 const response = await $.http.fetch(imageUrl(conn.base, state.frame), conn.init)
585 if (response.status === 200) {
586 state.frame = Number(response.headers['x-frame'] ?? -1)
587 if (state.imageFrame === null) {
588 // The first frame mounts the Image; blits swap it from then on.
589 state.imageFrame = state.frame
590 $.ui.invalidate('ui.render')
591 } else {
592 paint($)
593 }
594 }
595 } else {
596 const response = await $.http.fetch(frameUrl(conn.base, size, state.frame), conn.init)
597 // A frame for another size (the pane was resized under the request) waits for the next.
598 if (response.status === 200 && response.text.length === cellsLength(size)) {
599 state.frame = Number(response.headers['x-frame'] ?? -1)
600 state.cells = response.text
601 state.cellsSize = size
602 paint($)
603 }
604 }
605 } catch {
606 // The engine is gone: Doom's own Quit, or it stopped. The watch on its
607 // output says how, once its stream ends.
608 stopPulling()
609 state.conn = null
610 state.cells = null
611 if (state.status === 'running') setStatus($, 'exited')
612 } finally {
613 state.isPulling = false
614 }
615}
616
617// Paint the newest frame: swap the Image to it, or blit its cells. A blit
618// resolves once Claude Code has painted it, so one is in flight at a time and
619// a frame that arrives meanwhile waits for it (a newer one replacing it); the
620// next fetch does not wait for the paint. The strip (pad.js) keeps Claude
621// Code drawing frames.
622function paint($: EngineInterface): void {
623 if (state.isPainting) {
624 state.isPaintWaiting = true
625 return
626 }
627 const isImage = state.mode === 'image'
628 const cells = state.cells
629 const cellsSize = state.cellsSize
630 if (isImage ? state.imageFrame === null || state.imagePath === null : cells === null || cellsSize === null) return
631 state.isPainting = true
632 const blit =
633 isImage || cells === null || cellsSize === null
634 ? $.ui.blit({ requestId: PANE, key: SCREEN, source: imageSource(state.imagePath ?? '', state.frame) })
635 : $.ui.blit({ requestId: PANE, key: SCREEN, cells, columns: cellsSize.columns, rows: cellsSize.rows })
636 blit
637 .then((result) => {
638 // Refused swap after swap: the terminal draws no pictures after all
639 // (the Image shows its alt). Back to the cells. One refusal alone can be
640 // a pane hidden for a moment.
641 if (!isImage) return
642 state.imageRefusals = 'deny' in result && result.deny ? state.imageRefusals + 1 : 0
643 if (state.imageRefusals >= IMAGE_REFUSALS) {
644 state.mode = 'cells'
645 state.imageFrame = null
646 state.imageRefusals = 0
647 state.frame = -1
648 $.ui.invalidate('ui.render')
649 }
650 })
651 .catch(() => undefined)
652 .finally(() => {
653 state.isPainting = false
654 if (state.isPaintWaiting) {
655 state.isPaintWaiting = false
656 paint($)
657 }
658 })
659}
660
661async function sendKeys($: EngineInterface, codes: readonly number[]): Promise<void> {
662 if (state.conn === null) return
663 try {
664 await $.http.fetch(keyUrl(state.conn.base, codes), state.conn.init)
665 } catch {
666 // The frame pull notices the engine is gone.
667 }
668}
669
670// One stick request at a time; the newest waits for it, replacing any older.
671async function sendStick($: EngineInterface, stick: Stick): Promise<void> {
672 if (state.conn === null) return
673 if (state.isStickSending) {
674 state.stickWaiting = stick
675 return
676 }
677 state.isStickSending = true
678 try {
679 await $.http.fetch(stickUrl(state.conn.base, stick), state.conn.init)
680 state.stick = stick
681 } catch {
682 // The frame pull notices the engine is gone.
683 } finally {
684 state.isStickSending = false
685 }
686 const next = state.stickWaiting
687 state.stickWaiting = null
688 if (next !== null) await sendStick($, next)
689}
690
691// Ask the engine to quit, and end its stream, which ends the process
692// should it not answer. A later start is a new run: the old run's watch then
693// leaves the pane alone.
694async function stopEngine($: EngineInterface): Promise<void> {
695 stopPulling()
696 state.run++
697 const conn = state.conn
698 const child = state.child
699 state.conn = null
700 state.child = null
701 state.cells = null
702 state.status = 'off'
703 // The pane's surface modules end with it and count their keys from 1 again
704 // when it reopens. While it stays open (an engine that ended by itself and
705 // is started again) they go on counting, so their counts are kept.
706 state.seenKeys = {}
707 if (conn !== null) {
708 try {
709 await $.http.fetch(quitUrl(conn.base), conn.init)
710 return
711 } catch {
712 // Not answering: end its stream instead.
713 }
714 }
715 if (child !== null) child.return({ code: null, signal: null }).catch(() => undefined)
716}
717
718function messageOf(error: unknown): string {
719 return error instanceof Error ? error.message : String(error)
720}
721hooks/pad.ts 165 lines1// doom: the surface module taking the pointer and the keys. Two instances:
2// `look`, laid over the screen and drawing nothing there, so the mouse works
3// on the game itself (a surface module cannot draw the screen, so it lies on
4// top of it); and `strip`, the red line under it, which says what the stick is
5// doing. A click on either gives it the keyboard; every key it takes and the
6// stick go to the hooks module, which hands them to the engine.
7
8import type { ClientKeyEvent, ClientModule, ClientPointerEvent, ClientSurface } from 'claude-code'
9
10import { doomKeyOf } from './lib'
11import type { Pad } from './lib'
12
13// What the hooks module hands each instance: the layer over the screen needs
14// nothing; the strip shows the status line, the stick in words, and whether
15// it has been clicked yet.
16export type PadProps = { role: 'look' } | { role: 'strip'; status: string; stick: string | null; isArmed: boolean }
17
18type Mode = 'idle' | 'armed' | 'playing'
19type PadState = { mode: Mode; beat: 0 | 1 }
20
21// How many recent keys each post carries. A post replaces one not yet
22// delivered, so each carries the last few, numbered, and the hooks module
23// keeps the ones it has not seen.
24const RECENT = 24
25
26// Claude Code paints a blitted Raster at its next frame, and with nothing
27// else moving it draws a frame only about three times a second. The strip
28// redraws itself PUMP_MS apart (a blank that alternates between two blank
29// characters) so every frame the engine sends gets painted. That costs far
30// less than redrawing the whole pane from the hooks module.
31const PUMP_MS = 30
32const BEATS = [' ', '⠀'] as const
33
34// While the stick is held, say so again this often (in beats), so the
35// engine, which lets a stick go when it hears nothing for 1.5 s, keeps it.
36// The strip says so on its heartbeat; the layer over the screen, which has no
37// heartbeat, on a timer of its own that redraws nothing.
38const RESEND_BEATS = 10
39const RESEND_MS = PUMP_MS * RESEND_BEATS
40
41type Point = { x: number; y: number }
42type Keys = { n: number; recent: { n: number; code: number }[]; stick: Pad; anchor: Point | null; beats: number }
43
44// Each instance's key count, recent keys and stick. Not state: they redraw
45// nothing by themselves.
46const pads = new WeakMap<ClientSurface<PadState>, Keys>()
47
48function padOf(surface: ClientSurface<PadState>): Keys {
49 let pad = pads.get(surface)
50 if (pad === undefined) {
51 pad = { n: 0, recent: [], stick: { dx: 0, dy: 0, isHeld: false, isFiring: false, isStrafing: false }, anchor: null, beats: 0 }
52 pads.set(surface, pad)
53 }
54 return pad
55}
56
57// Everything the strip has to say in one post: a post replaces one not yet
58// delivered, so keys and stick travel together.
59function send(surface: ClientSurface<PadState>, pad: Keys): void {
60 surface.post({ type: 'keys', keys: pad.recent, stick: pad.stick })
61}
62
63// Where the pointer is, to the fraction of a cell where the terminal says.
64function where(e: ClientPointerEvent): Point {
65 return e.fine ? { x: e.fine.x, y: e.fine.y } : { x: e.x + 0.5, y: e.y + 0.5 }
66}
67
68function stateOf(surface: ClientSurface<PadState>): PadState {
69 return surface.state ?? { mode: 'idle', beat: 0 }
70}
71
72function typed(surface: ClientSurface<PadState>, e: ClientKeyEvent): void {
73 const code = doomKeyOf(e)
74 if (code === null) return
75 const pad = padOf(surface)
76 pad.n += 1
77 pad.recent.push({ n: pad.n, code })
78 if (pad.recent.length > RECENT) pad.recent.shift()
79 send(surface, pad)
80 const state = stateOf(surface)
81 if (state.mode !== 'playing') surface.setState({ ...state, mode: 'playing' })
82}
83
84// The left button holds the stick where it went down; the right fires.
85function pointed(surface: ClientSurface<PadState>, e: ClientPointerEvent): void {
86 const pad = padOf(surface)
87 const stick = pad.stick
88 const at = where(e)
89 if (e.type === 'down' && e.button === 'left') {
90 pad.anchor = at
91 pad.stick = { ...stick, dx: 0, dy: 0, isHeld: true, isStrafing: e.shift === true }
92 } else if (e.type === 'down' && e.button === 'right') {
93 pad.stick = { ...stick, isFiring: true }
94 } else if (e.type === 'move' && stick.isHeld && pad.anchor) {
95 pad.stick = { ...stick, dx: at.x - pad.anchor.x, dy: at.y - pad.anchor.y, isStrafing: e.shift === true }
96 } else if (e.type === 'up') {
97 const isLeft = e.button === 'left' || e.button === undefined
98 const isRight = e.button === 'right' || e.button === undefined
99 pad.stick = {
100 dx: isLeft ? 0 : stick.dx,
101 dy: isLeft ? 0 : stick.dy,
102 isHeld: isLeft ? false : stick.isHeld,
103 isFiring: isRight ? false : stick.isFiring,
104 isStrafing: isLeft ? false : stick.isStrafing,
105 }
106 if (isLeft) pad.anchor = null
107 } else {
108 return
109 }
110 send(surface, pad)
111 const state = stateOf(surface)
112 if (state.mode === 'idle') surface.setState({ ...state, mode: 'armed' })
113}
114
115function resend(surface: ClientSurface<PadState>): void {
116 const pad = padOf(surface)
117 if (pad.stick.isHeld || pad.stick.isFiring) send(surface, pad)
118}
119
120function beat(surface: ClientSurface<PadState>): void {
121 const pad = padOf(surface)
122 pad.beats += 1
123 if ((pad.stick.isHeld || pad.stick.isFiring) && pad.beats % RESEND_BEATS === 0) send(surface, pad)
124 const state = stateOf(surface)
125 surface.setState({ ...state, beat: state.beat === 0 ? 1 : 0 })
126}
127
128const PadModule: ClientModule<PadProps, PadState> = (props, surface) => {
129 const { Box, Text } = surface.elements
130 const isLook = props.role === 'look'
131 if (surface.state === undefined) {
132 surface.setState({ mode: 'idle', beat: 0 })
133 // One heartbeat is enough: the strip's. The layer over the screen still
134 // says a held stick again, or the engine would let it go.
135 if (isLook) surface.every(RESEND_MS, () => resend(surface))
136 else surface.every(PUMP_MS, () => beat(surface))
137 }
138 surface.onKey((e) => typed(surface, e))
139 surface.onPointer((e) => pointed(surface, e))
140 // Over the screen: nothing drawn, so the picture shows through.
141 if (props.role === 'look') return Box({ flexDirection: 'column' })
142
143 const state = stateOf(surface)
144 const line =
145 props.stick ??
146 (props.isArmed
147 ? '▶ drag sideways on the game with the left button to turn, right button fires · keys: w s walk, space fires, e use, 1-7 weapons, m menu, return picks and says yes · while you play, the prompt is Doom\'s: type / (or rest 10 s) to talk to Claude'
148 : '▶ hold the left button on the game and drag sideways to turn (shift: strafe) · w s walk · right button fires · a click also gives Doom the keys')
149 return Box({
150 flexDirection: 'column',
151 children: [
152 Box({
153 flexDirection: 'row',
154 children: [
155 Text({ children: [BEATS[state.beat]] }),
156 Text({ bold: state.mode !== 'playing', color: 'red', wrap: 'truncate-end', children: [line] }),
157 ],
158 }),
159 Text({ dimColor: true, wrap: 'truncate-end', children: [props.status === '' ? ' ' : props.status] }),
160 ],
161 })
162}
163
164export default PadModule
165hooks/lib.ts 396 lines1// Pure functions for the doom mod: which Doom key a terminal key is, how big
2// the screen is drawn, the engine's URLs and arguments. Nothing here takes $,
3// so the tests import it directly.
4
5import type { ClientKeyEvent, HttpInit, ImageSource } from 'claude-code'
6
7export type Size = { columns: number; rows: number }
8export type Transport = 'unix' | 'tcp'
9export type Connection = { base: string; init: HttpInit }
10export type Platform = { os: 'windows' | 'linux' | 'macos' | null; arch: 'x86_64' | 'arm64' | null }
11export type Placement = { transport: 'unix'; socketPath: string; imagePath: string } | { transport: 'tcp'; socketPath: null; imagePath: string }
12export type Route = 'pass' | 'release' | 'game'
13// The strip's stick as the surface module posts it, and the engine's.
14export type Pad = { dx: number; dy: number; isHeld: boolean; isFiring: boolean; isStrafing: boolean }
15export type Stick = { turn: number; forward: number; buttons: number }
16export type Button = { label: string; hotkey: string; code: number; autoFocus?: true }
17export type Args = { action: 'play' } | { action: 'quit' } | { error: string }
18
19// Doom's own key codes (engine/doomgeneric/doomkeys.h).
20export const KEY = {
21 right: 0xae,
22 left: 0xac,
23 up: 0xad,
24 down: 0xaf,
25 strafeLeft: 0xa0,
26 strafeRight: 0xa1,
27 use: 0xa2,
28 fire: 0xa3,
29 escape: 27,
30 enter: 13,
31 tab: 9,
32 backspace: 0x7f,
33 pause: 0xff,
34}
35
36// The keys the screen takes once a click on the game (or the strip under it)
37// has given it the keyboard. Escape never reaches it (it hands the keyboard
38// back), so the menu is `m` or backspace. Space fires and `e` uses. Return
39// picks in a menu and answers yes to Doom's questions (the engine makes Enter
40// its confirm key), and `y` sends Return too.
41const KEYS: Readonly<Record<string, number>> = {
42 up: KEY.up,
43 down: KEY.down,
44 left: KEY.left,
45 right: KEY.right,
46 w: KEY.up,
47 s: KEY.down,
48 a: KEY.left,
49 d: KEY.right,
50 ',': KEY.strafeLeft,
51 '.': KEY.strafeRight,
52 ' ': KEY.fire,
53 space: KEY.fire,
54 e: KEY.use,
55 return: KEY.enter,
56 enter: KEY.enter,
57 y: KEY.enter,
58 o: KEY.enter,
59 m: KEY.escape,
60 tab: KEY.tab,
61 p: KEY.pause,
62 n: 'n'.charCodeAt(0),
63}
64
65// The Doom key a terminal key stands for, or null for one it does not take.
66// A digit picks a weapon; backspace opens the menu as Escape would, and in a
67// menu goes back, as Doom's own backspace does.
68export function doomKeyOf(e: ClientKeyEvent | null | undefined): number | null {
69 if (!e || typeof e.key !== 'string' || e.ctrl || e.meta) return null
70 const key = e.key.length === 1 ? e.key.toLowerCase() : e.key
71 if (key === 'backspace') return KEY.escape
72 if (/^[1-7]$/.test(key)) return key.charCodeAt(0)
73 return Object.hasOwn(KEYS, key) ? (KEYS[key] ?? null) : null
74}
75
76// The buttons the hooks module draws under the screen. A pane's hotkeys reach
77// only buttons the hooks module draws, and work before any click; a hotkey is
78// one letter or digit, so space and Return cannot be one. Return presses the
79// button the pane's focus ring is on, so `ok` takes the ring (autoFocus):
80// Return picks and answers yes before any click too.
81export const BUTTONS: readonly Button[] = [
82 { label: 'menu', hotkey: 'm', code: KEY.escape },
83 { label: 'ok', hotkey: 'o', code: KEY.enter, autoFocus: true },
84 { label: '↑', hotkey: 'w', code: KEY.up },
85 { label: '←', hotkey: 'a', code: KEY.left },
86 { label: '↓', hotkey: 's', code: KEY.down },
87 { label: '→', hotkey: 'd', code: KEY.right },
88 { label: 'use', hotkey: 'e', code: KEY.use },
89]
90
91// Rows the pane spends on things other than the screen: the strip, its
92// status line and the buttons.
93export const CHROME_ROWS = 3
94
95const MIN_COLUMNS = 16
96const MAX_COLUMNS = 512
97const MAX_ROWS = 256
98
99// The screen's size in cells for a pane body of `bodyColumns` by `bodyRows`.
100// A terminal cell is about twice as tall as it is wide, and Doom's 320×200
101// was shown at 4:3: the screen is 3/8 as many rows as columns. An Image is at
102// most 255 cells either way, a Raster 512 by 256.
103export function screenSize(bodyColumns: number | undefined, bodyRows: number | undefined, maxColumns: number = MAX_COLUMNS): Size {
104 const width = Math.min(maxColumns, Math.max(MIN_COLUMNS, Math.floor(bodyColumns ?? 80)))
105 const height = Math.max(6, Math.floor(bodyRows ?? 30) - CHROME_ROWS)
106 let columns = width
107 let rows = Math.floor((columns * 3) / 8)
108 if (rows > height) {
109 rows = height
110 columns = Math.min(width, Math.floor((rows * 8) / 3))
111 }
112 return { columns: Math.max(MIN_COLUMNS, columns), rows: Math.min(MAX_ROWS, Math.max(6, rows)) }
113}
114
115// The pane the command asks for: docked, a width the render then fits to
116// the screen the pane's height allows; inline above the prompt, tall enough
117// for an 80-column screen.
118export function paneRequest(): Size {
119 return { columns: 90, rows: 30 + CHROME_ROWS }
120}
121
122const DEFAULT_COLOR = 0x01000000
123
124// A blank screen, the cells drawn before the engine's first frame arrives.
125export function blankCells(columns: number, rows: number): string {
126 const words = new Uint32Array(columns * rows * 3)
127 for (let i = 0; i < columns * rows; i++) {
128 words[i * 3] = 0x20
129 words[i * 3 + 1] = DEFAULT_COLOR
130 words[i * 3 + 2] = 0x000000
131 }
132 const bytes = new Uint8Array(words.buffer)
133 let binary = ''
134 for (const byte of bytes) binary += String.fromCharCode(byte)
135 return btoa(binary)
136}
137
138// How long a size's cells are as text: twelve bytes a cell, base64.
139export function cellsLength(size: Size): number {
140 return Math.ceil((size.columns * size.rows * 12) / 3) * 4
141}
142
143// How the hooks reach the engine: `base` for its URLs and `init` for
144// $.http.fetch. Over a Unix socket the URL's host is only a name; over
145// 127.0.0.1 each request carries the token the engine was started with.
146export function connectionOf(
147 transport: Transport,
148 { socketPath, port, token }: { socketPath: string | null; port: number | null; token: string | null },
149): Connection {
150 if (transport === 'unix') return { base: 'http://doom', init: socketPath === null ? {} : { socketPath } }
151 return { base: `http://127.0.0.1:${port ?? 0}`, init: { headers: { 'x-doom-token': token ?? '' } } }
152}
153
154export function frameUrl(base: string, size: Size, since: number): string {
155 return `${base}/frame?c=${size.columns}&r=${size.rows}&since=${since}`
156}
157
158export function keyUrl(base: string, codes: readonly number[]): string {
159 return `${base}/key?k=${codes.join(',')}`
160}
161
162export function imageUrl(base: string, since: number): string {
163 return `${base}/image?since=${since}`
164}
165
166export function quitUrl(base: string): string {
167 return `${base}/quit`
168}
169
170// Keys reach a Client only after a click, and Esc (which a mod never gets)
171// hands them back to Claude Code's prompt; before a click, the pane's focus
172// sends space, digits and the like to the prompt too. So while Doom is being
173// played (input in the last GAME_KEYS_MS), the prompt is Doom's: a key that
174// lands there is taken out (`game`), and a game key goes on to Doom, any
175// other key is dropped. It stays Doom's while it holds only what play typed
176// into it (`isOurs`: empty when the game took it), so keys Claude Code let
177// through anyway come back out. A `/` gives it back at once (`release`):
178// commands like `/doom quit`, or a message once deleted. A prompt that held
179// the person's own text, or a game left alone that long, is theirs (`pass`).
180export const GAME_KEYS_MS = 10000
181
182export function promptKeyRoute({
183 isRunning,
184 text,
185 isOurs,
186 msSinceGame,
187 typed,
188 hasModifier,
189}: {
190 isRunning: boolean
191 text: string
192 isOurs: boolean
193 msSinceGame: number
194 typed: string
195 hasModifier: boolean
196}): Route {
197 if (!isRunning || hasModifier || msSinceGame >= GAME_KEYS_MS) return 'pass'
198 if (text !== '' && !isOurs) return 'pass'
199 if (typed.startsWith('/')) return 'release'
200 return 'game'
201}
202
203// The stick: a drag on the screen (or the strip) with the left button held,
204// measured in cells from where the button went down. It only turns: a drag
205// left or right past the dead zone turns that way (strafes with shift) as
206// fast as the `a` and `d` keys turn, however far it goes, and an up or down
207// drag does nothing, so walking stays on the keys while the mouse turns. The
208// right button fires. Doom takes it as its mouse each tic: `turn` as the
209// mouse's sideways move, where 80 turns as fast as the arrow keys; the engine
210// turns it at half that for its first six tics, as Doom does a held key.
211export const STICK = { dead: 0.3, turn: 80, fire: 1, strafe: 2 }
212
213// The engine's stick for the strip's: `{ dx, isHeld, isFiring, isStrafing }`.
214// `forward` is always 0: the stick does not walk.
215export function stickOf(pad: Pad | null): Stick {
216 if (!pad) return { turn: 0, forward: 0, buttons: 0 }
217 const dx = pad.isHeld ? pad.dx : 0
218 const turn = Math.abs(dx) > STICK.dead ? Math.sign(dx) * STICK.turn : 0
219 const buttons = (pad.isFiring ? STICK.fire : 0) | (pad.isHeld && pad.isStrafing ? STICK.strafe : 0)
220 return { turn, forward: 0, buttons }
221}
222
223export function stickUrl(base: string, stick: Stick): string {
224 return `${base}/stick?t=${stick.turn}&f=${stick.forward}&b=${stick.buttons}`
225}
226
227// What the stick is doing, in words, for the strip; null when it rests.
228export function stickText(pad: Pad | null): string | null {
229 if (!pad || (!pad.isHeld && !pad.isFiring)) return null
230 const parts: string[] = []
231 if (pad.isHeld) {
232 if (pad.dx > STICK.dead) parts.push(pad.isStrafing ? 'strafe right' : 'turn right')
233 if (pad.dx < -STICK.dead) parts.push(pad.isStrafing ? 'strafe left' : 'turn left')
234 if (parts.length === 0) parts.push('held: drag sideways to turn')
235 }
236 if (pad.isFiring) parts.push('fire')
237 return '◉ ' + parts.join(' · ')
238}
239
240export function sameStick(a: Stick | null, b: Stick | null): boolean {
241 return a !== null && b !== null && a.turn === b.turn && a.forward === b.forward && a.buttons === b.buttons
242}
243
244// Doom's own screen, as the engine writes it for an Image: raw RGB.
245export const IMAGE = { width: 320, height: 200, maxColumns: 255 } as const
246
247// An Image's source for frame `generation` of that file: the terminal reads
248// the file itself, and a new generation makes it read it again.
249export function imageSource(path: string, generation: number): ImageSource {
250 return { file: path, format: 'rgb', width: IMAGE.width, height: IMAGE.height, generation }
251}
252
253// Whether the terminal draws pictures (kitty's graphics protocol): kitty and
254// Ghostty say so in TERM or TERM_PROGRAM. A terminal that does not still gets
255// the cells: a refused Image blit switches back.
256export function drawsImages(term: string | undefined, termProgram: string | undefined): boolean {
257 return /kitty|ghostty/i.test(String(term ?? '')) || /ghostty/i.test(String(termProgram ?? ''))
258}
259
260// The machine the engine runs on, as the name of its folder under
261// engine/bin: `{ os, arch }`, each null when unknown. On Windows from
262// %OS% and %PROCESSOR_ARCHITECTURE% (a 32-bit host reports the machine's
263// own in %PROCESSOR_ARCHITEW6432%); elsewhere from `uname -sm`.
264export function platformOf({
265 os,
266 processorArch,
267 processorArch6432,
268 uname,
269}: {
270 os?: string | undefined
271 processorArch?: string | undefined
272 processorArch6432?: string | undefined
273 uname?: string | undefined
274} = {}): Platform {
275 if (os === 'Windows_NT') {
276 const arch = String(processorArch6432 || processorArch || '').toUpperCase()
277 return { os: 'windows', arch: arch === 'AMD64' ? 'x86_64' : arch === 'ARM64' ? 'arm64' : null }
278 }
279 const [system = '', machine = ''] = String(uname ?? '').trim().split(/\s+/)
280 const name = system === 'Linux' ? 'linux' : system === 'Darwin' ? 'macos' : null
281 const arch = /^(x86_64|amd64)$/i.test(machine) ? 'x86_64' : /^(arm64|aarch64)$/i.test(machine) ? 'arm64' : null
282 return { os: name, arch }
283}
284
285// The engines to try, best first: the prebuilt one for this machine, then
286// one built here by `make` (engine/doom-claude).
287export function engineCandidates(root: string, platform: Platform): string[] {
288 const exe = platform.os === 'windows' ? '.exe' : ''
289 const prebuilt = platform.os && platform.arch ? [`${root}/engine/bin/${platform.os}-${platform.arch}/doom-claude${exe}`] : []
290 return [...prebuilt, `${root}/engine/doom-claude${exe}`]
291}
292
293// A Unix socket's path fits in about 100 bytes (104 on macOS, 108 on Linux).
294export const SOCKET_PATH_MAX = 100
295
296// Where the engine listens and writes its frames. A Unix socket in the first
297// private directory whose path fits (the session's runtime directory, the
298// user's temporary directory, the mod's data directory); 127.0.0.1 on
299// Windows, when none fits, or when `prefer` is `tcp`. The frame file sits in
300// that same directory.
301export function placementOf({
302 platform,
303 runtimeDir,
304 tmpDir,
305 dataDir,
306 id,
307 prefer,
308}: {
309 platform: Platform
310 runtimeDir?: string | undefined
311 tmpDir?: string | undefined
312 dataDir: string
313 id: string
314 prefer?: string | undefined
315}): Placement {
316 const dirs = [runtimeDir, tmpDir]
317 .filter((dir): dir is string => typeof dir === 'string' && dir.startsWith('/'))
318 .map((dir) => dir.replace(/\/+$/, ''))
319 dirs.push(dataDir)
320 const name = `claude-doom-${id}`
321 if (platform.os !== 'windows' && prefer !== 'tcp') {
322 for (const dir of dirs) {
323 const socketPath = `${dir}/${name}.sock`
324 if (socketPath.length <= SOCKET_PATH_MAX) return { transport: 'unix', socketPath, imagePath: `${dir}/${name}.rgb` }
325 }
326 }
327 return { transport: 'tcp', socketPath: null, imagePath: `${dirs[0] ?? dataDir}/${name}.rgb` }
328}
329
330// The engine's command line.
331export function engineArgs(binary: string, root: string, placement: Placement): string[] {
332 return [
333 binary,
334 ...(placement.transport === 'unix' ? ['--socket', placement.socketPath] : ['--port', '0']),
335 '--image',
336 placement.imagePath,
337 '--dir',
338 `${root}/data`,
339 '-iwad',
340 `${root}/wad/freedoom1.wad`,
341 ]
342}
343
344// The line the engine prints once it listens: `{ port }` (null over a Unix
345// socket), or null when `text` does not hold it yet.
346export function listeningOf(text: string): { port: number | null } | null {
347 const match = /^doom-claude listening(?: port=(\d+))?\r?$/m.exec(text)
348 if (!match) return null
349 return { port: match[1] ? Number(match[1]) : null }
350}
351
352// A token for the engine's TCP listener: 64 hex digits.
353export function newToken(): string {
354 return (crypto.randomUUID() + crypto.randomUUID()).replaceAll('-', '')
355}
356
357// The strip posts every key it has taken lately, numbered, so a post that
358// replaces an undelivered one loses nothing: keep the ones newer than `seen`.
359export function freshKeys(message: unknown, seen: number): { seen: number; codes: number[] } {
360 if (!isRecord(message) || message['type'] !== 'keys' || !Array.isArray(message['keys'])) return { seen, codes: [] }
361 let last = seen
362 const codes: number[] = []
363 for (const k of message['keys'] as unknown[]) {
364 if (!isRecord(k)) continue
365 const n = k['n']
366 const code = k['code']
367 if (typeof n !== 'number' || typeof code !== 'number') continue
368 if (n <= seen) continue
369 codes.push(code)
370 last = Math.max(last, n)
371 }
372 return { seen: last, codes }
373}
374
375// The stick a post carries, or null when it is not a well-formed one.
376export function padOf(message: unknown): Pad | null {
377 if (!isRecord(message) || !isRecord(message['stick'])) return null
378 const s = message['stick']
379 const dx = s['dx']
380 const dy = s['dy']
381 if (typeof dx !== 'number' || typeof dy !== 'number') return null
382 return { dx, dy, isHeld: s['isHeld'] === true, isFiring: s['isFiring'] === true, isStrafing: s['isStrafing'] === true }
383}
384
385function isRecord(value: unknown): value is Record<string, unknown> {
386 return typeof value === 'object' && value !== null && !Array.isArray(value)
387}
388
389// `/doom` alone opens the game; `/doom quit` ends it.
390export function parseArgs(args: string | undefined): Args {
391 const word = (args ?? '').trim().toLowerCase()
392 if (word === '') return { action: 'play' }
393 if ('quit'.startsWith(word) || word === 'stop') return { action: 'quit' }
394 return { error: `Unknown argument "${word}". Use /doom or /doom quit.` }
395}
396