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

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

The clip is rendered from real terminal output during gameplay. The engine runs locally; playing makes no model calls.
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
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.
| Key | Action |
|---|---|
| W / S, Up / Down | Move forward / backward |
| A / D, Left / Right | Turn |
| Q / E | Strafe |
| Space | Fire |
| F | Use / open doors |
| 1–7 | Select weapon |
| M / Enter | Game menu / select |
| Tab | Automap |
| P | Pause / resume |
| Escape | Give 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.
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.
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.
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.
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.assets.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.
hooks/register.ts 133 lines1// 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};
133hooks/controls.ts 31 lines1// 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