---
title: "doom"
url: "https://claudemods.dev/zh-cn/builds/doom-f5783c"
source: "https://github.com/reporails/arcade/tree/main/doom"
source_type: "github"
repo: "https://github.com/reporails/arcade/tree/main/doom"
author: "reporails"
author_handle: "reporails"
stars: 2
category: "其他"
categories: ["other"]
tags: []
permission_level: 3
scan_complete: true
install: "claude plugin marketplace add reporails/arcade\nclaude plugin install doom"
added: "2026-10-09T05:44:16.810Z"
updated: "2026-10-05T15:37:16Z"
locale: "zh-cn"
content_locale: "zh-cn"
---

# doom

Claude Code 窗格中的 Doom：在 doomgeneric 引擎上运行 Freedoom，在 kitty 和 Ghostty 中显示真实画面，其他终端则用方块字符绘制，Claude 工作时可用键盘和鼠标操作。提供 Linux、macOS 和 Windows 的预编译引擎。

## Description

# Doom

在 Claude Code 窗格中运行 Doom。这是一个以插件形式发布的模组：`/doom` 会打开一个窗格，在 doomgeneric 引擎上运行 Freedoom，同时 Claude 工作。在 kitty 或 Ghostty 中，屏幕是 Doom 原生 320×200 分辨率的真实画面；在其他终端中，则用四分之一方块字符绘制，每个字符单元对应四个像素。它不读取模型看到的任何内容，也不向模型添加任何内容。与其他街机游戏不同，它确实会接管提示框，但只在你游玩时生效：落在提示框中的游戏按键会改由 Doom 接收（输入 `/` 可以把提示框还给你）。它以子进程的方式运行引擎，并且只在本机通过 Unix socket 或 127.0.0.1 与之通信。

![Doom 在 kitty 中与 Claude Code 会话并排停靠：窗格中是 Freedoom 的第一关，记录中是 Claude 的回答（根据会话的屏幕单元格和引擎帧绘制）](../docs/doom.png)

随附 Linux、macOS 和 Windows 的预编译引擎，每个平台都有 x86_64 和 arm64 两个版本，因此无需编译器。目前只在 Linux x86_64 引擎上实际运行过（见“验证情况”）。

## 试用

在 Claude Code 会话中：

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

然后以全屏布局启动 Claude Code，并运行 `/doom`：

```bash
CLAUDE_CODE_NO_FLICKER=1 claude
```

在 2.1.285 或 2.1.286 版本上（模组处于抢先体验阶段），请添加 `CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1`；从 2.1.287 起，模组默认加载。此模组只在 2.1.285 上运行过。从源码检出时：`claude --plugin-dir ./doom`。

| 平台 | 引擎 | 画面 |
|---|---|---|
| Linux x86_64 / arm64 | `engine/bin/linux-*/doom-claude`，静态链接（musl），适用于任何发行版 | kitty 或 Ghostty：真实画面；其他终端：方块 |
| macOS Apple silicon / Intel | `engine/bin/macos-*/doom-claude` | kitty 或 Ghostty：真实画面；Terminal.app 和 iTerm2：方块 |
| Windows x86_64 / arm64 | `engine/bin/windows-*/doom-claude.exe` | 方块（没有 Windows 终端支持模组所检测的 kitty 画面协议） |
| 其他安装了 C 编译器的平台 | 首次执行 `/doom` 时用 `make` 构建 | 同上 |

在 WSL 下，Claude Code 是 Linux 程序，使用 Linux 引擎。

在 kitty 或 Ghostty 中运行可获得完整画面；模组通过 `TERM` / `TERM_PROGRAM` 识别它们。如果 Claude Code 仍然拒绝显示画面（例如在 tmux 中），则回退为方块。在其他终端中，屏幕尺寸随终端高度变化：窗格高度与提示符上方的空间相同，屏幕行数是列数的 3/8（Doom 的 4:3 比例），窗格宽度收窄到屏幕宽度，使记录区保留其余空间。在 126×38 的终端上，大约为 72×27 到 85×32 个字符单元，即 144×54 到 170×64 像素。行数越多（字体更小或窗口更高），画面越清晰。

