SLOPSHOPPER

doom

The original Doom engine, with Freedoom Phase 1, playable inside Claude Code. Run /doom.

newpanecommandprocessnetworktimer
v0.1.0-alpha.2GPL-2.0-or-laterupdated 2026-09-20ChaseWNorton/claude-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 │ ┃ DOOM / FREEDOOM · local · no model calls │ ┃ Loading the game… ⏺ 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 │ ┃ ⎿ doom: Doom: JSON Parse error: Unexpected identifier "dev" │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ▣ client module ./controls.ts │ ┃ [ Pause ] [ Restart game ] [ Close game ] │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · doom
DOOM / FREEDOOM · local · no model calls Loading the game… ▣ client module ./controls.ts [ Pause ] [ Restart game ] [ Close game ] Esc → Claude
README

DOOM for Claude Code

The original Doom engine. Freedoom's freely licensed game assets. A live game pane inside Claude Code, opened with /doom.

Mac Apple Silicon alpha · macOS 14+ · Node.js 22+ · Claude login required

Doom gameplay captured from a real Claude Code terminal pane

The clip is rendered from real terminal output during gameplay. The engine runs locally; playing makes no model calls.

Play the alpha

Download v0.1.0-alpha.2 for Mac Apple Silicon, extract it, then run this from the extracted claude-doom folder:

bash scripts/play.sh

Enter /doom, then click the keyboard strip below the game. Escape gives the keyboard back to Claude. Use a terminal with true color and mouse reporting, preferably at least 110 columns × 50 rows.

The ZIP includes the compiled engine, full corresponding source, Freedoom assets, and licenses. No compiler is needed. The launcher checks the platform and binary checksum, uses Claude Code 2.1.278 if installed, or installs that exact version into this folder. It enables Mods and fullscreen terminal rendering for that session. Claude Code is downloaded separately from Anthropic and requires your own login.

To verify the download in a terminal, download the neighboring .zip.sha256 asset from the release page and run:

shasum -a 256 -c claude-doom-v0.1.0-alpha.2-darwin-arm64.zip.sha256

Install through Claude's marketplace

Start the tested Claude version with Mods and mouse-capable rendering enabled:

CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 CLAUDE_CODE_NO_FLICKER=1 \
  npx --yes @anthropic-ai/claude-code@2.1.278

Then run these commands inside Claude:

/plugin marketplace add ChaseWNorton/claude-doom
/plugin install doom@faros-labs
/reload-plugins
/doom

Choose the installation scope in Claude's install dialog. Keep the two environment flags when starting future sessions. If an organization disables third-party plugins or function hooks, this mod cannot override that policy. Claude marketplace documentation.

Controls

KeyAction
W / S, Up / DownMove forward / backward
A / D, Left / RightTurn
Q / EStrafe
SpaceFire
FUse / open doors
1–7Select weapon
M / EnterGame menu / select
TabAutomap
PPause / resume
EscapeGive the keyboard back to Claude

The pane also has Pause, Restart game, and Close game buttons. From Claude's composer, use /doom pause, /doom restart, or /doom close. Restart begins episode 1, map 1, medium difficulty.

Alpha limits

  • Silent: audio is not implemented in this adapter.
  • Temporary saves: saves and settings are deleted when the game closes.
  • Terminal controls: key repeat approximates held keys; movement is less precise than in a dedicated game window.
  • Mac Apple Silicon only: Intel Mac, Linux, and Windows binaries are not included in this release.
  • Early-access API: Claude Code 2.1.278 is the tested runtime. Other versions may need adapter changes.

If clicks do nothing, use the launcher or the environment flags above, enable your terminal's mouse reporting, and check that CLAUDE_CODE_DISABLE_MOUSE, CLAUDE_CODE_DISABLE_MOUSE_CLICKS, and CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN are not disabling interaction. Fullscreen renderer documentation.

How it works

