---
title: "doom"
url: "https://claudemods.dev/zh-tw/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-tw"
content_locale: "zh-tw"
---

# 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` 或 Backspace | 選單 |
| Enter | 在選單中確認；對 Doom 的詢問回答「是」（退出、新遊戲、夢魘難度） |
| 方向鍵、`w` `a` `s` `d` | 移動與轉向，依終端機允許的方式持續按住（見下文） |
| `,` `.` | 平移 |
| Tab | 地圖 |
| `p` | 暫停 |
| `y` `n` | 是（送出 Enter）、否 |
| `Esc` | 將鍵盤交還給提示框；遊玩時遊戲鍵仍會送達 Doom（見下文） |

不點擊時，遊戲畫面下方條帶上的按鈕會回應對應的字母：`m` 選單，`o` 確定，`w` `a` `s` `d`，`e` 使用；Enter 會按下「確定」，從而保持面板的焦點環。其他遊戲鍵（空白鍵、數字、`,` `.`）會落入 Claude Code 的提示框，Esc 之後的每個按鍵也一樣。因此在遊玩期間（遊戲在最近 10 秒內收到過輸入），提示框歸 Doom 所有（一個 `prompt.edit` 掛鉤）：落在其中的遊戲鍵會送給 Doom，其他按鍵會被丟棄，提示框中不會留下任何內容。輸入 `/` 可立即收回提示框（用於 `/doom quit`，或刪除它後改對 Claude 輸入），或者 10 秒內不操作即可自動恢復。你先前輸入的草稿永遠不會被更動。沒有點擊時，方向鍵不會送達 Doom：在提示框中，它們用來叫出先前的提示詞。按 `m`，然後連按三次 Enter，即可以預設難度開始新遊戲。引擎把 Enter 設為 Doom 的確認鍵，因此 Enter 表示「是」；`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.
