SLOPSHOPPER

intermission

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

newpanebandspinnerguardcommand
★ 97v0.1.4MITupdated 2026-10-02jarrodwatts/intermission
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · intermission
│ ┃ intermission ✕ › fix the failing auth test and add an audit log call │ ┃ intermission is on │ ┃ ⏺ Read(src/auth.ts) │ ┃ When Claude has been working for 2 seconds ⎿ Read 6 lines │ ┃ you drop into Doom here, and you get handed ⏺ Update(src/auth.ts) │ ┃ back when it is done or needs you. ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ Couldn't get the game: it runs on macOS for ⎿ 3 pass, 1 fail │ ┃ now │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ /intermission off turns it off. │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ [ Got it ] │ › /intermission │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · intermission
intermission is on 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. Couldn't get the game: it runs on macOS for now /intermission off turns it off. [ Got it ]
README

intermission

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

License Stars

intermission in action

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.

Install

  1. Install the plugin:
   /plugin install intermission --marketplace jarrodwatts/intermission
  1. Turn it on:
   /intermission

The first time, it downloads the game, about 20 MB.

To turn it off again, run /intermission off.

Requirements

  • macOS 15 or later, on Apple silicon or Intel
  • Ghostty or kitty, the terminals that can show the game's pixels
  • Claude Code 2.1.287 or later

Controls

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.

What it connects to

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.

How it works

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.

Licenses

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.

Source 2 files
hooks/register.js 498 lines
1// 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}
498
hooks/input.js 77 lines
1// 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