Play Doom deathmatch while Claude works, and get handed back when it's done

A Claude Code plugin that drops you into Doom deathmatch while Claude works, and hands you back when it's done.

When Claude has been working for two seconds, a pane opens beside the transcript and you drop into a free-for-all on a shared server with everyone else who is waiting on Claude. When Claude finishes there's a three-second countdown and you're handed back. If Claude needs you, say for a permission prompt, you're handed back at once and dropped in again after you answer.
It's Odamex with Freedoom's maps, with monsters left in so the server is never empty.
/plugin install intermission --marketplace jarrodwatts/intermission
/intermission
The first time, it downloads the game, about 20 MB.
To turn it off again, run /intermission off.
Click the game to play. The click also locks the mouse for turning; Esc or ⌘ releases it.
| | | | :- | :- | | Move | WASD or the arrow keys | | Turn | the mouse, once locked | | Fire | left click | | Run forward | hold right click | | Open doors | space | | Weapons | 1 to 7 |
If your terminal is too narrow for the pane to open by itself, a line above the prompt offers it instead: press 1.
The game connects over UDP to the intermission server at 157.245.140.115, under a random name such as QueuedMarine42. Nothing about your session, project or Claude's work is sent. Between rounds it stays connected as a silent spectator, and disconnects after five minutes away.
The mod runs Odamex without a window. Each frame goes into shared memory, which the terminal paints into the pane, and the pane passes keys and clicks back. Terminals report key presses but not releases, so a key counts as held until its auto-repeat stops. The changes to Odamex are in engine/odamex.patch, applied to the commit in engine/odamex-commit.
The server is stock Odamex; deploy.sh builds and runs it on a droplet.
The mod is MIT, as in LICENSE. Odamex is GPL-2.0, and so are the changes to it in engine/. Freedoom is BSD-3-Clause.
hooks/register.js 498 lines1// intermission: drops you into Doom while Claude works and hands you back when
2// it's done, or as soon as it needs you.
3
4const PANE = 'intermission'
5const WIDTH = 640
6const HEIGHT = 360
7const DROP_IN_DELAY_MS = 2000
8const COUNTDOWN_SECONDS = 3
9const SERVER = '157.245.140.115:10666'
10// Between drop-ins the engine waits on the server as a spectator; after this
11// long it disconnects, so idle sessions don't hold the server's slots
12const AWAY_DISCONNECT_MS = 5 * 60 * 1000
13// Long enough for a slow connection; some networks never let it through at all
14const CONNECT_TIMEOUT_MS = 10 * 1000
15
16const RELEASES = 'https://github.com/jarrodwatts/intermission/releases/download'
17
18const NAME_STARTS = ['Idle', 'Bored', 'Queued', 'Pending', 'Async', 'Blocked', 'Lazy']
19const NAME_ENDS = ['Dev', 'Coder', 'Hacker', 'Intern', 'Marine', 'Imp']
20
21// Whether the person turned intermission on, and their name in the game, both
22// kept between sessions in $.store
23let isOn = false
24let name = null
25
26// Where play stands:
27// idle not playing, whether or not Claude is working
28// waiting Claude is working; dropping in once the delay passes
29// offered the terminal was too narrow for the pane to open by itself, so
30// the band above the prompt offers a key that opens it
31// playing the pane is open and the engine runs
32// countdown Claude is done; closing when the count reaches zero
33let phase = 'idle'
34let isTurnRunning = false
35// Closed by hand during this turn, so stay out until the next one
36let isDismissed = false
37let isWelcomeOpen = false
38let timer = null
39let countdown = 0
40
41// The running engine's output stream, the newest frame it wrote, the file it
42// reads input from, the input region's last line, and the disconnect timer
43let engine = null
44let frame = null
45let inputPath = null
46let clientLine = '0'
47let awayTimer = null
48// Set once the server proved unreachable, so the rest of the session plays a
49// local game against monsters instead
50let isOffline = false
51// The engine download in flight, and what the welcome pane says about it
52let download = null
53let downloadStatus = null
54// Kills and deaths in the current or last round, as the engine reports them
55let score = null
56
57function cancelTimer() {
58 timer?.cancel()
59 timer = null
60}
61
62function armDropIn($) {
63 if (!isOn || !isTurnRunning || isDismissed || phase !== 'idle') return
64 phase = 'waiting'
65 timer = $.clock.after(DROP_IN_DELAY_MS, () => dropIn($))
66}
67
68async function dropIn($) {
69 if (phase !== 'waiting') return
70 timer = null
71 const surfaces = await $.session.surfaces()
72 if (!surfaces.includes('terminal') || !(await $.fs.exists(enginePath($.plugin.root)))) {
73 phase = 'idle'
74 return
75 }
76 const opened = await $.ui.open({ id: PANE, title: 'intermission', focus: true })
77 if (!opened.isPlaced) {
78 // A waiting pane would pop up later, long after the moment has passed.
79 // One the person opens appears at any width, so offer them a key instead.
80 $.ui.log('the pane waits for a wider terminal: ' + opened.reason, { to: 'debug' })
81 await $.ui.close({ id: PANE })
82 phase = 'offered'
83 $.ui.invalidate('ui.render')
84 return
85 }
86 await startPlaying($)
87}
88
89async function acceptOffer($) {
90 if (phase !== 'offered') return
91 const { isPlaced } = await $.ui.open({ id: PANE, title: 'intermission', focus: true })
92 if (isPlaced) await startPlaying($)
93}
94
95async function startPlaying($) {
96 phase = 'playing'
97 $.ui.invalidate('ui.render')
98 awayTimer?.cancel()
99 awayTimer = null
100 if (engine) await writeInput($)
101 else void runEngine($)
102}
103
104// Claude is done or needs the person before they took up an offer to play
105function withdrawOffer($) {
106 cancelTimer()
107 phase = 'idle'
108 $.ui.invalidate('ui.render')
109}
110
111// why, when given, heads the toast that sums up the round
112async function pullOut($, why) {
113 cancelTimer()
114 phase = 'idle'
115 if (why && score) $.ui.toast(why + ' · ' + scoreText(score))
116 await $.ui.close({ id: PANE })
117}
118
119function scoreText({ kills, deaths }) {
120 return kills + (kills === 1 ? ' kill, ' : ' kills, ') + deaths + (deaths === 1 ? ' death' : ' deaths')
121}
122
123function startCountdown($) {
124 phase = 'countdown'
125 countdown = COUNTDOWN_SECONDS
126 $.ui.invalidate('ui.render')
127 timer = $.clock.every(1000, () => {
128 countdown -= 1
129 if (countdown > 0) $.ui.invalidate('ui.render')
130 else void pullOut($, "Claude's done")
131 })
132}
133
134// The engine plays or spectates by the first number, and takes keys only in play
135async function writeInput($) {
136 if (!inputPath) return
137 const isPlaying = phase === 'playing' || phase === 'countdown'
138 await $.fs.write(inputPath, isPlaying ? '1 ' + clientLine + '\n' : '0 0\n')
139}
140
141// The pane closed, whoever closed it: spectate until the next drop-in
142async function goAway($) {
143 cancelTimer()
144 phase = 'idle'
145 isWelcomeOpen = false
146 frame = null
147 // The next input region counts its clicks from zero again
148 clientLine = '0'
149 if (!engine) return
150 await writeInput($)
151 awayTimer?.cancel()
152 awayTimer = $.clock.after(AWAY_DISCONNECT_MS, () => void stopEngine($))
153}
154
155async function stopEngine($) {
156 awayTimer?.cancel()
157 awayTimer = null
158 // Leaving the stream's loop is what stops the engine
159 if (engine) await engine.return()
160}
161
162function randomName() {
163 const pick = (words) => words[Math.floor(Math.random() * words.length)]
164 return pick(NAME_STARTS) + pick(NAME_ENDS) + (10 + Math.floor(Math.random() * 90))
165}
166
167// Claude is about to ask the person something, so they must see the prompt
168async function needsYou($) {
169 if (phase === 'waiting' || phase === 'offered') {
170 withdrawOffer($)
171 } else if (phase !== 'idle') {
172 await pullOut($, 'Claude needs you')
173 }
174}
175
176function enginePath(root) {
177 return root + '/dist/odamex.app/Contents/MacOS/odamex'
178}
179
180// Each plugin version downloads the engine built for it, once
181function ensureEngine($) {
182 download ??= downloadEngine($).finally(() => {
183 download = null
184 })
185 return download
186}
187
188async function downloadEngine($) {
189 const root = $.plugin.root
190 if (await $.fs.exists(enginePath(root))) return
191 const showStatus = (text) => {
192 downloadStatus = text
193 $.ui.invalidate('ui.render')
194 }
195 try {
196 const [system, arch] = (await $.process.run(['uname', '-sm'])).stdout.trim().split(' ')
197 if (system !== 'Darwin') throw new Error('it runs on macOS for now')
198 const { version } = JSON.parse(await $.fs.read(root + '/.claude-plugin/plugin.json'))
199 const archive = root + '/engine.tar.gz'
200 showStatus('Downloading the game, about 40 MB…')
201 const fetched = await $.process.run(
202 ['curl', '-fsSL', '--retry', '2', '-o', archive, RELEASES + '/v' + version + '/intermission-engine-macos-' + arch + '.tar.gz'],
203 { timeoutMs: 10 * 60 * 1000 },
204 )
205 if (fetched.exitCode !== 0) throw new Error(fetched.stderr.trim() || 'the download failed')
206 await $.process.run(['mkdir', '-p', root + '/dist'])
207 const unpacked = await $.process.run(['tar', '-xzf', archive, '-C', root + '/dist'])
208 await $.process.run(['rm', '-f', archive])
209 if (unpacked.exitCode !== 0) throw new Error(unpacked.stderr.trim() || 'the download was damaged')
210 showStatus('The game is ready.')
211 } catch (error) {
212 showStatus("Couldn't get the game: " + error.message)
213 }
214}
215
216function engineRequest(root, id) {
217 return {
218 argv: [
219 enginePath(root),
220 '-iwad', root + '/dist/freedoom2.wad',
221 // Its own settings, so a person's own Odamex setup is never touched
222 '-config', '/tmp/intermission-' + id + '.cfg',
223 '-width', String(WIDTH),
224 '-height', String(HEIGHT),
225 '+vid_fullscreen', '0',
226 '+vid_maxfps', '35',
227 // Odamex's macOS music player ignores volume changes, so it played on
228 // while muted between drop-ins; its built-in OPL synth mixes through SDL
229 '+snd_musicsystem', '4',
230 '+cl_name', name,
231 ...(isOffline ? ['+map', 'MAP01'] : ['+connect', SERVER]),
232 ],
233 env: {
234 SDL_VIDEODRIVER: 'dummy',
235 SDL_RENDER_DRIVER: 'software',
236 // A per-run name keeps two sessions' frames from colliding
237 INTERMISSION_FRAMES: '/im' + id + '-',
238 INTERMISSION_INPUT: inputPath,
239 },
240 }
241}
242
243async function runEngine($) {
244 const id = Math.random().toString(36).slice(2, 6)
245 inputPath = '/tmp/intermission-' + id + '.input'
246 await writeInput($)
247 score = { kills: 0, deaths: 0 }
248 engine = $.process.spawn(engineRequest($.plugin.root, id))
249 const startedAt = await $.clock.now()
250 let isConnected = isOffline
251 let isUnreachable = false
252 let pending = ''
253 let failure = null
254 try {
255 for await (const { stream, text } of engine) {
256 if (stream !== 'stdout') continue
257 // Pieces arrive as written, not as lines
258 const lines = (pending + text).split('\n')
259 pending = lines.pop()
260 for (const line of lines) {
261 if (line === '@connected') {
262 isConnected = true
263 continue
264 }
265 if (!isConnected && (await $.clock.now()) - startedAt > CONNECT_TIMEOUT_MS) {
266 isUnreachable = true
267 break
268 }
269 const sentAway = /^@disconnected (.*)/.exec(line)
270 if (sentAway) {
271 failure = 'intermission was disconnected' + (sentAway[1] ? ': ' + sentAway[1] : '')
272 break
273 }
274 const scored = /^@score (\d+) (\d+)/.exec(line)
275 if (scored) {
276 score = { kills: Number(scored[1]), deaths: Number(scored[2]) }
277 $.ui.invalidate('ui.render')
278 continue
279 }
280 const match = /^@frame (\S+)/.exec(line)
281 if (!match) continue
282 const isFirst = frame === null
283 frame = match[1]
284 if (isFirst) {
285 $.ui.invalidate('ui.render')
286 } else {
287 $.ui.blit({ requestId: PANE, key: 'view', source: shmSource(frame) }).catch(() => {})
288 }
289 }
290 // Leaving the loop is what stops the engine
291 if (failure || isUnreachable) break
292 }
293 } catch (error) {
294 $.ui.log('the game did not start: ' + error, { to: 'debug' })
295 failure = "intermission couldn't start the game"
296 } finally {
297 engine = null
298 frame = null
299 inputPath = null
300 }
301 if (isUnreachable) {
302 isOffline = true
303 if (phase === 'playing' || phase === 'countdown') {
304 $.ui.toast(
305 "Couldn't reach the game server. Your network may block UDP, as corporate VPNs like Zscaler do, so this is an offline game against monsters.",
306 { timeoutMs: 8000 },
307 )
308 void runEngine($)
309 }
310 return
311 }
312 // Ending on its own while someone plays means the engine quit, crashed or was
313 // sent away; while they're away, the next drop-in simply starts it again.
314 // It also ends when this module unloads, and then there's nothing to close.
315 if (phase === 'playing' || phase === 'countdown') {
316 try {
317 $.ui.toast(failure ?? 'intermission lost the game')
318 await pullOut($)
319 } catch {}
320 }
321}
322
323function shmSource(name) {
324 return { shm: name, format: 'rgb', width: WIDTH, height: HEIGHT }
325}
326
327export function register(on) {
328 on('session.start', async ($, e, next) => {
329 isOn = (await $.store.get('isOn')) === true
330 name = await $.store.get('name')
331 if (!name) {
332 name = randomName()
333 await $.store.set('name', name)
334 }
335 await $.command.register({
336 name: 'intermission',
337 description: 'Play Doom while Claude works',
338 argumentHint: '[off]',
339 })
340 return next(e)
341 })
342
343 on('command.run', { command: 'intermission' }, async ($, e) => {
344 if (e.args.trim() === 'off') {
345 isOn = false
346 await $.store.set('isOn', false)
347 if (phase !== 'idle') await pullOut($)
348 await stopEngine($)
349 return { text: 'intermission is off.' }
350 }
351 isOn = true
352 await $.store.set('isOn', true)
353 // Opening it yourself also lets it open by itself in narrower terminals
354 if (phase === 'idle') {
355 isWelcomeOpen = true
356 await $.ui.open({ id: PANE, title: 'intermission' })
357 }
358 void ensureEngine($)
359 return {}
360 })
361
362 on('turn.start', async ($, e, next) => {
363 isTurnRunning = true
364 isDismissed = false
365 if (isWelcomeOpen) {
366 isWelcomeOpen = false
367 await $.ui.close({ id: PANE })
368 }
369 if (phase === 'countdown') {
370 // A queued prompt started straight away, so keep playing
371 cancelTimer()
372 phase = 'playing'
373 $.ui.invalidate('ui.render')
374 }
375 armDropIn($)
376 return next(e)
377 })
378
379 on('turn.complete', async ($, e, next) => {
380 if (e.agentId) return next(e)
381 isTurnRunning = false
382 if (phase === 'waiting' || phase === 'offered') {
383 withdrawOffer($)
384 } else if (phase === 'playing') {
385 if (e.isAborted) await pullOut($)
386 else startCountdown($)
387 }
388 return next(e)
389 })
390
391 on('tool.check', async ($, e, next) => {
392 const result = await next(e)
393 // In auto mode an ask can go to the classifier instead of the person;
394 // pulling out anyway costs a moment, missing a real prompt costs more
395 if (e.tool_use_id && result.decision === 'ask') await needsYou($)
396 return result
397 })
398
399 on('tool.call', async ($, e, next) => {
400 if (e.tool === 'AskUserQuestion') await needsYou($)
401 const result = await next(e)
402 // Once an answered prompt lets Claude carry on, drop back in
403 armDropIn($)
404 return result
405 })
406
407 on('ui.close', async ($, e, next) => {
408 if (e.id !== PANE) return next(e)
409 if (e.origin?.kind === 'person' && isTurnRunning) isDismissed = true
410 await goAway($)
411 return next(e)
412 })
413
414 on('ui.message', async ($, e) => {
415 if (e.element !== 'input') return {}
416 clientLine = e.data.line
417 await writeInput($)
418 return {}
419 })
420
421 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
422 if (phase !== 'offered') return next(e)
423 const { Box, Button } = $.ui.resolve(e)
424 // Keep whatever other mods show in the band
425 const others = await next(e)
426 return Box({
427 flexDirection: 'column',
428 children: [
429 Button({ key: 'play', label: 'Play Doom while Claude works', hotkey: '1', plain: true, onPress: () => acceptOffer($) }),
430 ...(others ? [others] : []),
431 ],
432 })
433 })
434
435 on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
436 if (phase === 'idle' || phase === 'waiting' || !score) return next(e)
437 return next({ ...e, props: { ...e.props, suffix: ' · ' + scoreText(score) + '…' } })
438 })
439
440 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
441 if (e.requestId !== PANE) return next(e)
442 const { Box, Text, Image, Client, Button } = $.ui.resolve(e)
443
444 if (isWelcomeOpen) {
445 return Box({
446 flexDirection: 'column',
447 gap: 1,
448 children: [
449 Text({ bold: true, children: ['intermission is on'] }),
450 Text({
451 children: [
452 'When Claude has been working for 2 seconds you drop into Doom here, and you get handed back when it is done or needs you.',
453 ],
454 }),
455 ...(downloadStatus ? [Text({ children: [downloadStatus] })] : []),
456 Text({ dimColor: true, children: ['/intermission off turns it off.'] }),
457 Button({
458 key: 'got-it',
459 label: 'Got it',
460 autoFocus: true,
461 onPress: async () => {
462 isWelcomeOpen = false
463 await $.ui.close({ id: PANE })
464 },
465 }),
466 ],
467 })
468 }
469
470 if (e.surface !== 'terminal') return Text({ children: ['intermission needs the terminal, in Ghostty or kitty.'] })
471 if (!frame) return Text({ children: [isOffline ? 'Starting an offline game…' : 'Joining the game as ' + name + '…'] })
472 // Terminal cells are about twice as tall as they are wide
473 const columns = Math.min(255, e.props.bodyColumns)
474 const rows = Math.max(1, Math.round((columns * HEIGHT) / WIDTH / 2))
475 const status =
476 phase === 'countdown'
477 ? Text({ bold: true, children: ["Claude's done · back in " + countdown] })
478 : Text({
479 dimColor: true,
480 children: ['Click the game to play and lock the mouse · Esc or ⌘ releases it · WASD or arrows · left click fires · right click runs · space opens'],
481 })
482 return Box({
483 flexDirection: 'column',
484 children: [
485 Image({ key: 'view', source: shmSource(frame), columns, rows, alt: 'Doom' }),
486 // Laid over the picture, so clicks land on the game
487 Box({
488 position: 'absolute',
489 top: 0,
490 left: 0,
491 children: [Client({ key: 'input', module: './input.js', width: columns, height: rows })],
492 }),
493 status,
494 ],
495 })
496 })
497}
498hooks/input.js 77 lines1// Catches keys and clicks over the game picture and posts what is held, which
2// the hooks module writes to the engine's input file. Each click also asks the
3// engine to lock the cursor, which it then reads for mouse-look itself.
4//
5// The terminal reports presses, never releases. A key counts as held until
6// its auto-repeat stops: long after a single press, because the first repeat
7// comes late, and briefly once repeats are flowing. Mouse buttons do report
8// releases.
9
10const HOLD_AFTER_PRESS_MS = 550
11const HOLD_WHILE_REPEATING_MS = 120
12
13const MOUSE_FIRE = 0x20000000
14const KEY_FORWARD = 'w'.charCodeAt(0)
15
16const SPECIAL_KEYS = {
17 up: 0x40000052,
18 down: 0x40000051,
19 left: 0x40000050,
20 right: 0x4000004f,
21 return: 0x0d,
22 tab: 0x09,
23}
24
25function keyCode(key) {
26 if (SPECIAL_KEYS[key]) return SPECIAL_KEYS[key]
27 return key.length === 1 ? key.toLowerCase().charCodeAt(0) : null
28}
29
30export default function GameInput(props, surface) {
31 if (surface.state === undefined) {
32 const input = {
33 presses: new Map(), // key code -> { at, isRepeating }
34 buttons: new Set(),
35 clicks: 0,
36 posted: '',
37 }
38
39 surface.onKey((e) => {
40 const code = keyCode(e.key)
41 if (code === null) return
42 const now = Date.now()
43 const last = input.presses.get(code)
44 input.presses.set(code, { at: now, isRepeating: !!last && now - last.at < HOLD_AFTER_PRESS_MS })
45 })
46
47 surface.onPointer((e) => {
48 const code = e.button === 'left' ? MOUSE_FIRE : e.button === 'right' ? KEY_FORWARD : null
49 if (code === null) return
50 if (e.type === 'down') {
51 input.buttons.add(code)
52 input.clicks += 1
53 }
54 if (e.type === 'up') input.buttons.delete(code)
55 })
56
57 surface.every(30, () => {
58 const now = Date.now()
59 const keys = new Set(input.buttons)
60 for (const [code, press] of input.presses) {
61 const holdMs = press.isRepeating ? HOLD_WHILE_REPEATING_MS : HOLD_AFTER_PRESS_MS
62 if (now - press.at < holdMs) keys.add(code)
63 else input.presses.delete(code)
64 }
65 const line = [input.clicks, ...[...keys].sort()].join(' ')
66 if (line === input.posted) return
67 input.posted = line
68 surface.post({ line })
69 })
70
71 surface.setState(input)
72 }
73
74 const { Box } = surface.elements
75 return Box({ width: '100%', height: '100%' })
76}
77