A handheld game console in a Claude Code pane: plays your own Game Boy game files


A Claude Code mod that runs a small Game Boy (DMG) emulator, so you can play while Claude works: in the Claude Code pane (Ghostty, kitty) or in its own window (VS Code and the rest). When Claude finishes a turn, the game pauses for you.

/pocket ~/Games/my-game.gb
No games are included. Pocket plays only the game file you give it — use a backup you made from a cartridge you own. This repository does not include, download or link to any game files.
In Claude Code:
/plugin marketplace add romanlucian/claude-pocket
/plugin install pocket@pocket
Pocket needs Node.js (the emulator runs in its own Node process). If Claude Code can't find node, set its full path in the mod's settings (Node command; run which node in a terminal to see it).
/pocket ~/path/to/your-game.gb
That's it. Where the game shows depends on your terminal:
Play in a window / Play in this pane switches between the two (the choice is kept).
| Key | Button |
|---|---|
| ← ↑ → ↓ | D-pad |
| Z | A |
| X | B |
| Enter | Start |
| Space (pane) / Shift (window) | Select |
| P | Pause / resume |
The pane also has Pause/Resume, Restart, Stop and Pause when Claude finishes (on by default: when Claude finishes a turn, your game pauses). /pocket with no file reopens the last game; /pocket stop stops it.
The game window is a page on your own computer only (127.0.0.1, at a secret address), and it closes when the game stops. A browser knows when you let go of a key, so the controls there feel exactly like the console's.
cpu_instrs and instr_timing tests and matches the dmg-acid2 reference picture.dmg_sound tests (the three left are about reading the wave memory mid-note).your-game.sav beside the game file, the same file other emulators use, so you can bring your saves along. It is written a second after the game saves and when you stop. The pane says when a save was loaded. (An MBC3 cartridge's clock is not kept.)core/ — the emulator: CPU (cpu.mjs), sound (apu.mjs) and the rest of the machine: cartridge and saves, timer, picture, joypad (machine.mjs). Plain JavaScript, no dependencies.runner/pocket.mjs — runs the emulator in real time (~60 fps) in a Node process, streams frames on stdout and takes keys over a local socket.runner/web.mjs — the game window: a page on 127.0.0.1 drawing frames on a canvas and playing the sound.hooks/ — the mod: the /pocket command, the pane, the key pad, and the auto-pause when Claude finishes a turn.Run the tests with claude plugin test.
MIT © 2026 Lucian Roman. Game Boy is a trademark of Nintendo; this project is not affiliated with or endorsed by Nintendo.
hooks/register.tsx 395 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { PocketGame } from '../types'
5import { expandPath, fit, HEIGHT, WIDTH } from './screen'
6import type { Fit } from './screen'
7
8type Engine = EngineInterface
9
10const PANE = 'pocket'
11// Rows the pane keeps for everything but the screen: title, pad, buttons, message.
12const CHROME_ROWS = 6
13
14const game = atom({ plugin: 'pocket', key: 'game' } as const, {
15 status: 'idle',
16 title: '',
17 romPath: '',
18 message: '',
19 firstFile: '',
20 webUrl: '',
21})
22const mode = atom({ plugin: 'pocket', key: 'mode' } as const, 'window')
23const pauseWhenDone = atom({ plugin: 'pocket', key: 'pauseWhenDone' } as const, true)
24
25// How to run Node: the `nodePath` option, read again at every load.
26let node = 'node'
27
28// The running emulator. Module state on purpose: a reload ends the process.
29type Runner = {
30 stop: () => void
31 socket?: string
32 latestFile?: string
33 generation: number
34 lastSeq: number
35 isShowing: boolean
36}
37let runner: Runner | undefined
38// The box the screen was last drawn in: what a blit must match.
39let pixelsBox: Fit = fit(80, 40)
40// Whether the last drawing mounted the screen as an Image: only then may a
41// blit swap its source (one sent before it is drawn is refused).
42let isImageMounted = false
43
44async function setGame($: Engine, change: Partial<PocketGame>): Promise<void> {
45 await update($, game, current => ({ ...current, ...change }))
46}
47
48async function control($: Engine, path: string, body: unknown = {}): Promise<boolean> {
49 const socket = runner?.socket
50 if (socket === undefined) return false
51 try {
52 const answer = await $.http.fetch(`http://pocket${path}`, {
53 method: 'POST',
54 socketPath: socket,
55 body: JSON.stringify(body),
56 })
57 return answer.ok
58 } catch {
59 return false
60 }
61}
62
63async function stopGame($: Engine, message = 'Stopped.'): Promise<void> {
64 const current = runner
65 runner = undefined
66 current?.stop()
67 await update($, game, value => (value.status === 'idle' ? value : { ...value, status: 'idle' as const, message, firstFile: '', webUrl: '' }))
68}
69
70async function setPaused($: Engine, isPaused: boolean, message: string): Promise<void> {
71 if (runner === undefined) return
72 if (await control($, isPaused ? '/pause' : '/resume')) {
73 await setGame($, { status: isPaused ? 'paused' : 'running', message })
74 }
75}
76
77// Paints the newest picture into the mounted screen, without a redraw.
78async function show($: Engine): Promise<void> {
79 const current = runner
80 if (current === undefined || current.isShowing || current.latestFile === undefined || !isImageMounted) return
81 current.isShowing = true
82 try {
83 const done = await $.ui.blit({
84 requestId: PANE,
85 key: 'screen',
86 source: { file: current.latestFile, format: 'rgb', width: WIDTH, height: HEIGHT, generation: current.generation },
87 })
88 // The Image draws its alt here: this terminal shows no pictures, or
89 // cannot read the file. Any other refusal (a redraw in between) passes.
90 if (done.deny !== undefined && /\balt\b|placeholder|cannot read/i.test(done.deny)) {
91 await setMode($, 'window')
92 await setGame($, { message: `This terminal shows no pictures (${done.deny}), so the game plays in its own window.` })
93 await control($, '/open')
94 }
95 } finally {
96 current.isShowing = false
97 }
98}
99
100// Switches where the game is drawn, for this game and the next ones:
101// `pixels` in the pane (Ghostty, kitty), `window` in its own window.
102async function setMode($: Engine, drawing: 'pixels' | 'window'): Promise<void> {
103 await $.store.set('mode', drawing)
104 if (runner !== undefined) runner.latestFile = undefined
105 await setGame($, { firstFile: '', message: '' })
106 await update($, mode, () => drawing)
107 await control($, '/mode', { image: drawing === 'pixels' })
108 if (drawing === 'window') await control($, '/open')
109}
110
111async function startGame($: Engine, romPath: string): Promise<void> {
112 await stopGame($)
113 await setGame($, { status: 'starting', title: '', romPath, message: 'Starting the console…', firstFile: '', webUrl: '' })
114 const stream = $.process.spawn({ argv: [node, `${$.plugin.root}/runner/pocket.mjs`, romPath] })
115 const mine: Runner = {
116 stop: () => void stream.return({ code: null, signal: 'SIGTERM' }),
117 generation: 0,
118 lastSeq: 0,
119 isShowing: false,
120 }
121 runner = mine
122 void pump($, stream, mine)
123}
124
125async function pump(
126 $: Engine,
127 stream: AsyncIterable<{ stream: 'stdout' | 'stderr'; text: string }>,
128 mine: Runner,
129): Promise<void> {
130 let pending = ''
131 let errors = ''
132 let failure: string | undefined
133 try {
134 for await (const chunk of stream) {
135 if (runner !== mine) break
136 if (chunk.stream === 'stderr') {
137 errors = (errors + chunk.text).slice(-2000)
138 continue
139 }
140 pending += chunk.text
141 const lines = pending.split('\n')
142 pending = lines.pop() ?? ''
143 for (const line of lines) {
144 let message: Record<string, unknown>
145 try {
146 message = JSON.parse(line) as Record<string, unknown>
147 } catch {
148 continue
149 }
150 if (typeof message.error === 'string') failure = message.error
151 if (typeof message.warning === 'string') await setGame($, { message: message.warning })
152 if (message.ready === true && typeof message.socket === 'string') {
153 mine.socket = message.socket
154 const title = typeof message.title === 'string' && message.title !== '' ? message.title : 'Game'
155 const webUrl = typeof message.web === 'string' ? message.web : ''
156 const drawing = await read($, mode)
157 if (drawing === 'pixels') await control($, '/mode', { image: true })
158 // Where the game saves, if it does: a `.sav` beside the game file.
159 const save = typeof message.save === 'string' ? message.save : ''
160 const saveNote =
161 save === '' ? '' : message.isSaveLoaded === true ? `Save loaded from ${save}.` : `The game saves to ${save}.`
162 await setGame($, { status: 'running', title, message: saveNote, webUrl })
163 if (drawing === 'window') await control($, '/open')
164 }
165 if (typeof message.paused === 'boolean') {
166 // The game window's P key paused or resumed the game.
167 const isPaused = message.paused
168 await update($, game, value =>
169 value.status === 'running' || value.status === 'paused'
170 ? { ...value, status: isPaused ? ('paused' as const) : ('running' as const), message: isPaused ? value.message : '' }
171 : value,
172 )
173 }
174 if (typeof message.frame === 'number') {
175 mine.generation = message.frame
176 if (typeof message.file === 'string') {
177 mine.latestFile = message.file
178 const { value: current } = await $.state.get({ plugin: 'pocket', key: 'game' })
179 if (current?.firstFile === '') await setGame($, { firstFile: message.file })
180 }
181 }
182 }
183 await show($)
184 }
185 } catch (error) {
186 failure ??= error instanceof Error ? error.message : String(error)
187 }
188 if (runner !== mine) return
189 runner = undefined
190 const why = failure ?? (errors.trim().split('\n').pop() || 'The console stopped.')
191 try {
192 await setGame($, { status: 'error', message: why, firstFile: '', webUrl: '' })
193 } catch {
194 // The session or the mod is gone: nothing left to tell.
195 }
196}
197
198async function sendKeys($: Engine, data: unknown): Promise<void> {
199 const current = runner
200 if (current === undefined) return
201 const recent = (data as { recent?: unknown }).recent
202 if (!Array.isArray(recent)) return
203 const buttons: string[] = []
204 let isPauseToggled = false
205 for (const press of recent as { seq?: unknown; button?: unknown }[]) {
206 if (typeof press.seq !== 'number' || typeof press.button !== 'string' || press.seq <= current.lastSeq) continue
207 current.lastSeq = press.seq
208 if (press.button === 'pause') isPauseToggled = true
209 else buttons.push(press.button)
210 }
211 if (isPauseToggled) {
212 const { status } = await read($, game)
213 await setPaused($, status !== 'paused', status === 'paused' ? '' : 'Paused. Press P or Resume to go on.')
214 }
215 if (buttons.length > 0) await control($, '/keys', { keys: buttons })
216}
217
218async function openPane($: Engine): Promise<void> {
219 await $.ui.open({ id: PANE, title: 'Pocket' })
220}
221
222async function remember($: Engine, romPath: string): Promise<void> {
223 await $.store.set('lastRom', romPath)
224}
225
226async function chooseMode($: Engine): Promise<void> {
227 // The person's own choice (the Picture button) wins.
228 const chosen = await $.store.get('mode')
229 if (chosen === 'pixels' || chosen === 'window') {
230 await update($, mode, () => chosen)
231 return
232 }
233 const term = (await $.env.get('TERM')) ?? ''
234 const program = (await $.env.get('TERM_PROGRAM')) ?? ''
235 const isKitty = (await $.env.get('KITTY_WINDOW_ID')) !== undefined
236 const isGhostty = (await $.env.get('GHOSTTY_RESOURCES_DIR')) !== undefined
237 const canDraw = isKitty || isGhostty || /kitty|ghostty/i.test(term) || /ghostty|kitty/i.test(program)
238 await update($, mode, () => (canDraw ? 'pixels' : 'window'))
239}
240
241async function play($: Engine, args: string): Promise<string> {
242 const home = (await $.env.get('HOME')) ?? ''
243 let romPath = args === '' ? '' : expandPath(args, home)
244 if (romPath === '') {
245 const saved = await $.store.get('lastRom')
246 romPath = typeof saved === 'string' ? saved : ''
247 }
248 await openPane($)
249 if (romPath === '') {
250 if (runner !== undefined) return 'Pocket is open.'
251 return 'Give it your game file, for example: /pocket ~/Games/my-game.gb'
252 }
253 if (!(await $.fs.exists(romPath))) return `There is no file at ${romPath}.`
254 await remember($, romPath)
255 await startGame($, romPath)
256 return (await read($, mode)) === 'window'
257 ? `Starting ${romPath} in its own window.`
258 : `Starting ${romPath}. Click the pad line in the Pocket pane to play.`
259}
260
261export const register: Register = (on, options) => {
262 const configured = typeof options.nodePath === 'string' ? options.nodePath.trim() : ''
263 node = configured === '' ? 'node' : configured
264
265 on('session.start', async ($, e, next) => {
266 await $.command.register({
267 name: 'pocket',
268 description: 'Play your own handheld game files in a Claude Code pane',
269 argumentHint: '<game file .gb> | stop',
270 })
271 await chooseMode($)
272 return next(e)
273 })
274
275 on('command.run', { command: 'pocket' }, async ($, e) => {
276 const args = e.args.trim()
277 if (args === 'stop') {
278 await stopGame($)
279 return { text: 'Pocket stopped.' }
280 }
281 return { text: await play($, args) }
282 })
283
284 on('ui.message', async ($, e, next) => {
285 if (e.requestId === PANE && e.element === 'pad') await sendKeys($, e.data)
286 return next(e)
287 })
288
289 on('ui.close', { id: PANE }, async ($, e, next) => {
290 await stopGame($, 'Closed.')
291 return next(e)
292 })
293
294 on('turn.complete', async ($, e, next) => {
295 const result = await next(e)
296 // A subagent's turn is not Claude finishing.
297 if (e.agentId !== undefined) return result
298 const { status } = await read($, game)
299 if (status === 'running' && (await read($, pauseWhenDone))) {
300 await setPaused($, true, 'Claude finished, so the game paused. Press P or Resume.')
301 $.ui.toast('pocket: Claude finished. Your game is paused.')
302 }
303 return result
304 })
305
306 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
307 const table = $.ui.resolve(e)
308 const { Box, Text, Button } = table
309 const Image = 'Image' in table ? table.Image : undefined
310 const Client = 'Client' in table ? table.Client : undefined
311
312 const current = await read($, game)
313 const drawing = await read($, mode)
314 const isPausing = await read($, pauseWhenDone)
315 const isPlaying = current.status === 'running' || current.status === 'paused'
316 const inPane = isPlaying && drawing === 'pixels'
317 const freeRows = Math.max(10, e.props.scroll.bodyRows - CHROME_ROWS)
318 const columns = Math.max(16, e.props.bodyColumns)
319
320 let screen = null
321 isImageMounted = false
322 if (inPane && Image !== undefined && current.firstFile !== '') {
323 pixelsBox = fit(columns, freeRows)
324 isImageMounted = true
325 screen = (
326 <Image
327 key="screen"
328 source={{ file: current.firstFile, format: 'rgb', width: WIDTH, height: HEIGHT }}
329 columns={pixelsBox.columns}
330 rows={pixelsBox.rows}
331 alt="The game screen"
332 />
333 )
334 }
335
336 const statusLine =
337 current.status === 'idle'
338 ? 'No game running.'
339 : current.status === 'starting'
340 ? 'Starting…'
341 : current.status === 'error'
342 ? 'The console stopped.'
343 : `${current.title}${current.status === 'paused' ? ' · paused' : ''}`
344
345 return (
346 <Box flexDirection="column">
347 <Box gap={1}>
348 <Text bold>{statusLine}</Text>
349 {isPlaying && <Text dimColor>{inPane ? 'in this pane' : 'in its own window'}</Text>}
350 </Box>
351 {screen}
352 {inPane && Client !== undefined && (
353 <Client key="pad" module="./pad.tsx" props={{ isPaused: current.status === 'paused' }} height={1} />
354 )}
355 <Box gap={1} flexWrap="wrap">
356 {isPlaying && (
357 <Button
358 key="pause"
359 label={current.status === 'paused' ? 'Resume' : 'Pause'}
360 onPress={() => void setPaused($, current.status !== 'paused', '')}
361 />
362 )}
363 {current.romPath !== '' && (
364 <Button key="restart" label={isPlaying ? 'Restart' : 'Play again'} onPress={() => void startGame($, current.romPath)} />
365 )}
366 {isPlaying && <Button key="stop" label="Stop" onPress={() => void stopGame($)} />}
367 {isPlaying && !inPane && <Button key="open" label="Show game window" onPress={() => void control($, '/open')} />}
368 {isPlaying && (
369 <Button
370 key="picture"
371 label={inPane ? 'Play in a window' : 'Play in this pane'}
372 onPress={() => void setMode($, inPane ? 'window' : 'pixels')}
373 />
374 )}
375 <Button
376 key="auto-pause"
377 label={isPausing ? 'Pause when Claude finishes: on' : 'Pause when Claude finishes: off'}
378 onPress={() => void update($, pauseWhenDone, value => !value)}
379 />
380 </Box>
381 {current.message !== '' && <Text dimColor>{current.message}</Text>}
382 {isPlaying && !inPane && (
383 <Text dimColor>
384 Play in the game window: ←↑→↓ move · Z = A · X = B · Enter = Start · Shift = Select · P = pause · M = sound on/off.
385 Closed it? Show game window.
386 </Text>
387 )}
388 {!isPlaying && current.romPath === '' && (
389 <Text dimColor>Type /pocket and the path of your own game file, for example /pocket ~/Games/my-game.gb</Text>
390 )}
391 </Box>
392 )
393 })
394}
395hooks/screen.ts 57 lines1// Pure helpers for the pane: fitting the 160x144 picture to it, reading keys
2// and paths. No `$` here, so the tests call them directly.
3
4export const WIDTH = 160
5export const HEIGHT = 144
6
7export type Fit = { columns: number; rows: number }
8
9/**
10 * The cell box the picture gets in a pane `columns` wide with `rows` free:
11 * the terminal scales the real picture into it, a cell being about twice as
12 * tall as wide.
13 */
14export function fit(columns: number, rows: number): Fit {
15 const maxColumns = Math.max(16, Math.min(columns, 96))
16 const maxRows = Math.max(8, rows)
17 // rows a picture `c` columns wide needs: a cell is two "pixels" tall.
18 const rowsFor = (c: number) => Math.ceil((c * HEIGHT) / WIDTH / 2)
19 let c = maxColumns
20 while (c > 16 && rowsFor(c) > maxRows) c--
21 return { columns: c, rows: Math.min(255, rowsFor(c)) }
22}
23
24/** The console button a key stands for, or `pause`. */
25export function buttonFor(key: string): string | undefined {
26 switch (key) {
27 case 'up':
28 case 'down':
29 case 'left':
30 case 'right':
31 return key
32 case 'z':
33 case 'Z':
34 return 'a'
35 case 'x':
36 case 'X':
37 return 'b'
38 case 'return':
39 case 'enter':
40 return 'start'
41 case ' ':
42 case 'space':
43 return 'select'
44 case 'p':
45 case 'P':
46 return 'pause'
47 default:
48 return undefined
49 }
50}
51
52/** A path as typed: `~/Games/x.gb` under the home folder, quotes removed. */
53export function expandPath(path: string, home: string): string {
54 const clean = path.trim().replace(/^(['"])(.*)\1$/, '$2')
55 return clean.startsWith('~/') ? `${home}${clean.slice(1)}` : clean
56}
57hooks/pad.tsx 43 lines1// The keyboard pad: a one-line Client under the screen. Once it has the
2// focus (a click on it), each key is sent to the hooks module, which passes
3// it to the emulator. Esc gives the keyboard back to Claude Code.
4//
5// A post replaces one not yet delivered in the same frame, so every post
6// carries the last few presses, numbered; the hooks module takes the new ones.
7
8import type { ClientModule } from 'claude-code'
9
10import { buttonFor } from './screen'
11
12type Press = { seq: number; button: string }
13type PadState = { seq: number; recent: Press[] }
14type PadProps = { isPaused: boolean }
15
16const Pad: ClientModule<PadProps, PadState> = (props, surface) => {
17 if (surface.state === undefined) {
18 surface.onKey(event => {
19 const button = buttonFor(event.key)
20 if (button === undefined) return
21 const current = surface.state ?? { seq: 0, recent: [] }
22 const seq = current.seq + 1
23 const recent = [...current.recent, { seq, button }].slice(-8)
24 surface.setState({ seq, recent })
25 surface.post({ recent })
26 })
27 surface.setState({ seq: 0, recent: [] })
28 }
29 const { Box, Text } = surface.elements
30 return (
31 <Box gap={1}>
32 <Text bold color={props.isPaused ? 'warning' : 'success'}>
33 {props.isPaused ? '❚❚ Paused' : '▶ Click here to play'}
34 </Text>
35 <Text dimColor wrap="truncate-end">
36 ←↑→↓ move · Z = A · X = B · Enter = Start · Space = Select · P = pause · Esc = back to Claude
37 </Text>
38 </Box>
39 )
40}
41
42export default Pad
43types/index.d.ts 24 lines1/** The game being played, as the pane shows it. */
2export type PocketGame = {
3 status: 'idle' | 'starting' | 'running' | 'paused' | 'error'
4 title: string
5 romPath: string
6 message: string
7 /** The picture file the terminal reads (pixels mode), once the first frame exists. */
8 firstFile: string
9 /** The game window's address (a page on 127.0.0.1), once the console is up. */
10 webUrl: string
11}
12
13declare module 'claude-code' {
14 interface PluginState {
15 pocket: {
16 game: PocketGame
17 /** `pixels`: a real picture in the pane (Ghostty, kitty). `window`: the game in its own window. */
18 mode: 'pixels' | 'window'
19 /** Pause the game when Claude finishes a turn. */
20 pauseWhenDone: boolean
21 }
22 }
23}
24