`CLAUDE_CODE_NO_FLICKER=1` 会启用全屏布局：窗格停靠在右侧，并响应鼠标点击。不设置该变量时，窗格位于提示符上方，只能用按钮键来玩 Doom。

## 命令

| 输入 | 作用 |
|---|---|
| `/doom` | 打开窗格并启动 Doom。如果 Doom 已在运行，则把窗格调回前台。 |
| `/doom quit` | 结束 Doom 并关闭窗格。 |
| `Esc` | 将键盘交还给 Claude Code 的提示框（Claude Code 保留 Escape 用于此功能，任何模组都无法接管）。你继续按下的游戏键仍会送达 Doom：游玩时，模组会把它们从提示框中拦截并交给游戏。 |
| Ctrl+X 然后 X | 关闭窗格，同时结束 Doom。 |

`/doom` 是即时命令，因此在 Claude 回合进行中也能使用。

## 游玩

鼠标负责转向，键盘负责移动。终端能告知鼠标按键何时按下、何时松开，却无法告知按键的松开；而转向需要精确停止，因此转向由鼠标负责。

| 鼠标（在游戏上） | Doom |
|---|---|
| 按住左键并向左或向右拖动 | 转向，速度与 `a` 和 `d` 完全一致（按住的按键在前六分之一秒内为半速，与 Doom 对按住按键转向的处理一致），拖动距离不限；上下拖动无效 |
| ……同时按住 Shift | 平移而非转向 |
| 松开 | 立即停止转向 |
| 按住右键 | 开火，按住期间持续射击 |

拖动从按键按下的位置开始计算，按键按住后，即使拖出游戏画面的边缘，拖动仍然有效。游戏画面下方的红色条带同样接受拖动，并显示操纵杆的作用（“◉ turn right”）。拖动时可以同时用 `w` 和 `s` 行走：键盘和鼠标可同时生效。在游戏或条带上单击后，Doom 也会获得键盘输入：

| 按键 | Doom |
|---|---|
| 空格 | 开火 |
| `e` | 使用（门、开关） |
| `1` 到 `7` | 武器 |
| `m` 或退格键 | 菜单 |
| 回车 | 在菜单中确认；对 Doom 的询问回答“是”（退出、新游戏、噩梦难度） |
| 方向键、`w` `a` `s` `d` | 移动与转向，按终端允许的方式持续按住（见下文） |
| `,` `.` | 平移 |
| Tab | 地图 |
| `p` | 暂停 |
| `y` `n` | 是（发送回车）、否 |
| `Esc` | 将键盘交还给提示框；游玩时游戏键仍会送达 Doom（见下文） |

不单击时，游戏画面下方条带上的按钮会响应对应字母：`m` 菜单，`o` 确定，`w` `a` `s` `d`，`e` 使用；回车会按下“确定”，从而保持窗格的焦点环。其他游戏键（空格、数字、`,` `.`）会落入 Claude Code 的提示框，Esc 之后的每个按键也一样。因此在游玩期间（游戏在最近 10 秒内接收过输入），提示框归 Doom 所有（一个 `prompt.edit` 钩子）：落在其中的游戏键会送给 Doom，其他按键被丢弃，提示框中不会留下任何内容。输入 `/` 可立即收回提示框（用于 `/doom quit`，或删除它后给 Claude 发消息），或者 10 秒内不操作即可自动恢复。你之前输入的草稿永远不会被改动。没有单击时，方向键不会送达 Doom：在提示框中，它们用于调出之前的提示词。按 `m`，然后连续三次回车，即可以默认难度开始新游戏。引擎把 Enter 设为 Doom 的确认键，因此回车表示“是”；`n` 表示“否”。鼠标需要全屏布局（`CLAUDE_CODE_NO_FLICKER=1`）。

游戏标题演示期间，任意键都会打开菜单（这是 Doom 自身的规则）；菜单打开时，`m`（Escape）会将其关闭。