hooks/register.ts opens a native Mod pane. A Node bridge starts the compiled doomgeneric engine, reads its actual 320×200 framebuffer through a pipe, and converts it to half-block terminal pixels. Claude's Raster and $.ui.blit display the game. A focused Client module sends keyboard input back to the engine.

The bridge listens on an ephemeral 127.0.0.1 port with a random per-game token. It makes no external requests. Game frames are not added to the conversation. Closing the mod ends the game, and an abandoned bridge exits after 60 seconds without a client. This all happens within a normal authenticated Claude session.

Build and verify

Building from source additionally requires a C compiler and Git. On macOS, install Apple's Command Line Tools. From a clone:

npm run setup:dev
npm run build
npm test
npm run typecheck
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 .runtime/node_modules/.bin/claude plugin test .
npm run package
npm run test:package

Development setup downloads the pinned, checksum-verified API declarations from Anthropic and installs the tested Claude runtime and TypeScript under .runtime. Those files are not redistributed. See Anthropic's Mods API.

Native tests verify actual movement, rotation, ammunition, pause/resume, restart, raster output, authorization, and process cleanup. Package tests extract the ZIP into a fresh directory and exercise its included engine without compiling. The official Mod test verifies pane rendering and keyboard forwarding. A Mac CI job repeats these checks on a fresh runner.

tests/terminal-smoke.py drives real Claude through a PTY with a logged-in account; its test-only dependencies are pyte and Pillow. npm run preview prints a local browser URL for diagnosing the same native engine. The browser is a separate diagnostic view.

See release procedure for packaging and publishing details.

Licenses and credits

  • Engine: doomgeneric revision dcb7a8dbc7a16ce3dda29382ac9aae9d77d21284, derived from the original Doom source, GPL-2.0-or-later. Complete corresponding source is in vendor/doomgeneric; the platform adapter is in native/doomgeneric_claude.c. Build scripts are included.
  • Game data: Freedoom Phase 1, version 0.13.0, under its permissive BSD license. License and contributor credits are retained in assets.
  • This mod's code is GPL-2.0-or-later. Download provenance is in vendor/provenance.json. See third-party notices.

No commercial Doom assets are included. This is an independent experiment, not an official Anthropic, id Software, or Freedoom product.

