Plays Doom-engine WADs in a Claude Code pane: runs doom-cli (GPLv2, built from upstream) in a private tmux server and repaints its frames into a Raster…

Plays Doom-engine WADs in a Claude Code pane. It is a Claude Code mod: a plugin whose behaviour lives in a function-hooks module (hooks/register.tsx). It runs doom-cli in a private tmux server, reads each frame with tmux capture-pane, and repaints it into a terminal Raster, scaled to fit the pane.
Function hooks are early access and off by default. Start Claude Code with the flag and the plugin folder:
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude --plugin-dir plugins/claudoom
| Command | Does |
|---|---|
/claudoom install | Builds doom-cli and downloads Freedoom |
/claudoom install shareware | Shows the shareware licence terms; add accept to download |
/claudoom | Plays the shareware episode if installed, else Freedoom; again closes it |
/claudoom freedoom2 | Plays Freedoom Phase 2 |
/claudoom <file.wad> | Plays a WAD you own |
/claudoom stop | Stops the game |
/claudoom uninstall | Stops the game and removes what the install wrote; files you added yourself stay |
While the pane holds the keys: WASD or ↑↓ move, F fire, E use, Enter or R select, Q menu, 1–7 weapons. Esc hands the keys back to the prompt; ctrl+x tab takes them again. It needs tmux, make, curl, tar, unzip and shasum. It never runs git, so a git config that rewrites GitHub URLs to SSH cannot break the install.
Nothing below ships with this plugin. /claudoom install fetches it onto your machine, into ~/.claude/claudoom, over HTTPS only (a redirect to plain http is refused), and verifies each download against a pinned digest before anything is built, kept or run.
The engine's digest covers the extracted source tree, not the tarball, so a forge re-compressing the same commit does not break the install while a changed file still does. The command that recomputes it is in hooks/doom.ts, beside the digest.
| Component | Licence | Source |
|---|---|---|
| doom-cli, a doomgeneric port | GPLv2 | fetched from upstream at a pinned commit, verified against a pinned source-tree digest, then built locally; the mod talks to it only through tmux |
| Freedoom 0.13.0 | 3-clause BSD, notice kept as freedoom-COPYING.txt | the official GitHub release, checked against its signed SHA-256 |
| DOOM1.WAD, the shareware episode (opt-in) | id Software's shareware licence: not free software, no commercial use | id's own doom19s.zip from /idgames, archive and WAD both checked |
The shareware licence (LICENSE.DOC, shipped with id's v1.8 shareware) allows electronic distribution royalty free and "only in compressed format", and forbids modifying the WAD (§1), commercial use (§2) and charging anyone for it without id's prior written consent (§4). That is why the shareware download is opt-in, shows those terms first, and fetches id's compressed archive rather than a bare WAD.
DOOM is a trademark of the ZeniMax group of companies. Claude is a trademark of Anthropic. This plugin is not affiliated with or endorsed by id Software, ZeniMax or Anthropic. This is not legal advice.
hooks/register.tsx 339 lines1import type { Elements, On, Timer, UiBlitArgs } from 'claude-code'
2
3import * as Doom from './doom'
4import { bannerWords, encode, fitGrid, fitWords, paintedRowsOf, wordsOf, type Grid } from './frame'
5import { ACTION_HOTKEYS, WEAPON_HOTKEYS, pressesOf, tmuxKeyOf } from './keys'
6
7const PANE_ID = 'claudoom'
8const SCREEN_KEY = 'screen'
9const CONTROLS_KEY = 'controls'
10const FRAME_MS = 33
11// A capture can fail once while tmux is busy; only a run of failures means
12// the game quit.
13const FAILURES_TO_END = 3
14// Controls under the screen: all three rows when the pane has room for the
15// whole frame, only the movement row when it does not.
16const FULL_CONTROL_ROWS = 3
17const TIGHT_CONTROL_ROWS = 1
18// The drawing is always one row taller than the pane's body, whatever the
19// picture's size. An inline pane is as tall as its content up to what the
20// layout spares, so content sized from the body would shrink the body with
21// it; and the arrows only raise `ui.scroll` while there is a row to scroll.
22const CONTENT_ROWS = Doom.GRID.rows + FULL_CONTROL_ROWS + 1
23// A height change alone does not redraw a pane, so redraw on a clock to pick
24// one up: every 15th frame, twice a second.
25const RELAYOUT_EVERY_TICKS = 15
26
27const LOADING = ['C L A U D O O M', '', 'loading…']
28
29/**
30 * What the frame loop needs from `$` after the hook that started it returned.
31 * `$` itself may not be kept (the engine refuses a module that stores it), so
32 * `session.start` binds one closure per call, each spelling `$` out.
33 */
34type Host = {
35 run: Doom.Run
36 files: Doom.Files
37 home: () => Promise<string | undefined>
38 every: (ms: number, fn: () => void) => Timer
39 blit: (args: UiBlitArgs) => Promise<unknown>
40 invalidate: () => void
41 closePane: () => Promise<void>
42 log: (text: string) => void
43}
44
45/**
46 * `/claudoom` opens a pane holding the game: a Raster repainted with each
47 * captured frame, scaled to the room the pane has, and the controls under it.
48 */
49export function register(on: On) {
50 let host: Host | null = null
51 let session = ''
52 let frame: Uint32Array | null = null
53 let view: Grid = Doom.GRID
54 let timer: Timer | null = null
55 let isOpen = false
56 let isCapturing = false
57 let isInstalling = false
58 let failures = 0
59 let fullRows = 0
60 let ticks = 0
61
62 const run: Doom.Run = (argv, init) =>
63 host ? host.run(argv, init) : Promise.reject(new Error('doom: no session yet'))
64
65 const press = (key: string): void => {
66 void Doom.sendKeys(run, session, [key]).catch(() => undefined)
67 }
68
69 async function paths(): Promise<Doom.Paths> {
70 return Doom.pathsOf((await host?.home()) ?? '')
71 }
72
73 /** The Raster's cells at the size it is drawn: the last whole frame, or the banner. */
74 function cellsFor(grid: Grid): string {
75 return encode(frame ? fitWords(frame, Doom.GRID, grid) : bannerWords(LOADING, grid))
76 }
77
78 async function tick(): Promise<void> {
79 if (!host || !isOpen || isCapturing) return
80 isCapturing = true
81 ticks++
82 if (ticks % RELAYOUT_EVERY_TICKS === 0) host.invalidate()
83 try {
84 const { frame: text, error } = await Doom.capture(run, session)
85 if (text === undefined) {
86 failures++
87 // Say what tmux said the first time: a game that quit and a tmux whose
88 // capture-pane has no -N both end here, looking identical.
89 if (failures === 1 && error) host.log(`claudoom: reading the game's screen failed: ${error}`)
90 if (failures >= FAILURES_TO_END) await endGame('claudoom exited.')
91 return
92 }
93 failures = 0
94 // A torn capture paints fewer rows than a whole frame; keep the last
95 // whole one on screen instead.
96 const rows = paintedRowsOf(text)
97 if (rows < fullRows) return
98 fullRows = rows
99 frame = wordsOf(text, Doom.GRID)
100 await host.blit({ requestId: PANE_ID, key: SCREEN_KEY, cells: cellsFor(view) })
101 } catch {
102 // A missed frame is harmless; the next tick paints over it.
103 } finally {
104 isCapturing = false
105 }
106 }
107
108 function startLoop(): void {
109 stopLoop()
110 timer = host?.every(FRAME_MS, () => {
111 void tick()
112 }) ?? null
113 }
114
115 function stopLoop(): void {
116 timer?.cancel()
117 timer = null
118 }
119
120 /** Stops the loop and the game; leaves the pane to whoever is closing it. */
121 async function stopGame(): Promise<void> {
122 stopLoop()
123 isOpen = false
124 await Doom.stop(run, session)
125 }
126
127 async function endGame(note?: string): Promise<void> {
128 const wasOpen = isOpen
129 await stopGame()
130 if (wasOpen && host) {
131 await host.closePane().catch(() => undefined)
132 if (note) host.log(note)
133 }
134 }
135
136 on('session.start', async ($, e, next) => {
137 host = {
138 run: (argv, init) => $.process.run(argv, init),
139 files: {
140 exists: path => $.fs.exists(path),
141 read: path => $.fs.read(path),
142 write: (path, text) => $.fs.write(path, text),
143 },
144 home: () => $.env.get('HOME'),
145 every: (ms, fn) => $.clock.every(ms, fn),
146 blit: args => $.ui.blit(args),
147 invalidate: () => $.ui.invalidate('ui.render'),
148 closePane: () => $.ui.close({ id: PANE_ID }),
149 log: text => $.ui.log(text),
150 }
151 session = `claudoom-${(await $.session.id()).slice(0, 8)}`
152 await $.command.register({
153 name: 'claudoom',
154 description: 'Play Doom-engine WADs (Freedoom by default) in a pane beside the conversation',
155 argumentHint: '[freedoom2 | <file.wad> | install [shareware] | stop | uninstall]',
156 })
157
158 // A reload (the plugin folder saved under --plugin-dir) runs this again
159 // with fresh state while the pane stays open: pick the running game back up.
160 const panes = await $.ui.panes()
161 if (panes.some(pane => pane.id === PANE_ID) && (await Doom.isRunning(run, session))) {
162 isOpen = true
163 startLoop()
164 }
165
166 return next(e)
167 })
168
169 on('command.run', { command: 'claudoom' }, async ($, e) => {
170 const args = e.args.trim()
171 const [verb = '', option = '', consent = ''] = args.toLowerCase().split(/\s+/)
172 const bound = host
173 if (!bound) return { text: 'claudoom is not ready: this session has not started yet.' }
174
175 if (verb === 'stop') {
176 await endGame()
177 return { text: 'claudoom stopped.' }
178 }
179
180 if (verb === 'uninstall') {
181 if (isInstalling) return { text: 'claudoom is installing; run /claudoom uninstall once it finishes.' }
182 await endGame()
183 const installed = await paths()
184 const { removed, kept } = await Doom.uninstall(run, bound.files, installed)
185 if (removed.length === 0) return { text: `Nothing to remove: claudoom has no files in ${installed.dir}.` }
186 const left = kept.length > 0 ? ` Left in place, not claudoom's: ${kept.join(', ')}.` : ' The folder is gone.'
187 return {
188 text: `Removed ${removed.join(', ')} from ${installed.dir}.${left} The plugin itself stays installed; remove it with /plugin. If a game ever outlives its session, "tmux -L claudoom kill-server" stops it.`,
189 }
190 }
191
192 if (!(await Doom.hasTmux(run))) {
193 return { text: 'claudoom runs inside tmux, and tmux is not on PATH. Install it (brew install tmux), then run /claudoom again.' }
194 }
195
196 const where = await paths()
197
198 if (verb === 'install') {
199 const isShareware = option === 'shareware'
200 if (isShareware && consent !== 'accept') return { text: Doom.SHAREWARE_TERMS }
201 if (isInstalling) return { text: 'claudoom is still installing.' }
202 isInstalling = true
203 const fetchWad = isShareware ? Doom.fetchShareware : Doom.fetchFreedoom
204 const installed = isShareware ? 'The shareware episode' : 'Freedoom'
205 void (async () => (await Doom.buildEngine(run, bound.files, where)) ?? (await fetchWad(run, bound.files, where)))()
206 .then(error => bound.log(error ? `claudoom install failed: ${error}` : `${installed} is installed. Run /claudoom to play.`))
207 .catch(error => bound.log(`claudoom install failed: ${String(error)}`))
208 .finally(() => {
209 isInstalling = false
210 })
211 return {
212 text: isShareware
213 ? `Downloading id's shareware archive into ${where.dir}, under the terms shown.`
214 : `Installing into ${where.dir}: building doom-cli (GPLv2, built from upstream source) and downloading Freedoom (BSD, about 25 MB). This takes a minute or two.`,
215 }
216 }
217
218 if (isOpen) {
219 await endGame()
220 return { text: 'claudoom closed.' }
221 }
222
223 // `/claudoom freedoom2` or `/claudoom <path to a .wad>` picks the game; a bare
224 // `/claudoom` plays the shareware episode if it was installed, else Freedoom.
225 const home = (await bound.home()) ?? ''
226 const chosen =
227 verb === 'freedoom' ? where.freedoom1
228 : verb === 'freedoom2' ? where.freedoom2
229 : verb.endsWith('.wad') ? args.replace(/^~(?=\/)/, home)
230 : undefined
231 if (verb && !chosen) {
232 return { text: 'Usage: /claudoom [freedoom | freedoom2 | <file.wad> | install [shareware] | stop | uninstall]' }
233 }
234
235 const wad = await Doom.wadOf(bound.files, where, chosen)
236 if (chosen && !wad) return { text: `There is no WAD at ${chosen}.` }
237 if (!wad || !(await bound.files.exists(where.bin))) {
238 return {
239 text: `claudoom is not installed in ${where.dir}. Run /claudoom install: it builds doom-cli (GPLv2, built from upstream source) and downloads Freedoom, a free BSD-licensed game for this engine. The shareware episode is opt-in: /claudoom install shareware.`,
240 }
241 }
242
243 const error = await Doom.start(run, session, where, wad)
244 if (error) return { text: `claudoom did not start: ${error}` }
245
246 frame = null
247 failures = 0
248 fullRows = 0
249 isOpen = true
250 await $.ui.open({
251 id: PANE_ID,
252 title: 'claudoom',
253 focus: true,
254 rows: CONTENT_ROWS - 1,
255 columns: Doom.GRID.columns,
256 })
257 startLoop()
258
259 return {
260 text: 'claudoom is running and its pane has the keys: WASD or ↑↓ move, F fire, E use, Enter or R select, Q menu. Esc hands the keys back, ctrl+x tab takes them again, /claudoom closes it.',
261 }
262 })
263
264 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
265 if (e.requestId !== PANE_ID) return next(e)
266
267 if (e.surface !== 'terminal') {
268 const { Text } = $.ui.resolve(e)
269 return <Text>claudoom draws on the terminal only.</Text>
270 }
271
272 const { Box, Button, Raster, Client, Text } = $.ui.resolve(e) as Elements['terminal']
273 const bodyRows = e.props.scroll.bodyRows
274 const isRoomy = bodyRows >= Doom.GRID.rows + FULL_CONTROL_ROWS
275 const controlRows = isRoomy ? FULL_CONTROL_ROWS : TIGHT_CONTROL_ROWS
276 view = fitGrid(Doom.GRID, { columns: e.props.bodyColumns, rows: Math.max(1, bodyRows - controlRows) })
277 const spacerRows = Math.max(CONTENT_ROWS, bodyRows + 1) - view.rows - controlRows
278
279 const buttonsOf = (entries: typeof ACTION_HOTKEYS) =>
280 entries.map(entry => (
281 <Button
282 key={`key-${entry.hotkey}`}
283 hotkey={entry.hotkey}
284 label={entry.label}
285 plain
286 dimColor
287 // Enter presses the focused Button, so the ring starts on Doom's own
288 // Enter: menus select with the key a player reaches for.
289 autoFocus={entry.hotkey === 'r' ? true : undefined}
290 onPress={() => press(entry.tmux)}
291 />
292 ))
293
294 return (
295 <Box flexDirection="column">
296 <Raster key={SCREEN_KEY} columns={view.columns} rows={view.rows} cells={cellsFor(view)} />
297 <Box flexDirection="row" gap={1}>
298 {e.props.isFocused ? <Text color="green">●</Text> : <Text dimColor>○</Text>}
299 {buttonsOf(ACTION_HOTKEYS)}
300 </Box>
301 {isRoomy && (
302 <Box flexDirection="row" gap={1}>
303 {buttonsOf(WEAPON_HOTKEYS)}
304 </Box>
305 )}
306 {isRoomy && <Client key={CONTROLS_KEY} module="./controls.tsx" height={1} />}
307 <Box key="spacer" height={spacerRows} flexShrink={0} />
308 </Box>
309 )
310 })
311
312 // A focused pane keeps the arrow keys for scrolling. Each one still arrives
313 // here as a one-row scroll of this pane: hand ↑/↓ to the game and keep the
314 // pane still. A wheel (it carries a pointer) or a page key moves nothing either.
315 on('ui.scroll', { requestId: PANE_ID }, ($, e, next) => {
316 if (!isOpen || e.origin.kind !== 'person') return next(e)
317 if (!e.pointer && Math.abs(e.by) === 1) press(e.by < 0 ? 'Up' : 'Down')
318 return {}
319 })
320
321 on('ui.message', ($, e, next) => {
322 if (e.requestId !== PANE_ID || e.element !== CONTROLS_KEY) return next(e)
323 const keys = pressesOf(e.data).flatMap(press => tmuxKeyOf(press) ?? [])
324 void Doom.sendKeys(run, session, keys).catch(() => undefined)
325 return {}
326 })
327
328 on('ui.close', { id: PANE_ID }, async ($, e, next) => {
329 const result = await next(e)
330 await stopGame()
331 return result
332 })
333
334 on('session.end', async ($, e, next) => {
335 await stopGame()
336 return next(e)
337 })
338}
339hooks/doom.ts 391 lines1import type { ProcessRunInit, ProcessRunResult } from 'claude-code'
2
3import type { Grid } from './frame'
4
5/**
6 * doom-cli, a native build of doomgeneric that draws to a terminal, driven
7 * through a tmux server of the mod's own. `$.process.run` is one-shot, so a
8 * game that keeps running cannot be streamed; a detached tmux session holds
9 * it, `capture-pane` reads a frame, `send-keys` types into it.
10 */
11
12export type Run = (argv: readonly string[], init?: ProcessRunInit) => Promise<ProcessRunResult>
13
14export type Files = {
15 exists: (path: string) => Promise<boolean>
16 read: (path: string) => Promise<string>
17 write: (path: string, text: string) => Promise<void>
18}
19
20export type Paths = {
21 dir: string
22 bin: string
23 repo: string
24 downloads: string
25 freedoom1: string
26 freedoom2: string
27 shareware: string
28}
29
30// 80 columns in quadrant mode is 160x52 pixels: 26 rows of 2x2 cells.
31export const GRID: Grid = { columns: 80, rows: 26 }
32
33// The window is taller than the frame so doom-cli's trailing newline never
34// scrolls the first row away.
35const WINDOW_ROWS = 30
36
37// A socket of its own keeps the game out of the person's `tmux ls`, and
38// `-f /dev/null` keeps their tmux.conf from changing what a capture holds.
39const TMUX = ['tmux', '-L', 'claudoom', '-f', '/dev/null'] as const
40
41// Every download is pinned and verified before anything is built, kept or run,
42// so an upstream change or a swapped file fails the install instead of running
43// something nobody reviewed.
44
45// doom-cli is GPLv2. Its source is fetched from upstream and built on the
46// person's own machine; the plugin ships none of it. The source arrives as the
47// tarball of one pinned commit over HTTPS, never through git: a person's git
48// config may rewrite github URLs to SSH, and then a clone needs their agent.
49//
50// What is pinned is the digest of the EXTRACTED TREE, not of the tarball: a
51// forge may re-compress an archive it serves for the same commit, which changes
52// the tarball's bytes and nothing inside it. This digest is the sha256 of the
53// sorted per-file sha256 list, so it survives that and still fails closed on a
54// changed file. Recompute it with the command in DOOM_CLI.digestCommand.
55const DOOM_CLI = {
56 commit: '018e1edf67a093f8ac48e57591eb934e9bc01b26',
57 tarball: 'https://codeload.github.com/ludocode/doom-cli/tar.gz/018e1edf67a093f8ac48e57591eb934e9bc01b26',
58 sourceSha256: 'a83bdb933f32601b2fab63b23b0cd6bc07cdbc77dcf5fe9de23fc7898cb2a9d4',
59 digestCommand: 'find . -type f -print0 | LC_ALL=C sort -z | xargs -0 shasum -a 256 | shasum -a 256',
60}
61
62// Freedoom is 3-clause BSD: free to fetch and keep, with its COPYING notice.
63// The digest is the one in the release's PGP-signed freedoom-0.13.0-CHECKSUM.
64const FREEDOOM = {
65 url: 'https://github.com/freedoom/freedoom/releases/download/v0.13.0/freedoom-0.13.0.zip',
66 sha256: '3f9b264f3e3ce503b4fb7f6bdcb1f419d93c7b546f4df3e874dd878db9688f59',
67 folder: 'freedoom-0.13.0',
68}
69
70// id's own v1.9 shareware archive from /idgames. Its licence allows electronic
71// distribution "only in compressed format", so the archive is fetched and
72// unpacked here, never a bare WAD from a mirror. The WAD digest is v1.9's.
73const SHAREWARE = {
74 url: 'https://www.gamers.org/pub/idgames/idstuff/doom/doom19s.zip',
75 sha256: 'cacf0142b31ca1af00796b4a0339e07992ac5f21bc3f81e7532fe1b5e1b486e6',
76 wadSha1: '5b2e249b9c5133ec987b3ea77596381dc0d6bc1d',
77}
78
79/** What `/claudoom install shareware` shows before anything is downloaded. */
80export const SHAREWARE_TERMS = [
81 "The shareware episode (DOOM1.WAD) is id Software's, and it is not free software.",
82 "Its licence (LICENSE.DOC, shipped with id's v1.8 shareware; the v1.9 archive carries none) lets you play it and copy it for others,",
83 'and share it electronically, royalty free, "only in compressed format". It forbids modifying it (§1),',
84 'any commercial use (§2, "Commercial Use is Prohibited"), and charging anyone for it without "ID\'s prior written consent" (§4).',
85 "To accept those terms and download id's original doom19s.zip from /idgames, run /claudoom install shareware accept.",
86].join(' ')
87
88// mempcpy is a GNU extension macOS lacks.
89const MEMPCPY_PATCH = `
90#ifndef mempcpy
91static inline void* mempcpy_compat(void* dest, const void* src, size_t n) {
92 memcpy(dest, src, n);
93 return (char*)dest + n;
94}
95#define mempcpy mempcpy_compat
96#endif
97`
98
99export function pathsOf(home: string): Paths {
100 const dir = `${home}/.claude/claudoom`
101 return {
102 dir,
103 bin: `${dir}/doomgeneric`,
104 repo: `${dir}/repo`,
105 downloads: `${dir}/downloads`,
106 freedoom1: `${dir}/freedoom1.wad`,
107 freedoom2: `${dir}/freedoom2.wad`,
108 shareware: `${dir}/DOOM1.WAD`,
109 }
110}
111
112async function succeeds(run: Run, argv: readonly string[], init?: ProcessRunInit): Promise<boolean> {
113 try {
114 return (await run(argv, init)).exitCode === 0
115 } catch {
116 return false
117 }
118}
119
120export function hasTmux(run: Run): Promise<boolean> {
121 return succeeds(run, ['tmux', '-V'])
122}
123
124async function missingTool(run: Run, tools: readonly string[]): Promise<string | undefined> {
125 for (const tool of tools) {
126 if (!(await succeeds(run, ['which', tool]))) return `${tool} is not installed`
127 }
128 return undefined
129}
130
131async function digestOf(run: Run, algorithm: '1' | '256', path: string): Promise<string | undefined> {
132 const result = await run(['shasum', '-a', algorithm, path])
133 return result.exitCode === 0 ? result.stdout.split(/\s+/)[0] : undefined
134}
135
136// `-L` follows redirects, so the scheme is pinned too: a redirect to plain http
137// is refused rather than followed.
138const CURL = ['curl', '-fsSL', '--proto', '=https', '--proto-redir', '=https'] as const
139
140/** Downloads a file and refuses it unless its SHA-256 is the pinned one. */
141async function fetchChecked(run: Run, url: string, sha256: string, to: string): Promise<string | undefined> {
142 const fetched = await run([...CURL, '-o', to, url], { timeoutMs: 300_000 })
143 if (fetched.exitCode !== 0) return `downloading ${url} failed: ${fetched.stderr.trim()}`
144 const digest = await digestOf(run, '256', to)
145 if (digest !== sha256) {
146 await run(['rm', '-f', to])
147 return `${url} does not match its pinned SHA-256 (got ${digest ?? 'nothing'}); nothing was installed`
148 }
149 return undefined
150}
151
152/**
153 * The WAD `/claudoom` plays: one the person named, else the shareware episode if
154 * they opted in to it, else Freedoom.
155 */
156export async function wadOf(files: Files, paths: Paths, chosen?: string): Promise<string | undefined> {
157 const candidates = chosen ? [chosen] : [paths.shareware, paths.freedoom1]
158 for (const wad of candidates) {
159 if (await files.exists(wad)) return wad
160 }
161 return undefined
162}
163
164/** Fetches doom-cli at the pinned commit, patches it for macOS and builds it. */
165export async function buildEngine(run: Run, files: Files, paths: Paths): Promise<string | undefined> {
166 if (await files.exists(paths.bin)) return undefined
167
168 const missing = await missingTool(run, ['curl', 'tar', 'make', 'shasum'])
169 if (missing) return missing
170
171 await run(['mkdir', '-p', paths.dir])
172
173 // The source lands in a staging directory and is verified there. `repo`
174 // itself appears only once the tree is whole and checked, so an interrupted
175 // install leaves nothing that a later run reads as "the source is present".
176 if (!(await files.exists(paths.repo))) {
177 const staging = `${paths.repo}.partial`
178 const abandon = async (why: string) => {
179 await run(['rm', '-rf', staging, paths.downloads])
180 return why
181 }
182
183 await run(['rm', '-rf', staging])
184 await run(['mkdir', '-p', paths.downloads, staging])
185
186 const tarball = `${paths.downloads}/doom-cli.tar.gz`
187 const fetched = await run([...CURL, '-o', tarball, DOOM_CLI.tarball], { timeoutMs: 180_000 })
188 if (fetched.exitCode !== 0) {
189 return abandon(`downloading doom-cli ${DOOM_CLI.commit.slice(0, 8)} failed: ${fetched.stderr.trim()}`)
190 }
191
192 const unpacked = await run(['tar', '-xzf', tarball, '-C', staging, '--strip-components=1'])
193 if (unpacked.exitCode !== 0) return abandon(`unpacking doom-cli failed: ${unpacked.stderr.trim()}`)
194
195 // The script text is fixed here and the directory travels as an argument.
196 const digested = await run(['sh', '-c', `cd "$1" && ${DOOM_CLI.digestCommand}`, 'sh', staging])
197 const digest = digested.stdout.split(/\s+/)[0]
198 if (digest !== DOOM_CLI.sourceSha256) {
199 return abandon(
200 `doom-cli ${DOOM_CLI.commit.slice(0, 8)} does not match its pinned source digest ` +
201 `(got ${digest || 'nothing'}); nothing was built`,
202 )
203 }
204
205 const staged = await run(['mv', staging, paths.repo])
206 if (staged.exitCode !== 0) return abandon(`staging doom-cli failed: ${staged.stderr.trim()}`)
207 await run(['rm', '-rf', paths.downloads])
208 }
209
210 const source = `${paths.repo}/doomgeneric/doomgeneric_cli.c`
211 if (await files.exists(source)) {
212 const text = await files.read(source)
213 if (text.includes('mempcpy') && !text.includes('mempcpy_compat')) {
214 const lineEnd = text.indexOf('\n', text.lastIndexOf('#include'))
215 await files.write(source, text.slice(0, lineEnd + 1) + MEMPCPY_PATCH + text.slice(lineEnd + 1))
216 }
217 }
218
219 const build = await run(['make', '-f', 'Makefile.cli'], {
220 cwd: `${paths.repo}/doomgeneric`,
221 env: { CFLAGS: '-Wno-absolute-value' },
222 timeoutMs: 300_000,
223 })
224 if (build.exitCode !== 0) {
225 // Both ends of the build log: a missing compiler or header says so in the
226 // first lines, and make's own "Error 1" is all the last lines carry.
227 const lines = build.stderr.trim().split('\n')
228 const said = lines.length > 6 ? [...lines.slice(0, 3), '…', ...lines.slice(-3)] : lines
229 return `make failed: ${said.join(' ')}`
230 }
231
232 const copy = await run(['cp', `${paths.repo}/doomgeneric/doomgeneric`, paths.bin])
233 if (copy.exitCode !== 0) return `copying the binary failed: ${copy.stderr.trim()}`
234
235 return undefined
236}
237
238/** Fetches the Freedoom release, checks it, and keeps both WADs and the licence. */
239export async function fetchFreedoom(run: Run, files: Files, paths: Paths): Promise<string | undefined> {
240 const copying = `${paths.dir}/${FREEDOOM_COPYING}`
241 // All three, so a half-finished install repairs itself on the next run
242 // instead of reporting success with a WAD or the licence notice missing.
243 const kept = [paths.freedoom1, paths.freedoom2, copying]
244 if ((await Promise.all(kept.map(path => files.exists(path)))).every(Boolean)) return undefined
245
246 const missing = await missingTool(run, ['curl', 'shasum', 'unzip'])
247 if (missing) return missing
248
249 await run(['mkdir', '-p', paths.downloads])
250 const zip = `${paths.downloads}/${FREEDOOM.folder}.zip`
251 const error = await fetchChecked(run, FREEDOOM.url, FREEDOOM.sha256, zip)
252 if (error) return error
253
254 const members = ['freedoom1.wad', 'freedoom2.wad', 'COPYING.txt'].map(name => `${FREEDOOM.folder}/${name}`)
255 const unpacked = await run(['unzip', '-o', '-j', zip, ...members, '-d', paths.downloads], { timeoutMs: 120_000 })
256 if (unpacked.exitCode !== 0) return `unpacking Freedoom failed: ${unpacked.stderr.trim()}`
257
258 for (const [from, to] of [
259 ['freedoom1.wad', paths.freedoom1],
260 ['freedoom2.wad', paths.freedoom2],
261 ['COPYING.txt', copying],
262 ] as const) {
263 const moved = await run(['mv', `${paths.downloads}/${from}`, to])
264 if (moved.exitCode !== 0) return `keeping ${from} failed: ${moved.stderr.trim()}`
265 }
266
267 await run(['rm', '-rf', paths.downloads])
268 return undefined
269}
270
271/**
272 * Fetches id's shareware archive, joins the two halves of its 1995 installer
273 * (one LHA archive, which macOS `tar` reads) and keeps only the WAD, checked.
274 */
275export async function fetchShareware(run: Run, files: Files, paths: Paths): Promise<string | undefined> {
276 if (await files.exists(paths.shareware)) return undefined
277
278 const missing = await missingTool(run, ['curl', 'shasum', 'unzip', 'tar'])
279 if (missing) return missing
280
281 await run(['mkdir', '-p', paths.downloads])
282 const zip = `${paths.downloads}/doom19s.zip`
283 const error = await fetchChecked(run, SHAREWARE.url, SHAREWARE.sha256, zip)
284 if (error) return error
285
286 const parts = ['DOOMS_19.1', 'DOOMS_19.2']
287 const unzipped = await run(['unzip', '-o', zip, ...parts, '-d', paths.downloads])
288 if (unzipped.exitCode !== 0) return `unpacking doom19s.zip failed: ${unzipped.stderr.trim()}`
289
290 const lzh = `${paths.downloads}/dooms19.lzh`
291 const [first, second] = parts.map(part => `${paths.downloads}/${part}`)
292 // The paths travel as arguments, never inside the script text.
293 const joined = await run(['sh', '-c', 'cat "$1" "$2" > "$3"', 'sh', first!, second!, lzh])
294 if (joined.exitCode !== 0) return `joining the installer halves failed: ${joined.stderr.trim()}`
295
296 const extracted = await run(['tar', '-xf', lzh, '-C', paths.downloads, 'DOOM1.WAD'])
297 if (extracted.exitCode !== 0) return `extracting DOOM1.WAD failed: ${extracted.stderr.trim()}`
298
299 const wad = `${paths.downloads}/DOOM1.WAD`
300 const digest = await digestOf(run, '1', wad)
301 if (digest !== SHAREWARE.wadSha1) {
302 await run(['rm', '-rf', paths.downloads])
303 return `DOOM1.WAD is not the v1.9 shareware file (SHA-1 ${digest ?? 'unreadable'}); nothing was installed`
304 }
305
306 await run(['mv', wad, paths.shareware])
307 await run(['rm', '-rf', paths.downloads])
308 return undefined
309}
310
311/** Freedoom's licence notice, kept beside its WADs. */
312export const FREEDOOM_COPYING = 'freedoom-COPYING.txt'
313
314/**
315 * Removes what `install` wrote, by name, and nothing else: a file the person
316 * put in the folder themselves stays, and so does the folder around it.
317 */
318export async function uninstall(run: Run, files: Files, paths: Paths): Promise<{ removed: string[]; kept: string[] }> {
319 const owned = [
320 paths.bin,
321 paths.repo,
322 `${paths.repo}.partial`,
323 paths.downloads,
324 paths.freedoom1,
325 paths.freedoom2,
326 `${paths.dir}/${FREEDOOM_COPYING}`,
327 paths.shareware,
328 // The game's own leavings: a save directory on every start, the cfg files
329 // when it is quit from its menu.
330 `${paths.dir}/.savegame`,
331 `${paths.dir}/default.cfg`,
332 `${paths.dir}/doom.cfg`,
333 ]
334 const removed: string[] = []
335 for (const path of owned) {
336 if (!(await files.exists(path))) continue
337 await run(['rm', '-rf', path])
338 removed.push(path.slice(paths.dir.length + 1))
339 }
340
341 if (!(await files.exists(paths.dir))) return { removed, kept: [] }
342 const listing = await run(['ls', '-A', paths.dir])
343 const kept = listing.stdout.split('\n').filter(Boolean)
344 if (kept.length === 0) await run(['rmdir', paths.dir])
345 return { removed, kept }
346}
347
348/** Starts the game in a fresh detached session; resolves an error, or nothing. */
349export async function start(run: Run, session: string, paths: Paths, wad: string): Promise<string | undefined> {
350 await stop(run, session)
351 const result = await run([
352 ...TMUX,
353 'new-session', '-d', '-s', session,
354 // The game's own directory, never the session's: doomgeneric writes
355 // `.savegame/` and its cfg files relative to the cwd it inherits, which
356 // would otherwise be whatever repository Claude Code was started in.
357 '-c', paths.dir,
358 '-x', String(GRID.columns), '-y', String(WINDOW_ROWS),
359 paths.bin, '-iwad', wad, '-charset', 'quadrant', '-color', '24bit', '-columns', String(GRID.columns),
360 ])
361 return result.exitCode === 0 ? undefined : result.stderr.trim() || `tmux exited ${result.exitCode}`
362}
363
364/**
365 * The visible frame as text with its colour escapes, or nothing once the game
366 * is gone. `-N` keeps each row's trailing spaces: without it tmux trims them,
367 * coloured or not, and a row ending in one flat colour comes back short.
368 *
369 * A failure carries tmux's own words: the game ending and a tmux too old for
370 * `-N` both stop the frames, and only the second says so.
371 */
372export async function capture(run: Run, session: string): Promise<{ frame?: string; error?: string }> {
373 const result = await run([...TMUX, 'capture-pane', '-t', session, '-p', '-e', '-N', '-S', '0', '-E', String(GRID.rows - 1)])
374 if (result.exitCode === 0) return { frame: result.stdout }
375 return { error: result.stderr.trim() || `capture-pane exited ${result.exitCode}` }
376}
377
378export async function sendKeys(run: Run, session: string, keys: readonly string[]): Promise<void> {
379 if (keys.length === 0) return
380 const result = await run([...TMUX, 'send-keys', '-t', session, ...keys])
381 if (result.exitCode !== 0) throw new Error(`send-keys exited ${result.exitCode}: ${result.stderr.trim()}`)
382}
383
384export function isRunning(run: Run, session: string): Promise<boolean> {
385 return succeeds(run, [...TMUX, 'has-session', '-t', session])
386}
387
388export async function stop(run: Run, session: string): Promise<void> {
389 await succeeds(run, [...TMUX, 'kill-session', '-t', session])
390}
391hooks/frame.ts 254 lines1/**
2 * Turns a `tmux capture-pane -p -e -N` frame into a Raster's `cells`: standard
3 * base64 of little-endian u32 triplets `[codePoint, foreground, background]`,
4 * row-major, exactly `columns * rows` of them.
5 */
6
7export type Grid = { columns: number; rows: number }
8
9type Pen = { fg: number; bg: number }
10
11// The picture is the game's, not the terminal's: a cell with no colour of its
12// own is black, so a light theme does not show through the frame.
13const BLACK = 0x000000
14
15const SPACE = 0x20
16const UPPER_HALF = 0x2580
17const FULL_BLOCK = 0x2588
18
19// doom-cli's quadrant glyphs by lit sub-cells: bit 0 upper left, 1 upper
20// right, 2 lower left, 3 lower right, as its `quadrants[]` table orders them.
21const QUADRANT_MASK = new Map<number, number>([
22 [SPACE, 0], [0x2598, 1], [0x259d, 2], [0x2580, 3],
23 [0x2596, 4], [0x258c, 5], [0x259e, 6], [0x259b, 7],
24 [0x2597, 8], [0x259a, 9], [0x2590, 10], [0x259c, 11],
25 [0x2584, 12], [0x2599, 13], [0x259f, 14], [FULL_BLOCK, 15],
26])
27
28const BASIC = [
29 0x000000, 0xcd0000, 0x00cd00, 0xcdcd00, 0x0000ee, 0xcd00cd, 0x00cdcd, 0xe5e5e5,
30 0x7f7f7f, 0xff0000, 0x00ff00, 0xffff00, 0x5c5cff, 0xff00ff, 0x00ffff, 0xffffff,
31]
32
33function xterm256(n: number): number {
34 if (n < 16) return BASIC[n] ?? BLACK
35 if (n >= 232) {
36 const v = 8 + (n - 232) * 10
37 return (v << 16) | (v << 8) | v
38 }
39 const i = n - 16
40 const level = (c: number) => (c === 0 ? 0 : 55 + c * 40)
41 return (level(Math.floor(i / 36)) << 16) | (level(Math.floor(i / 6) % 6) << 8) | level(i % 6)
42}
43
44function applySgr(params: string, pen: Pen): void {
45 const p = params === '' ? [0] : params.split(';').map(n => Number(n) || 0)
46
47 for (let i = 0; i < p.length; i++) {
48 const code = p[i] ?? 0
49
50 if (code === 0) {
51 pen.fg = BLACK
52 pen.bg = BLACK
53 } else if (code === 39) {
54 pen.fg = BLACK
55 } else if (code === 49) {
56 pen.bg = BLACK
57 } else if (code === 38 || code === 48) {
58 let color: number | undefined
59 if (p[i + 1] === 2) {
60 color = ((p[i + 2] ?? 0) << 16) | ((p[i + 3] ?? 0) << 8) | (p[i + 4] ?? 0)
61 i += 4
62 } else if (p[i + 1] === 5) {
63 color = xterm256(p[i + 2] ?? 0)
64 i += 2
65 }
66 if (color !== undefined) {
67 if (code === 38) pen.fg = color
68 else pen.bg = color
69 }
70 } else if (code >= 30 && code <= 37) {
71 pen.fg = BASIC[code - 30] ?? BLACK
72 } else if (code >= 90 && code <= 97) {
73 pen.fg = BASIC[code - 90 + 8] ?? BLACK
74 } else if (code >= 40 && code <= 47) {
75 pen.bg = BASIC[code - 40] ?? BLACK
76 } else if (code >= 100 && code <= 107) {
77 pen.bg = BASIC[code - 100 + 8] ?? BLACK
78 }
79 }
80}
81
82// A Raster refuses the whole tree over one cell that is not a width-1 BMP
83// character, so control characters blank out and anything past the BMP (the
84// sextant glyphs at U+1FB00) becomes a full block in its foreground colour.
85function glyphOf(codePoint: number): number {
86 if (codePoint < 0x20 || (codePoint >= 0x7f && codePoint < 0xa0)) return SPACE
87 if (codePoint > 0xffff) return FULL_BLOCK
88 return codePoint
89}
90
91function blankWords(grid: Grid, bg = BLACK): Uint32Array {
92 const words = new Uint32Array(grid.columns * grid.rows * 3)
93 for (let cell = 0; cell < grid.columns * grid.rows; cell++) {
94 words[cell * 3] = SPACE
95 words[cell * 3 + 1] = BLACK
96 words[cell * 3 + 2] = bg
97 }
98 return words
99}
100
101export function encode(words: Uint32Array): string {
102 return new Uint8Array(words.buffer).toBase64()
103}
104
105/**
106 * Paints the captured text over a black grid. SGR state carries across lines
107 * as it would on a terminal; rows and columns past the grid are dropped, and
108 * missing ones stay black.
109 */
110export function wordsOf(frame: string, grid: Grid): Uint32Array {
111 const words = blankWords(grid)
112 const pen: Pen = { fg: BLACK, bg: BLACK }
113 const lines = frame.split('\n')
114
115 for (let row = 0; row < grid.rows && row < lines.length; row++) {
116 const line = lines[row] ?? ''
117 let column = 0
118 let i = 0
119
120 while (i < line.length) {
121 if (line.charCodeAt(i) === 0x1b) {
122 const next = line[i + 1]
123 if (next === '[') {
124 let end = i + 2
125 while (end < line.length && !/[@-~]/.test(line[end] ?? '')) end++
126 if (line[end] === 'm') applySgr(line.slice(i + 2, end), pen)
127 i = end + 1
128 continue
129 }
130 if (next === ']') {
131 // OSC (a hyperlink, a title): skip to BEL or ESC \.
132 let end = i + 2
133 while (end < line.length && line.charCodeAt(end) !== 0x07 && line.charCodeAt(end) !== 0x1b) end++
134 i = line.charCodeAt(end) === 0x1b ? end + 2 : end + 1
135 continue
136 }
137 i += 2
138 continue
139 }
140
141 const codePoint = line.codePointAt(i) ?? SPACE
142 i += codePoint > 0xffff ? 2 : 1
143
144 if (column < grid.columns) {
145 const cell = (row * grid.columns + column) * 3
146 words[cell] = glyphOf(codePoint)
147 words[cell + 1] = pen.fg
148 words[cell + 2] = pen.bg
149 }
150 column++
151 }
152 }
153
154 return words
155}
156
157/** Unpacks quadrant cells into a pixel grid two wide and two tall per cell. */
158function pixelsOf(words: Uint32Array, grid: Grid): Uint32Array {
159 const width = grid.columns * 2
160 const pixels = new Uint32Array(width * grid.rows * 2)
161
162 for (let row = 0; row < grid.rows; row++) {
163 for (let column = 0; column < grid.columns; column++) {
164 const cell = (row * grid.columns + column) * 3
165 const glyph = words[cell] ?? SPACE
166 const fg = words[cell + 1] ?? BLACK
167 const bg = words[cell + 2] ?? BLACK
168 const mask = QUADRANT_MASK.get(glyph) ?? (glyph === SPACE ? 0 : 15)
169 const top = row * 2 * width + column * 2
170 pixels[top] = mask & 1 ? fg : bg
171 pixels[top + 1] = mask & 2 ? fg : bg
172 pixels[top + width] = mask & 4 ? fg : bg
173 pixels[top + width + 1] = mask & 8 ? fg : bg
174 }
175 }
176
177 return pixels
178}
179
180/**
181 * Resamples a frame to another cell grid. The same grid passes through as
182 * drawn; any other becomes upper-half blocks, one exact colour per half, each
183 * sampled from the nearest source pixel. The caller keeps the proportions.
184 */
185export function fitWords(words: Uint32Array, source: Grid, target: Grid): Uint32Array {
186 if (source.columns === target.columns && source.rows === target.rows) return words
187
188 const pixels = pixelsOf(words, source)
189 const width = source.columns * 2
190 const height = source.rows * 2
191 const out = new Uint32Array(target.columns * target.rows * 3)
192
193 for (let row = 0; row < target.rows; row++) {
194 const top = Math.min(height - 1, Math.floor(((row + 0.25) * height) / target.rows))
195 const bottom = Math.min(height - 1, Math.floor(((row + 0.75) * height) / target.rows))
196 for (let column = 0; column < target.columns; column++) {
197 const x = Math.min(width - 1, Math.floor(((column + 0.5) * width) / target.columns))
198 const cell = (row * target.columns + column) * 3
199 out[cell] = UPPER_HALF
200 out[cell + 1] = pixels[top * width + x] ?? BLACK
201 out[cell + 2] = pixels[bottom * width + x] ?? BLACK
202 }
203 }
204
205 return out
206}
207
208/**
209 * The largest grid of the source's proportions that fits the room, never
210 * larger than the source: shrinking loses detail, growing only adds blur.
211 */
212export function fitGrid(source: Grid, room: Grid): Grid {
213 if (room.columns >= source.columns && room.rows >= source.rows) return source
214 const columns = Math.max(1, Math.min(room.columns, Math.floor((room.rows * source.columns) / source.rows)))
215 const rows = Math.max(1, Math.min(room.rows, Math.round((columns * source.rows) / source.columns)))
216 return { columns, rows }
217}
218
219/**
220 * How many rows of the capture carry anything: the last line with any bytes.
221 * doom-cli opens every frame with a clear screen, and tmux applies the ~60 KB
222 * that follows in chunks, so a capture taken mid-frame ends early.
223 */
224export function paintedRowsOf(frame: string): number {
225 const lines = frame.split('\n')
226 for (let row = lines.length - 1; row >= 0; row--) {
227 if ((lines[row] ?? '').length > 0) return row + 1
228 }
229 return 0
230}
231
232/**
233 * A dark grid with lines of text centred on it: what the Raster shows before
234 * the first captured frame arrives.
235 */
236export function bannerWords(lines: readonly string[], grid: Grid, fg = 0xcc2200): Uint32Array {
237 const words = blankWords(grid)
238
239 const top = Math.max(0, Math.floor((grid.rows - lines.length) / 2))
240 lines.forEach((text, index) => {
241 const row = top + index
242 if (row >= grid.rows) return
243 const glyphs = [...text].slice(0, grid.columns)
244 const left = Math.max(0, Math.floor((grid.columns - glyphs.length) / 2))
245 glyphs.forEach((glyph, offset) => {
246 const cell = (row * grid.columns + left + offset) * 3
247 words[cell] = glyphOf(glyph.codePointAt(0) ?? SPACE)
248 words[cell + 1] = fg
249 })
250 })
251
252 return words
253}
254hooks/keys.ts 75 lines1/**
2 * Maps a key a `Client` heard to the key name `tmux send-keys` takes.
3 */
4
5export type KeyPress = { key: string; ctrl?: boolean }
6
7const NAMED: Readonly<Record<string, string>> = {
8 up: 'Up',
9 down: 'Down',
10 left: 'Left',
11 right: 'Right',
12 return: 'Enter',
13 enter: 'Enter',
14 tab: 'Tab',
15 backspace: 'BSpace',
16 delete: 'DC',
17 space: 'Space',
18 ' ': 'Space',
19 pageup: 'PPage',
20 pagedown: 'NPage',
21 home: 'Home',
22 end: 'End',
23}
24
25// doom-cli reads a terminal, which reports no Ctrl-as-a-key, so fire is Z
26// there. F fires too, as it did in the widget this mod replaces.
27const FIRE = new Set(['f', 'F', 'z', 'Z'])
28
29// Escape never reaches a Client: it hands the keyboard back to the prompt.
30// Backtick stands in for Doom's own Escape, which opens the menu.
31const MENU = '`'
32
33export function tmuxKeyOf(press: KeyPress): string | undefined {
34 const named = NAMED[press.key]
35 if (named) return press.ctrl ? `C-${named}` : named
36 if (press.key === MENU) return 'Escape'
37 if (FIRE.has(press.key)) return 'Z'
38 // tmux reads a lone `;` argument as its own command separator.
39 if ([...press.key].length !== 1 || press.key === ';') return undefined
40 return press.ctrl ? `C-${press.key}` : press.key
41}
42
43/**
44 * The keys a focused pane can press without a click: a Button's `hotkey` is
45 * one lowercase letter or digit, and the arrows scroll the pane rather than
46 * reach it, so movement sits on WASD.
47 */
48export type Hotkey = { hotkey: string; label: string; tmux: string }
49
50export const ACTION_HOTKEYS: readonly Hotkey[] = [
51 { hotkey: 'w', label: '↑', tmux: 'Up' },
52 { hotkey: 'a', label: '←', tmux: 'Left' },
53 { hotkey: 's', label: '↓', tmux: 'Down' },
54 { hotkey: 'd', label: '→', tmux: 'Right' },
55 { hotkey: 'f', label: 'fire', tmux: 'Z' },
56 { hotkey: 'e', label: 'use', tmux: 'Space' },
57 { hotkey: 'r', label: 'enter', tmux: 'Enter' },
58 { hotkey: 'q', label: 'menu', tmux: 'Escape' },
59 { hotkey: 'y', label: 'yes', tmux: 'y' },
60]
61
62export const WEAPON_HOTKEYS: readonly Hotkey[] = ['fist', 'pistol', 'shotgun', 'chain', 'rocket', 'plasma', 'bfg'].map(
63 (label, index) => ({ hotkey: String(index + 1), label, tmux: String(index + 1) }),
64)
65
66/** Reads what the controls strip posted; anything malformed is dropped. */
67export function pressesOf(data: unknown): KeyPress[] {
68 if (!Array.isArray(data)) return []
69 return data.flatMap(item => {
70 if (typeof item !== 'object' || item === null) return []
71 const { key, ctrl } = item as { key?: unknown; ctrl?: unknown }
72 return typeof key === 'string' ? [{ key, ctrl: ctrl === true }] : []
73 })
74}
75hooks/controls.tsx 39 lines1import type { ClientModule, ClientKeyEvent } from 'claude-code'
2
3/**
4 * The strip under the screen. A Raster takes no keys, so this Client holds
5 * the keyboard once clicked and posts what it hears to the hooks module,
6 * which types it into the game.
7 */
8
9type State = { isArmed: true }
10
11// A post not yet delivered is replaced by the next one in the same frame, so
12// keys queue here and leave once per frame, never one post per key.
13const FLUSH_MS = 16
14
15const HINT = '▶ or click here for the whole keyboard: arrows, Z fire, space use, ` menu'
16
17const Controls: ClientModule<null, State> = (_props, surface) => {
18 if (surface.state === undefined) {
19 let queue: { key: string; ctrl: boolean }[] = []
20
21 surface.onKey((event: ClientKeyEvent) => {
22 queue.push({ key: event.key, ctrl: event.ctrl === true })
23 })
24
25 surface.every(FLUSH_MS, () => {
26 if (queue.length === 0) return
27 surface.post(queue)
28 queue = []
29 })
30
31 surface.setState({ isArmed: true })
32 }
33
34 const { Text } = surface.elements
35 return <Text dimColor>{HINT}</Text>
36}
37
38export default Controls
39