终端只发送按下及其自动重复，不发送松开事件（在 GNOME 上，自动重复在半秒后开始，之后每 30 毫秒一次），并且只重复最新按下的键：`w` 按住时若又按下 `a`，那么 `w` 便不再重复，无论它是否仍被按着。因此，与 [doom-cli](https://github.com/ludocode/doom-cli) 的做法一样，一个按键在下一次重复到来之前都被视为按住：移动或转向键被按下后会一直保持按住，直到终端的第一次重复本应到来（即终端的重复延迟，由引擎从它发出的重复中学习，再加 60 毫秒）；之后每次重复额外保持 160 毫秒。因此按住的键不会突然停止，而松开的键会在其最后一次重复之后的六分之一秒停止。游玩时，转向键改由 Doom 的鼠标转向：在终端开始重复之前缓慢转动，之后则与 Doom 的方向键一样快（按住的按键前六分之一秒为半速，与 Doom 对按住按键的加速一致）。因此轻点一下约转动 10°，与在真实键盘上快速轻点 Doom 的效果相同；按住的键也不会停止（doom-cli 自己的源码也暗示了这一点：“just turn more slowly outside of state repeat”）。在菜单、标题演示或暂停状态下，转向键仍是普通按键，因此菜单滑块仍能接收它们。开火和使用键保持 120 毫秒，因此轻点即为一发。移动键之间互相排斥：按下 `w` `a` `s` `d`、方向键、`,` 或 `.` 中任一键，会立即松开其他所有移动键，因此转向键只负责转向，而你一转向，行走便会停止。同时行走和转向，则是一个行走键加上鼠标的横向拖动。开火、使用和武器键各自独立保持，且不会松开其他键：行走时按空格会开火，并继续行走，直到行走键的按住时间耗尽。

## 文件

```text
doom/
├── .claude-plugin/plugin.json
├── hooks/
│   ├── hooks.json            指向 register.ts
│   ├── register.ts           钩子：命令、窗格、帧拉取、按键
│   ├── pad.ts                界面模块：接收键盘的条带
│   └── lib.ts                纯函数：按键映射、屏幕尺寸、URL、引擎参数
├── engine/
│   ├── doomgeneric/          doomgeneric 引擎，未作修改（GPL-2.0）
│   ├── doomgeneric_claude.c  其平台层：以 Unix socket 或 127.0.0.1 上的 HTTP 代替窗口
│   ├── bin/<os>-<arch>/      预编译引擎：linux、macos、windows × x86_64、arm64
│   ├── build-all.sh          使用 zig cc 为所有平台构建 bin/
│   └── Makefile              为本机构建 ./doom-claude；没有合适的预编译引擎时，模组会运行它
├── wad/freedoom1.wad         Freedoom 第一阶段，0.13.0（BSD；wad/COPYING.freedoom）
├── data/                     首次运行时生成：Doom 的配置、存档、engine.log（引擎的最近一次运行）
├── tests/doom.test.ts        使用 `claude plugin test` 运行
└── README.md
```

它们的组合方式如下：

- **独立进程。** 模组的沙箱设计上没有 WebAssembly，因此游戏以原生程序运行。`/doom` 会判断机器环境（Windows 上为 `%OS%` 和 `%PROCESSOR_ARCHITECTURE%`，其他系统为 `uname -sm`），选择 `engine/bin/<os>-<arch>/doom-claude`；若没有合适的预编译引擎，则回退到用 `make` 在本机构建的版本（Windows 上从不如此），然后用 `$.process.spawn` 启动它。只要模组读取其输出，引擎就会一直运行：Claude Code 卸载模组时会结束它。引擎开始监听后会打印一行 `doom-claude listening`，模组等待这行输出。引擎的所有输出都会被保留，并在引擎结束时写入 `data/engine.log`；若引擎异常结束，窗格中显示最后一行。
- **钩子如何与它通信。** 在 Linux 和 macOS 上，使用会话运行时目录（`XDG_RUNTIME_DIR`）中的 Unix socket；否则使用用户临时目录（`TMPDIR`，macOS 上即是如此）；再否则使用 `data/`：选取第一个能容纳 socket（约 100 字节）的路径。在 Windows 上，或者没有任何路径能容纳时，引擎会监听 127.0.0.1 上一个空闲端口（并打印 `doom-claude listening port=N`），且只接受携带 `X-Doom-Token` 请求头的请求。令牌是模组通过环境变量（`DOOM_CLAUDE_TOKEN`）交给它的随机 64 位数字；其他请求一律以 403 拒绝。`DOOM_CLAUDE_TRANSPORT=tcp` 会让 Linux 和 macOS 也使用 127.0.0.1，这也是在 Linux 上测试该路径的方式。
- **画面（kitty、Ghostty）。** 每 28 毫秒，钩子模块向引擎请求 `/image?since=…`。有更新的帧时，引擎会以原始 320×200 RGB 格式把它写入与 socket 相同的私有目录中的一个文件（先写到旁边，再重命名覆盖，因此不会读到写了一半的内容），并回复其编号。钩子模块把带键的 `Image` 替换为 `{ file, format: 'rgb', width: 320, height: 200, generation }`。Claude Code 把文件路径交给 kitty，由 kitty 自行读取像素，因此没有任何像素经过 Claude Code。连续十次替换被拒绝（`Image` 显示其替代文字）后，屏幕切换为方块。
- **帧（方块）。** 每 28 毫秒，钩子模块用 `$.http.fetch` 请求 `/frame?c=…&r=…`。引擎返回已编码为 `Raster` 单元的屏幕，钩子模块再用 `$.ui.blit` 把该文本绘制到挂载的 `Raster` 上。若帧不比上次绘制的更新（引擎只在画面变化时才计为新帧），则以空的 `204` 回应。
- **单元格。** 每个单元格为两乘两个像素，每个像素是它所覆盖的屏幕像素的平均值，并经过提亮（Doom 画面较暗，在方块模式下更暗）。单元格选用四分之一方块字形，以及最能匹配其四个像素的两种颜色。颜色来自一个 32 色调色板，它通过中位切分法从当前画面生成，并保留半秒：`Raster` 最多绘制 1024 种颜色对，超出的会被吸附到最接近的颜色，这会使画面出现斑点，而 32 乘 32 正好是 1024。若一个单元格的左邻居颜色匹配度几乎相同，它就直接沿用左邻的颜色，因为 Claude Code 的绘制速度会随着同一行内颜色变化次数的增加而变慢（实测：使用 64 色调色板且不复用时为每秒 12–14 帧；使用此方法时为每秒 20–35 帧，测试环境相同且负载较高）。没有空白单元格，因为 Claude Code 不绘制行尾的空白。
- **绘制。** 一次 blit 会在 Claude Code 的下一帧

## Install

```
claude plugin marketplace add reporails/arcade
claude plugin install doom
```

## Original 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)](../docs/doom.png)

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:

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

Then start Claude Code in the fullscreen layout and run `/doom`:

```bash
CLAUDE_CODE_NO_FLICKER=1 claude
```

On 2.1.285 or 2.1.286, where mods are early access, add `CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1`; from 2.1.287 mods load by default. It has been played on 2.1.285 only. From a checkout: `claude --plugin-dir ./doom`.

| Platform | Engine | Picture |
|---|---|---|
| Linux x86_64 / arm64 | `engine/bin/linux-*/doom-claude`, static (musl): any distribution | kitty or Ghostty: the picture; elsewhere blocks |
| macOS Apple silicon / Intel | `engine/bin/macos-*/doom-claude` | kitty or Ghostty: the picture; Terminal.app and iTerm2: blocks |
| Windows x86_64 / arm64 | `engine/bin/windows-*/doom-claude.exe` | blocks (no Windows terminal speaks kitty's picture protocol that the mod detects) |
| Anything else with a C compiler | built on first `/doom` with `make` | as above |

Under WSL, Claude Code is a Linux program and uses the Linux engine.

Run it in kitty or Ghostty for the full picture; the mod finds them by `TERM` / `TERM_PROGRAM`, and falls back to blocks if Claude Code refuses the picture anyway (as it does inside tmux). In any other terminal the screen's size follows the terminal's height: the dock is as tall as the space above the prompt, the screen is 3/8 as many rows as columns (Doom's 4:3), and the pane narrows to the screen's width so the transcript keeps the rest. On a 126×38 terminal that is about 72×27 to 85×32 cells, 144×54 to 170×64 pixels. More rows (a smaller font, or a taller window) give a sharper picture.

`CLAUDE_CODE_NO_FLICKER=1` turns on the fullscreen layout: the pane docks on the right and takes clicks. Without it the pane sits above the prompt and Doom is played with the button keys alone.

## Commands

| Input | What it does |
|---|---|
| `/doom` | Opens the pane and starts Doom. If Doom is running, brings the pane back. |
| `/doom quit` | Ends Doom and closes the pane. |
| `Esc` | Gives the keyboard back to Claude Code's prompt (Claude Code keeps Escape for that; no mod can take it). Game keys you go on pressing still reach Doom: while you play, the mod takes them out of the prompt and hands them to the game. |
| Ctrl+X then X | Closes the pane, which ends Doom. |

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

## 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 game | Doom |
|---|---|
| Hold the left button and drag left or right | Turn, exactly as fast as `a` and `d` (half speed for the first sixth of a second, as Doom turns a held key), however far you drag; an up or down drag does nothing |
| …with shift held | Strafe instead of turn |
| Let go | Stop turning, at once |
| Hold the right button | Fire, for as long as it is held |

The drag is measured from where the button went down, and keeps working past the edge of the game once the button is down. The red strip under the game takes the same drags and says what the stick is doing ("◉ turn right"). Walk with `w` and `s` while you drag: the keys and the mouse work at once. A click on the game or the strip also gives Doom the keyboard:

| Key | Doom |
|---|---|
| Space | Fire |
| `e` | Use (doors, switches) |
| `1` to `7` | Weapons |
| `m` or backspace | Menu |
| Return | Pick in a menu; yes to Doom's questions (quit, new game, nightmare) |
| Arrows, `w` `a` `s` `d` | Move and turn, held the way a terminal allows (below) |
| `,` `.` | Strafe |
| Tab | Map |
| `p` | Pause |
| `y` `n` | Yes (sends Return), no |
| `Esc` | Hands the keyboard back to the prompt; game keys still reach Doom while you play (below) |

Without a click, the buttons under the strip answer their letters: `m` menu, `o` ok, `w` `a` `s` `d`, `e` use; and Return presses `ok`, which holds the pane's focus ring. Every other game key (space, the digits, `,` `.`) lands in Claude Code's prompt, and so does every key after Esc. So while you play (the game had input in the last 10 seconds), the prompt is Doom's (a `prompt.edit` hook): a game key that lands there goes to Doom, any other key is dropped, and nothing stays in the prompt. Type `/` to have it back at once (for `/doom quit`, or delete it and write to Claude), or leave the game alone for 10 seconds. A draft you had typed before is never touched. The arrows never reach Doom without a click: in the prompt they recall earlier prompts. `m`, then Return three times, starts a new game on the default skill. The engine makes Enter Doom's confirm key, so Return answers yes; `n` answers no. The mouse needs the fullscreen layout (`CLAUDE_CODE_NO_FLICKER=1`).

During the title demo any key opens the menu (Doom's own rule), and `m` (Escape) closes it again when it is open.

A terminal sends no key-up, only a press and then its auto-repeat (half a second later on GNOME, then every 30 ms), and it repeats only the newest key: once `a` is pressed while `w` is held, `w` goes quiet whether it is still held or not. So, as [doom-cli](https://github.com/ludocode/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

```text
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, Ghostty, the desktop app (it gets a message instead of a screen), 2.1.287 or later, and how the timed holds feel in real play.

## Licence

Three licences, by folder:

- `hooks/` and `tests/`: MIT, as the rest of this repository (`../LICENSE`).
- `engine/`: GPL-2.0-or-later. The engine is [doomgeneric](https://github.com/ozkl/doomgeneric) (`engine/doomgeneric/LICENSE`), and its platform layer `engine/doomgeneric_claude.c` and the prebuilt binaries under `engine/bin/` are built with it, so they carry the same licence. The mod only runs the engine as a separate program.
- `wad/freedoom1.wad`: Freedoom, BSD-3-Clause (`wad/COPYING.freedoom`).

"Doom" is id Software's name for its game. This mod plays Freedoom, a free game made for Doom engines, and contains nothing of id's.