Source 2 files
hooks/register.ts 133 lines
1// SPDX-License-Identifier: GPL-2.0-or-later
2import type { EngineInterface, Register, Timer } from 'claude-code';
3
4type Bridge = { url: string; token: string };
5type Frame = { sequence: number; columns: number; rows: number; cells: string; alive: boolean; paused: boolean; error?: string };
6
7type State = {
8  bridge?: Bridge; frame?: Frame; timer?: Timer;
9  opened: boolean; busy: boolean; launching: boolean;
10  columns: number; rows: number; error: string;
11};
12
13async function request($: EngineInterface, state: State, endpoint: string, body?: object) {
14  if (!state.bridge) throw new Error('Doom is not running. Run /doom to start it.');
15  const result = await $.http.fetch(state.bridge.url + endpoint, {
16    headers: { Authorization: `Bearer ${state.bridge.token}`, ...(body ? { 'Content-Type': 'application/json' } : {}) },
17    ...(body ? { method: 'POST', body: JSON.stringify(body) } : {}),
18  });
19  if (!result.ok) throw new Error(`Doom bridge returned ${result.status}. Try /doom restart.`);
20  return result.text;
21}
22
23async function stop($: EngineInterface, state: State) {
24  state.opened = false; state.timer?.cancel(); state.timer = undefined;
25  if (state.bridge) await request($, state, '/stop', {}).catch(() => {});
26  state.bridge = undefined; state.frame = undefined;
27}
28
29async function poll($: EngineInterface, state: State) {
30  if (!state.opened || !state.bridge || state.busy) return;
31  state.busy = true;
32  try {
33    const next = JSON.parse(await request($, state, `/frame?columns=${state.columns}&rows=${state.rows}`)) as Frame;
34    if (!state.opened) return;
35    if (!next.alive) throw new Error(next.error || 'The Doom engine stopped. Run /doom restart.');
36    const resize = !state.frame || state.frame.columns !== next.columns || state.frame.rows !== next.rows;
37    const changed = !state.frame || state.frame.sequence !== next.sequence;
38    state.frame = next;
39    if (resize) $.ui.invalidate('ui.render');
40    else if (changed) await $.ui.blit({ requestId: 'doom', key: 'screen', cells: next.cells });
41  } catch (cause) {
42    state.error = cause instanceof Error ? cause.message : String(cause);
43    state.timer?.cancel(); state.timer = undefined; $.ui.invalidate('ui.render');
44  } finally { state.busy = false; }
45}
46
47async function launch($: EngineInterface, state: State) {
48  if (state.launching) return;
49  state.launching = true; state.error = '';
50  try {
51    if (!state.bridge) {
52      const result = await $.process.run(['node', `${$.plugin.root}/scripts/bridge.mjs`, 'start'], { cwd: $.plugin.root, timeoutMs: 12000 });
53      if (result.exitCode !== 0) throw new Error(result.stderr.trim() || 'Run npm run build in the Doom mod directory.');
54      const started = JSON.parse(result.stdout.trim()) as Bridge;
55      if (!/^http:\/\/127\.0\.0\.1:\d+$/.test(started.url) || !/^[a-f0-9]{48}$/.test(started.token)) throw new Error('Invalid local game address.');
56      state.bridge = started;
57    }
58    state.opened = true;
59    await poll($, state);
60    await $.ui.open({ id: 'doom', title: 'DOOM · Freedoom', focus: true, rows: 39, columns: 110 });
61    state.timer?.cancel(); state.timer = $.clock.every(50, () => poll($, state));
62  } finally { state.launching = false; }
63}
64
65async function action($: EngineInterface, state: State, kind: string) {
66  if (kind === 'close') { await stop($, state); await $.ui.close({ id: 'doom' }); return; }
67  if (kind === 'restart') { await stop($, state); await launch($, state); $.ui.invalidate('ui.render'); return; }
68  if (kind === 'pause') {
69    const result = JSON.parse(await request($, state, '/pause', {})) as { paused: boolean };
70    if (state.frame) state.frame.paused = result.paused;
71    $.ui.invalidate('ui.render');
72  }
73}
74
75export const register: Register = on => {
76  const state: State = { opened: false, busy: false, launching: false, columns: 96, rows: 32, error: '' };
77  on('session.start', async ($, e, next) => {
78    await $.command.register({ name: 'doom', description: 'Play the original Doom engine with Freedoom', argumentHint: '[pause|restart|close]', immediate: true });
79    return next(e);
80  });
81
82  on('command.run', { command: 'doom' }, async ($, e) => {
83    const arg = e.args.trim().toLowerCase();
84    try {
85      if (['close', 'pause', 'restart'].includes(arg)) await action($, state, arg);
86      else if (arg) return { text: 'Usage: /doom [pause|restart|close]' };
87      else await launch($, state);
88      return {};
89    } catch (cause) {
90      await stop($, state);
91      return { text: `Doom: ${cause instanceof Error ? cause.message : String(cause)}` };
92    }
93  });
94
95  on('ui.render', { component: 'Pane' }, ($, e, next) => {
96    if (e.requestId !== 'doom') return next(e);
97    if (e.surface !== 'terminal') return $.ui.resolve(e).Text({ children: 'Run /doom in the Claude Code terminal. This adapter renders terminal pixels.' });
98    const { Box, Text, Button, Client, Raster } = $.ui.resolve(e);
99    // Inline body's measured height follows its content; using that during
100    // loading collapses the first frame. Reserve space from the viewport.
101    const paneRows = e.props.placement === 'dock' ? e.props.scroll.bodyRows : Math.min(39, (e.viewport?.rows ?? 55) - 12);
102    const availableRows = Math.max(8, paneRows - 5);
103    state.columns = Math.max(24, Math.min(140, e.props.bodyColumns || 96, Math.floor(availableRows / 0.375)));
104    state.rows = Math.max(8, Math.min(availableRows, Math.floor(state.columns * 0.375), Math.floor(5600 / state.columns)));
105    return Box({ flexDirection: 'column', children: [
106      Text({ children: state.frame?.paused ? 'DOOM / FREEDOOM — PAUSED' : 'DOOM / FREEDOOM · local · no model calls', bold: true, color: '#efab62' }),
107      state.error ? Text({ children: state.error, color: '#ff7d71' }) : state.frame && state.frame.columns === state.columns && state.frame.rows === state.rows
108        ? Raster({ key: 'screen', columns: state.columns, rows: state.rows, cells: state.frame.cells })
109        : Box({ height: state.rows, width: state.columns, children: Text({ children: 'Loading the game…' }) }),
110      Client({ key: 'controls', module: './controls.ts', width: state.columns, height: 2 }),
111      Box({ gap: 2, children: [
112        Button({ key: 'pause', children: state.frame?.paused ? 'Resume' : 'Pause', onPress: () => action($, state, 'pause') }),
113        Button({ key: 'restart', children: 'Restart game', onPress: () => action($, state, 'restart') }),
114        Button({ key: 'close', children: 'Close game', onPress: () => action($, state, 'close') }),
115        Text({ children: 'Esc → Claude', dimColor: true }),
116      ] }),
117    ] });
118  });
119
120  on('ui.message', async ($, e) => {
121    if (!state.opened || e.element !== 'controls' || typeof e.data !== 'object' || !e.data) return {};
122    const data = e.data as { action?: string; keys?: { key: string; action: string }[] };
123    try {
124      if (data.action === 'pause') await action($, state, 'pause');
125      else if (data.keys) await request($, state, '/input', { keys: data.keys });
126    } catch (cause) { state.error = String(cause); $.ui.invalidate('ui.render'); }
127    return {};
128  });
129
130  on('ui.close', async ($, e, next) => { if (e.id === 'doom') await stop($, state); return next(e); });
131  on('session.end', async ($, e, next) => { await stop($, state); return next(e); });
132};
133
hooks/controls.ts 31 lines
1// SPDX-License-Identifier: GPL-2.0-or-later
2import type { ClientModule, JsonValue } from 'claude-code';
3
4type State = { queue: { key: string; action: string }[]; pause: boolean };
5const controls: ClientModule<JsonValue, State> = (_props, surface) => {
6  const { Box, Text } = surface.elements;
7  if (!surface.state) {
8    const state: State = { queue: [], pause: false };
9    surface.setState(state);
10    surface.onKey(event => {
11      if (event.ctrl || event.meta) return;
12      const key = event.key.toLowerCase();
13      if (key === 'p') state.pause = true;
14      else if (['w','a','s','d','q','e','f',' ','space','up','down','left','right','return','enter','m','tab','map','backspace','1','2','3','4','5','6','7','y','n','-','='].includes(key)) {
15        state.queue.push({ key, action: 'pulse' });
16        if (state.queue.length > 32) state.queue.shift();
17      }
18      surface.setState(state);
19    });
20    surface.every(30, () => {
21      if (state.pause) { surface.post({ action: 'pause' }); state.pause = false; }
22      else if (state.queue.length) { surface.post({ keys: state.queue.splice(0) }); }
23    });
24  }
25  return Box({ flexDirection: 'column', children: [
26    Text({ children: 'CLICK HERE TO PLAY · W/S move · A/D turn · Q/E strafe · Space fire · F use', color: '#e8c79f', bold: true }),
27    Text({ children: 'Arrows move/turn · 1–7 weapon · M menu · Enter select · Tab map · P pause', dimColor: true }),
28  ] });
29};
30export default controls;
31