Sidequest for Claude Code: play Snake in a side pane while Claude does the main quest; works in any terminal

Claude does the main quest. You do a side quest: Snake in a side pane while Claude works. Text only, so it works in any terminal, Warp included.
Plugin id: sidequest.
1 on the line above the prompt to open it.Commands:
/sidequest opens the pane with the last game./sidequest snake opens Snake./sidequest off stops it opening by itself. /sidequest on turns that back on.With 2 or more games, press m in the pane to pick a game.
hooks/games/<id>/ with an index.js.| Part | What it does |
|---|---|
id, title, hint | Name in the command, name on screen, help line |
fits(size) | size is { columns, rows } of the play area |
start(size) | A new game |
restore(saved, size) | The saved game, or null |
key(state, key) | The next state, or null for a key the game does not use |
stepMs(state) | Time between moves in ms, or null |
step(state) | One move |
score(state), isOver(state) | For the status line |
draw(state, { Box, Text }) | The play area, walls included |
hooks/games/index.js.tests/games.test.ts checks every game in the list.The frame owns Space (pause) and m (menu). Esc gives the focus back to the terminal, so no game can use it.
git clone https://github.com/farhad-aman/claude-sidequest ~/projects/sidequest
Add the folder to CLAUDE_CODE_PLUGIN_DIRS in ~/.claude/settings.json, then restart Claude Code:
"env": { "CLAUDE_CODE_PLUGIN_DIRS": "~/projects/sidequest" }
claude plugin test ~/projects/sidequest
claude plugin validate ~/projects/sidequest
MIT
hooks/register.js 252 lines1import { GAMES, gameById } from './games/index.js'
2
3const PANE = 'sidequest'
4const DROP_IN_DELAY_MS = 2000
5const COUNTDOWN_SECONDS = 3
6const OLD_KEYS = { best: 'best:snake', game: 'game:snake' }
7
8let isOn = true
9let currentGame = GAMES[0].id
10let saves = {}
11let phase = 'idle'
12let isTurnRunning = false
13let isDismissed = false
14let timer = null
15let countdown = 0
16
17function boardProps() {
18 return {
19 gameId: currentGame,
20 saves,
21 isPaused: phase === 'countdown',
22 isClaudeWorking: isTurnRunning,
23 countdown: phase === 'countdown' ? countdown : null,
24 }
25}
26
27async function moveOldKeys($) {
28 for (const [oldKey, newKey] of Object.entries(OLD_KEYS)) {
29 const value = await $.store.get(oldKey)
30 if (value === undefined) continue
31 if ((await $.store.get(newKey)) === undefined) await $.store.set(newKey, value)
32 await $.store.delete(oldKey)
33 }
34}
35
36async function loadStore($) {
37 await moveOldKeys($)
38 isOn = (await $.store.get('isOn')) !== false
39 currentGame = (gameById(await $.store.get('lastGame')) ?? GAMES[0]).id
40 saves = {}
41 for (const { id } of GAMES) {
42 const best = await $.store.get(`best:${id}`)
43 saves[id] = { state: (await $.store.get(`game:${id}`)) ?? null, best: typeof best === 'number' ? best : 0 }
44 }
45}
46
47function cancelTimer() {
48 timer?.cancel()
49 timer = null
50}
51
52async function chooseGame($, id) {
53 currentGame = id
54 await $.store.set('lastGame', id)
55}
56
57function armDropIn($) {
58 if (!isOn || !isTurnRunning || isDismissed || phase !== 'idle') return
59 phase = 'waiting'
60 timer = $.clock.after(DROP_IN_DELAY_MS, () => void dropIn($))
61}
62
63async function dropIn($) {
64 if (phase !== 'waiting') return
65 timer = null
66 const opened = await $.ui.open({ id: PANE, title: PANE })
67 if (!opened.isPlaced) {
68 await $.ui.close({ id: PANE })
69 phase = 'offered'
70 $.ui.invalidate('ui.render')
71 return
72 }
73 phase = 'playing'
74 $.ui.invalidate('ui.render')
75}
76
77async function acceptOffer($) {
78 if (phase !== 'offered') return
79 const opened = await $.ui.open({ id: PANE, title: PANE })
80 if (!opened.isPlaced) {
81 await $.ui.close({ id: PANE })
82 return
83 }
84 phase = 'playing'
85 $.ui.invalidate('ui.render')
86}
87
88function withdrawOffer($) {
89 cancelTimer()
90 phase = 'idle'
91 $.ui.invalidate('ui.render')
92}
93
94async function pullOut($) {
95 cancelTimer()
96 phase = 'idle'
97 await $.ui.close({ id: PANE })
98}
99
100async function handBack($) {
101 if (phase === 'waiting' || phase === 'offered') withdrawOffer($)
102 else if (phase === 'playing' || phase === 'countdown') await pullOut($)
103}
104
105function startCountdown($) {
106 phase = 'countdown'
107 countdown = COUNTDOWN_SECONDS
108 $.ui.invalidate('ui.render')
109 timer = $.clock.every(1000, () => {
110 countdown -= 1
111 if (countdown > 0) $.ui.invalidate('ui.render')
112 else void pullOut($)
113 })
114}
115
116async function goAway($) {
117 cancelTimer()
118 phase = 'idle'
119 for (const [id, save] of Object.entries(saves)) {
120 if (save.state) await $.store.set(`game:${id}`, save.state)
121 }
122}
123
124/** @type {import('claude-code').Register} */
125export const register = (on) => {
126 on('session.start', async ($, e, next) => {
127 await loadStore($)
128 await $.command.register({
129 name: 'sidequest',
130 description: 'Play a game while Claude works',
131 argumentHint: '[on|off|<game>]',
132 })
133 return next(e)
134 })
135
136 on('command.run', { command: 'sidequest' }, async ($, e) => {
137 const arg = e.args.trim().toLowerCase()
138 if (arg === 'off') {
139 isOn = false
140 await $.store.set('isOn', false)
141 await handBack($)
142 return { text: 'Sidequest will not open by itself now. /sidequest on turns it back on.' }
143 }
144 if (arg === 'on') {
145 isOn = true
146 await $.store.set('isOn', true)
147 armDropIn($)
148 return { text: 'Sidequest opens by itself while Claude works.' }
149 }
150 if (arg) {
151 const game = gameById(arg)
152 if (!game) return { text: `No game named ${arg}. Games: ${GAMES.map(({ id }) => id).join(', ')}.` }
153 await chooseGame($, game.id)
154 }
155 cancelTimer()
156 phase = 'playing'
157 await $.ui.open({ id: PANE, title: PANE })
158 $.ui.invalidate('ui.render')
159 return {}
160 })
161
162 on('turn.start', async ($, e, next) => {
163 isTurnRunning = true
164 isDismissed = false
165 if (phase === 'countdown') {
166 cancelTimer()
167 phase = 'playing'
168 }
169 $.ui.invalidate('ui.render')
170 armDropIn($)
171 return next(e)
172 })
173
174 on('turn.complete', async ($, e, next) => {
175 if (e.agentId) return next(e)
176 isTurnRunning = false
177 if (phase === 'waiting' || phase === 'offered') withdrawOffer($)
178 else if (phase === 'playing') {
179 if (e.isAborted) await pullOut($)
180 else startCountdown($)
181 }
182 return next(e)
183 })
184
185 // In auto mode an ask can go to the classifier instead of the person; closing anyway costs a moment, missing a real prompt costs more
186 on('tool.check', async ($, e, next) => {
187 const result = await next(e)
188 if (e.tool_use_id && result.decision === 'ask') await handBack($)
189 return result
190 })
191
192 on('tool.call', async ($, e, next) => {
193 if (e.tool === 'AskUserQuestion') await handBack($)
194 const result = await next(e)
195 armDropIn($)
196 return result
197 })
198
199 on('ui.close', async ($, e, next) => {
200 if (e.id !== PANE) return next(e)
201 if (e.origin.kind === 'person' && isTurnRunning) isDismissed = true
202 await goAway($)
203 return next(e)
204 })
205
206 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
207 if (phase !== 'offered') return next(e)
208 const { Box, Button } = $.ui.resolve(e)
209 const others = await next(e)
210 return Box({
211 flexDirection: 'column',
212 children: [
213 Button({ key: 'play', label: `Play ${gameById(currentGame).title} while Claude works`, hotkey: '1', plain: true, onPress: () => acceptOffer($) }),
214 ...(others ? [others] : []),
215 ],
216 })
217 })
218
219 on('ui.message', async ($, e) => {
220 if (e.element !== 'board') return {}
221 if (gameById(e.data.lastGame)) {
222 await chooseGame($, e.data.lastGame)
223 $.ui.invalidate('ui.render')
224 return {}
225 }
226 const { gameId } = e.data
227 if (!gameById(gameId)) return {}
228 const old = saves[gameId]
229 const best = typeof e.data.best === 'number' ? Math.max(old.best, e.data.best) : old.best
230 saves = { ...saves, [gameId]: { state: e.data.state ?? null, best } }
231 if (best > old.best) await $.store.set(`best:${gameId}`, best)
232 return {}
233 })
234
235 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
236 if (e.requestId !== PANE) return next(e)
237 const { Box, Text, Client } = $.ui.resolve(e)
238 if (e.surface !== 'terminal') return Text({ children: ['Sidequest plays in the terminal.'] })
239 return Box({
240 children: [
241 Client({
242 key: 'board',
243 module: './frame.js',
244 props: boardProps(),
245 width: e.props.bodyColumns,
246 height: e.props.scroll.bodyRows,
247 }),
248 ],
249 })
250 })
251}
252hooks/games/index.js 8 lines1import snake from './snake/index.js'
2
3export const GAMES = [snake]
4
5export function gameById(id) {
6 return GAMES.find((game) => game.id === id)
7}
8hooks/frame.js 173 lines1import { GAMES, gameById } from './games/index.js'
2import { menuKey, menuLines, openMenu } from './menu.js'
3
4const FRAME_MS = 16
5const STATUS_AND_HINT_ROWS = 2
6
7export default function Frame(props, surface) {
8 let state = surface.state
9 if (state === undefined) {
10 const gameId = (gameById(props.gameId) ?? GAMES[0]).id
11 const saves = props.saves ?? {}
12 state = {
13 props,
14 requestedId: props.gameId,
15 gameId,
16 saves,
17 game: saves[gameId]?.state ?? null,
18 best: saves[gameId]?.best ?? 0,
19 size: null,
20 isPaused: true,
21 elapsedMs: 0,
22 menu: null,
23 }
24 surface.onKey((e) => onKey(surface, e.key))
25 surface.every(FRAME_MS, () => onFrame(surface))
26 surface.setState(state)
27 }
28 state.props = props
29 return draw(state, surface)
30}
31
32function playSize(surface) {
33 return { columns: surface.columns, rows: surface.rows - STATUS_AND_HINT_ROWS }
34}
35
36function isSameSize(a, b) {
37 return a !== null && a.columns === b.columns && a.rows === b.rows
38}
39
40function withGame(state, id) {
41 const saves = { ...state.saves, [state.gameId]: { state: state.game, best: state.best } }
42 return {
43 ...state,
44 saves,
45 gameId: id,
46 game: saves[id]?.state ?? null,
47 best: saves[id]?.best ?? 0,
48 size: null,
49 isPaused: true,
50 elapsedMs: 0,
51 menu: null,
52 }
53}
54
55function onFrame(surface) {
56 const state = surface.state
57 if (state.props.gameId !== state.requestedId) {
58 const target = gameById(state.props.gameId)
59 const next = target && target.id !== state.gameId ? withGame(state, target.id) : state
60 surface.setState({ ...next, requestedId: state.props.gameId })
61 return
62 }
63 const game = gameById(state.gameId)
64 const size = playSize(surface)
65 if (!game.fits(size)) return
66 if (!isSameSize(state.size, size)) {
67 const fitted = game.restore(state.game, size) ?? game.start(size)
68 surface.setState({ ...state, game: fitted, size, isPaused: true, elapsedMs: 0 })
69 return
70 }
71 if (state.props.isPaused && !state.isPaused) {
72 surface.setState({ ...state, isPaused: true })
73 return
74 }
75 if (state.isPaused || state.props.isPaused || game.isOver(state.game)) return
76 const stepMs = game.stepMs(state.game)
77 if (stepMs === null) return
78 state.elapsedMs += FRAME_MS
79 if (state.elapsedMs < stepMs) return
80 update(surface, { game: game.step(state.game), elapsedMs: state.elapsedMs - stepMs })
81}
82
83function onKey(surface, key) {
84 const state = surface.state
85 if (!state.game || state.size === null || state.props.isPaused) return
86 if (state.menu) {
87 onMenuKey(surface, key)
88 return
89 }
90 if (key.toLowerCase() === 'm' && GAMES.length > 1) {
91 surface.setState({ ...state, menu: openMenu(GAMES.map(({ id }) => id), state.gameId), isPaused: true })
92 return
93 }
94 const game = gameById(state.gameId)
95 if (key === ' ' || key === 'space') {
96 if (game.isOver(state.game)) update(surface, { game: game.start(state.size), isPaused: false, elapsedMs: 0 })
97 else update(surface, { isPaused: !state.isPaused })
98 return
99 }
100 if (game.isOver(state.game)) return
101 const next = game.key(state.game, key)
102 if (next !== null) update(surface, { game: next, isPaused: false })
103}
104
105function onMenuKey(surface, key) {
106 const state = surface.state
107 const result = menuKey(state.menu, key)
108 if (result.action === 'move') surface.setState({ ...state, menu: result.menu })
109 else if (result.action === 'close') surface.setState({ ...state, menu: null })
110 else if (result.action === 'pick') {
111 surface.setState(withGame(state, result.id))
112 surface.post({ lastGame: result.id })
113 }
114}
115
116function update(surface, changes) {
117 const state = { ...surface.state, ...changes }
118 state.best = Math.max(state.best, gameById(state.gameId).score(state.game))
119 surface.setState(state)
120 surface.post({ gameId: state.gameId, state: state.game, best: state.best })
121}
122
123function draw(state, surface) {
124 const { Box, Text } = surface.elements
125 const game = gameById(state.gameId)
126 const size = playSize(surface)
127 if (surface.columns > 0 && !game.fits(size)) {
128 return Text({ dimColor: true, children: ['Make the pane bigger to play.'] })
129 }
130 if (!state.game || !isSameSize(state.size, size)) {
131 return Text({ dimColor: true, children: ['…'] })
132 }
133 return Box({
134 flexDirection: 'column',
135 children: [
136 Text({ children: [statusText(state, game)] }),
137 state.menu ? menuBox(state, Box, Text) : game.draw(state.game, surface.elements),
138 Text({ dimColor: true, children: [hintText(state, game)] }),
139 ],
140 })
141}
142
143function menuBox(state, Box, Text) {
144 const describe = (id) => ({
145 title: gameById(id).title,
146 best: id === state.gameId ? state.best : (state.saves[id]?.best ?? 0),
147 })
148 return Box({
149 flexDirection: 'column',
150 children: menuLines(state.menu, state.gameId, describe).map((line) => Text({ children: [line] })),
151 })
152}
153
154function statusText(state, game) {
155 const { props } = state
156 const claude =
157 props.countdown !== null && props.countdown !== undefined
158 ? `Claude is done · back in ${props.countdown}`
159 : props.isClaudeWorking
160 ? '● Claude is working'
161 : '✓ Claude is done'
162 const score = game.score(state.game)
163 return `${game.title} · Score ${score} · Best ${Math.max(state.best, score)} · ${claude}`
164}
165
166function hintText(state, game) {
167 if (state.props.isPaused) return 'Paused while Claude hands you back'
168 if (state.menu) return '↑↓ choose · Enter play · m back'
169 if (game.isOver(state.game)) return 'Game over · Space to play again'
170 if (state.isPaused) return 'Paused · click the board, then press a key to play'
171 return `${game.hint} · Space pauses${GAMES.length > 1 ? ' · m games' : ''}`
172}
173hooks/games/snake/index.js 89 lines1import { MIN_HEIGHT, MIN_WIDTH, fitTo, newGame, step, tickMs, turn } from './rules.js'
2
3const WALL_COLUMNS = 2
4const WALL_ROWS = 2
5const SNAKE_COLOR = '#4ade80'
6const HEAD_COLOR = '#d9f99d'
7const FOOD_COLOR = '#f87171'
8const WALL_COLOR = 'gray'
9const KEY_DIRECTIONS = { up: 'up', down: 'down', left: 'left', right: 'right', w: 'up', s: 'down', a: 'left', d: 'right' }
10
11function cells({ columns, rows }) {
12 return { width: Math.floor((columns - WALL_COLUMNS) / 2), height: rows - WALL_ROWS }
13}
14
15function fits(size) {
16 const { width, height } = cells(size)
17 return width >= MIN_WIDTH && height >= MIN_HEIGHT
18}
19
20function start(size) {
21 const { width, height } = cells(size)
22 return newGame(width, height)
23}
24
25function restore(saved, size) {
26 const { width, height } = cells(size)
27 return fitTo(saved, width, height)
28}
29
30function key(game, pressed) {
31 const direction = KEY_DIRECTIONS[pressed.length === 1 ? pressed.toLowerCase() : pressed]
32 return direction ? turn(game, direction) : null
33}
34
35function draw(game, { Box, Text }) {
36 return Box({
37 flexDirection: 'column',
38 children: [
39 Text({ color: WALL_COLOR, children: ['┌' + '──'.repeat(game.width) + '┐'] }),
40 ...boardRows(game, Text),
41 Text({ color: WALL_COLOR, children: ['└' + '──'.repeat(game.width) + '┘'] }),
42 ],
43 })
44}
45
46function boardRows(game, Text) {
47 const colors = new Map(game.snake.map(([x, y]) => [y * game.width + x, SNAKE_COLOR]))
48 const [headX, headY] = game.snake[0]
49 colors.set(headY * game.width + headX, HEAD_COLOR)
50 if (game.food) colors.set(game.food[1] * game.width + game.food[0], FOOD_COLOR)
51 const rows = []
52 for (let y = 0; y < game.height; y++) {
53 const runs = []
54 for (let x = 0; x < game.width; x++) {
55 const color = colors.get(y * game.width + x) ?? null
56 const last = runs.at(-1)
57 if (last && last.color === color) last.count += 1
58 else runs.push({ color, count: 1 })
59 }
60 rows.push(
61 Text({
62 children: [
63 Text({ color: WALL_COLOR, children: ['│'] }),
64 ...runs.map(({ color, count }) =>
65 color ? Text({ color, children: ['██'.repeat(count)] }) : ' '.repeat(count),
66 ),
67 Text({ color: WALL_COLOR, children: ['│'] }),
68 ],
69 }),
70 )
71 }
72 return rows
73}
74
75export default {
76 id: 'snake',
77 title: 'Snake',
78 hint: 'Arrows or WASD turn',
79 fits,
80 start,
81 restore,
82 key,
83 stepMs: tickMs,
84 step: (game) => step(game),
85 score: (game) => game.score,
86 isOver: (game) => game.isOver,
87 draw,
88}
89hooks/menu.js 26 lines1const UP_KEYS = new Set(['up', 'w'])
2const DOWN_KEYS = new Set(['down', 's'])
3const PICK_KEYS = new Set(['return', ' ', 'space'])
4
5export function openMenu(ids, currentId) {
6 return { ids, index: Math.max(0, ids.indexOf(currentId)) }
7}
8
9export function menuKey(menu, key) {
10 const pressed = key.length === 1 ? key.toLowerCase() : key
11 if (pressed === 'm') return { action: 'close' }
12 if (PICK_KEYS.has(pressed)) return { action: 'pick', id: menu.ids[menu.index] }
13 const delta = UP_KEYS.has(pressed) ? -1 : DOWN_KEYS.has(pressed) ? 1 : 0
14 if (delta === 0) return { action: 'none' }
15 const count = menu.ids.length
16 return { action: 'move', menu: { ...menu, index: (menu.index + delta + count) % count } }
17}
18
19export function menuLines(menu, currentId, describe) {
20 return menu.ids.map((id, i) => {
21 const { title, best } = describe(id)
22 const marker = i === menu.index ? '›' : ' '
23 return `${marker} ${title} · Best ${best}${id === currentId ? ' · playing' : ''}`
24 })
25}
26hooks/games/snake/rules.js 91 lines1export const MIN_WIDTH = 8
2export const MIN_HEIGHT = 4
3
4const MOVES = { up: [0, -1], down: [0, 1], left: [-1, 0], right: [1, 0] }
5const OPPOSITE = { up: 'down', down: 'up', left: 'right', right: 'left' }
6const START_TICK_MS = 115
7const FASTEST_TICK_MS = 40
8const FOODS_PER_SPEEDUP = 5
9const SPEEDUP_MS = 7
10const MAX_QUEUED_TURNS = 2
11
12export function newGame(width, height, random = Math.random) {
13 const y = Math.floor(height / 2)
14 const x = Math.floor(width / 3) + 2
15 const snake = [[x, y], [x - 1, y], [x - 2, y]]
16 return {
17 width,
18 height,
19 snake,
20 direction: 'right',
21 queued: [],
22 food: placeFood(width, height, snake, random),
23 score: 0,
24 isOver: false,
25 }
26}
27
28export function turn(game, direction) {
29 if (game.isOver || !MOVES[direction]) return game
30 const last = game.queued.at(-1) ?? game.direction
31 if (direction === last || direction === OPPOSITE[last] || game.queued.length >= MAX_QUEUED_TURNS) return game
32 return { ...game, queued: [...game.queued, direction] }
33}
34
35export function step(game, random = Math.random) {
36 if (game.isOver) return game
37 const [direction = game.direction, ...queued] = game.queued
38 const [dx, dy] = MOVES[direction]
39 const head = [game.snake[0][0] + dx, game.snake[0][1] + dy]
40 const eats = game.food !== null && head[0] === game.food[0] && head[1] === game.food[1]
41 const body = eats ? game.snake : game.snake.slice(0, -1)
42 const isOutside = head[0] < 0 || head[1] < 0 || head[0] >= game.width || head[1] >= game.height
43 if (isOutside || body.some(([x, y]) => x === head[0] && y === head[1])) {
44 return { ...game, direction, queued, isOver: true }
45 }
46 const snake = [head, ...body]
47 if (!eats) return { ...game, snake, direction, queued }
48 const food = placeFood(game.width, game.height, snake, random)
49 return { ...game, snake, direction, queued, score: game.score + 1, food, isOver: food === null }
50}
51
52export function tickMs(game) {
53 return Math.max(FASTEST_TICK_MS, START_TICK_MS - Math.floor(game.score / FOODS_PER_SPEEDUP) * SPEEDUP_MS)
54}
55
56export function fitTo(value, width, height) {
57 if (!isGame(value)) return null
58 const isInside = ([x, y]) => x < width && y < height
59 if (!value.snake.every(isInside) || (value.food !== null && !isInside(value.food))) return null
60 return { ...value, width, height }
61}
62
63function placeFood(width, height, snake, random) {
64 const taken = new Set(snake.map(([x, y]) => y * width + x))
65 const free = []
66 for (let i = 0; i < width * height; i++) if (!taken.has(i)) free.push(i)
67 if (free.length === 0) return null
68 const cell = free[Math.floor(random() * free.length)]
69 return [cell % width, Math.floor(cell / width)]
70}
71
72function isGame(value) {
73 return (
74 typeof value === 'object' &&
75 value !== null &&
76 Array.isArray(value.snake) &&
77 value.snake.length > 0 &&
78 value.snake.every(isCell) &&
79 (value.food === null || isCell(value.food)) &&
80 MOVES[value.direction] !== undefined &&
81 Array.isArray(value.queued) &&
82 value.queued.every((direction) => MOVES[direction] !== undefined) &&
83 Number.isInteger(value.score) &&
84 typeof value.isOver === 'boolean'
85 )
86}
87
88function isCell(value) {
89 return Array.isArray(value) && value.length === 2 && value.every((n) => Number.isInteger(n) && n >= 0)
90}
91