SLOPSHOPPER

doom

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…

newpanecommandpromptprocessnetwork
★ 2v0.1.0MIT AND GPL-2.0-or-later AND BSD-3-Clauseupdated 2026-10-05reporails/arcade/doom
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · doom
│ ┃ Doom ✕ › fix the failing auth test and add an audit log call │ ┃ │ ┃ ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ › /doom │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ▣ client module ./pad.ts │ ┃ m: menu o: ok w: ↑ a: ← s: ↓ d: → e: u │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Doom
▣ cl ▣ client module ./pad.ts m: menu o: ok w: ↑ a: ← s: ↓ d: → e: use
README

Doom

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.

Doom docked beside a Claude Code session in kitty: Freedoom's first level in the pane, Claude's answer in the transcript (rendered from the session's screen cells and the engine's frame)

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).

Try it

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.

PlatformEnginePicture
Linux x86_64 / arm64engine/bin/linux-*/doom-claude, static (musl): any distributionkitty or Ghostty: the picture; elsewhere blocks
macOS Apple silicon / Intelengine/bin/macos-*/doom-claudekitty or Ghostty: the picture; Terminal.app and iTerm2: blocks
Windows x86_64 / arm64engine/bin/windows-*/doom-claude.exeblocks (no Windows terminal speaks kitty's picture protocol that the mod detects)
Anything else with a C compilerbuilt on first /doom with makeas 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.

Commands

InputWhat it does
/doomOpens the pane and starts Doom. If Doom is running, brings the pane back.
/doom quitEnds Doom and closes the pane.
EscGives 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 XCloses the pane, which ends Doom.

/doom is an immediate command, so it works while Claude is mid-turn.

Playing

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 gameDoom
Hold the left button and drag left or rightTurn, 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 heldStrafe instead of turn
Let goStop turning, at once
Hold the right buttonFire, 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:

KeyDoom
SpaceFire
eUse (doors, switches)
1 to 7Weapons
m or backspaceMenu
ReturnPick in a menu; yes to Doom's questions (quit, new game, nightmare)
Arrows, w a s dMove and turn, held the way a terminal allows (below)
, .Strafe
TabMap
pPause
y nYes (sends Return), no
EscHands 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.

Files

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:

  • A process of its own. A mod's sandbox has no WebAssembly, by design, so the game runs as a native program. /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.
  • How the hooks reach it. On Linux and macOS, a Unix socket in the session's runtime directory (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.
  • Picture (kitty, Ghostty). Every 28 ms the hooks module asks the engine for /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.
  • Frames (blocks). Every 28 ms the hooks module fetches /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.
  • The cells. Each cell is two by two pixels, each pixel the mean of the screen pixels it covers, brightened (Doom is dark, and darker in blocks). The cell takes the quadrant glyph and the two colours that fit its four pixels best. The colours come from a 32-colour palette made by median cut from the picture in view and kept for half a second: a 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.
  • Painting. A blit is painted at Claude Code's next frame, and with nothing else moving Claude Code draws about three frames a second, so the game froze for about 300 ms at a time. The strip under the screen redraws itself every 30 ms (a blank that alternates between two blank characters), which makes Claude Code draw a frame each time. A blit resolves once it is painted, so one blit is in flight at a time, a newer frame waits for it, and the next fetch does not wait for the paint.
  • The pointer over the game. A surface module (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.
  • Keys and the stick. Each instance of 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.
  • The pane's width. The pane opens 90 columns wide; its first drawing works out the screen its height allows and asks the dock for that width, once. The later request for the keyboard keeps that width.
  • Ending. Closing the pane, /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.

What has been verified

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):

  • In tmux with blocks: the picture changed 35–40 times a second (the screen sampled every 10 ms), the longest wait 75–106 ms; Claude Code used about a full core, the engine 36–38%.
  • In kitty 0.32.2 with the picture: 31–32 picture swaps a second reached kitty, the longest wait 75–86 ms; Claude Code used 31–42% of a core (not the 7% measured before the cross-platform rework, which this run did not reproduce), the engine 20–23%.

Since a turn key turns slowly until it repeats (the linux-x86_64 engine only so far):

  • A tap of 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:

  • A right-turn key against the engine on its own, keys sent the way a terminal sends them, the facing and the keys down read back from /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°.
  • With a terminal repeating after 250 ms, on a fresh engine: the first hold taught it 255 ms, and a tap then turned 29.9°, let go at 321 ms.
  • Movement keys still take turns (a pressed lets w go; space while walking keeps both down), and claude plugin test passes 19 of 19.
  • The mouse against the keys, on the engine alone: a drag to the right and a held 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.
  • All six engines cross-compile with zig 0.17 (build-all.sh): static ELF for Linux, Mach-O for macOS, PE32+ console programs for Windows.
  • The engine alone on Linux, over both: a Unix socket (/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.
  • Live on Linux in tmux (2.1.285), with the prebuilt static 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.
  • Frame rates in those runs were low (about 4 a second reached the pane), on a machine at load 16–22 on 8 cores. The engine's own cost per frame is unchanged: 12 ms of CPU to encode a 72×27 frame on the static musl engine, 10–11 ms on a glibc build. The musl engine spends more on the game itself, 19–20% of a core against 12–13%.
  • Not run: the macOS and Windows engines (no such machine here). On Windows the 127.0.0.1 path is the one tried on Linux above; the Winsock code around it has only been compiled.

Before that change, on the daemon build:

  • claude plugin validate passes on 2.1.285.
  • The key holds against the engine on its own, keys sent the way a terminal sends them (a press, the first repeat 500 ms later, then every 30 ms, only the newest key repeating), read back from /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.
  • The stick on the engine alone, from its frames: holding a turn turned the view (9,192 of the view's pixels changed in 0.6 s), letting go stopped it (none changed in the next 0.4 s), and walking then firing took the player to the wall and the ammo from 50 to 48.
  • The stick in a live session in tmux, with mouse events as a terminal sends them: a drag up and right from the strip sent turn 63 and forward 31 and the strip read "◉ forward · turn right"; letting go sent zeros; the right button sent fire on its press and nothing on its release.
  • The pointer on the game itself, live. In tmux (blocks): the screen stayed drawn under the layer (33 rows), a drag up and left on the game sent turn −102 and forward 31, letting go sent zeros, the right button fired. In kitty 0.32.2 (the picture), with mouse events in pixels as kitty reports them there: a drag on the game sent turn 102 and forward 31 and the strip read "◉ forward · turn right", letting go sent zeros, the right button fired, and the picture kept being swapped meanwhile (35 swaps).
  • 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.
  • The encoder under AddressSanitizer at 40, 82 and 120 columns: no errors. On the normal build a frame takes 5–18 ms to encode at 82×30.
  • Played in a real interactive session on 2.1.285 inside tmux at 126×38, the size of the first live try, with the machine loaded by other work (load average 12–17 on 8 cores):
  • The pane fitted to the screen: a 74-column body for a 72×27 screen, leaving the transcript 51 columns.
  • During the title demo the engine drew about 55 new frames a second and about 28–33 reached the pane. Sampling the screen every 10 ms, the picture changed 28–33 times a second, the longest wait between changes 78–148 ms.
  • Holding Left, Up and Right in a game: 20–27 frames a second reached the pane, the longest visible wait 72–110 ms.
  • Before these changes the same setup froze: after about 30 s the picture changed 3–14 times a second with waits near 300 ms, while the engine was serving 30 frames a second.
  • In kitty 0.32.2 (a real window, Claude Code's output recorded with 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.
  • Inside tmux with TERM=xterm-kitty, Claude Code drew no picture and the mod fell back to blocks, as designed.
  • Esc closed the pane and ending the session stopped the engine and removed its socket and image file. When the session's process is killed instead, the engine exits on its own once nothing has asked it for 30 seconds.

Known limits

  • Blocks outside kitty and Ghostty. A cell is the smallest thing a terminal draws; quadrant blocks split it into four pixels in two colours. On a terminal with a large font that is about 150×60 pixels, a quarter of Doom's own. Claude Code paints every block frame itself: 50–90% of a core, about 30 frames a second at best and fewer when the machine is busy, and 32 colours at a time, each rounded to 4 bits a channel. Inside tmux, Claude Code draws 256 colours unless it is started with TMUX unset and COLORTERM=truecolor, which is how the sessions above were run.
  • Keys a terminal cannot send. Ctrl, Shift and Alt never arrive alone, so fire is space and there is no run key. Without key releases, the keyboard cannot walk and turn at once; a walking key and the mouse can. Escape returns the keyboard instead of reaching Doom, so the menu is 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.
  • Not used: sound.
  • Windows draws blocks. No Windows terminal the mod detects speaks kitty's picture protocol, so Windows gets the quadrant blocks. WezTerm speaks it, but the mod does not detect it, and whether Claude Code would send it an Image is untried.
  • Not verified: the macOS and Windows engines on a real machine, gnome-terminal outside tmux since the block changes, Ghost
Source 3 files
hooks/register.ts 721 lines
1// 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}
721
hooks/pad.ts 165 lines
1// 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
165
hooks/lib.ts 396 lines
1// 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