Play snake while Claude works. Every tool call Claude makes drops food into the game; the game pauses when Claude is done.

Play snake inside Claude Code while it works. Every tool call Claude makes drops food into the game.
<a href="media/tool-snake-launch.mp4"><img src="media/tool-snake-demo.gif" width="800" alt="Claude Code with the tool-snake pane docked beside the chat: each tool call in the chat drops a colored chip onto the snake board"></a>
git clone https://github.com/Yash1927/claude-code-snake claude --plugin-dir claude-code-snake ``r restarts after a crash, Esc gives the keys back to the prompt./snake opens the pane any time.The game needs the terminal or the desktop app. In the fullscreen layout the pane docks beside the chat; otherwise it sits above the prompt.
tool-snake is a Claude Code mod: a plugin of function hooks that runs in the session.
| file | what it does |
|---|---|
hooks/register.tsx | Hooks prompt.submit (start a turn, open the pane), tool.call (add food for the main agent's calls), turn.complete (stop) and ui.message (toast and speak the final count), and draws the pane. |
hooks/game.tsx | The game: a 22x12 board with 2-character square cells, stepped every 110 ms, edges wrap. Draws the header, board, legend and hint. |
tests/snake.test.tsx | Food from tool calls, eating, pausing when Claude is done, steering, the spoken line, and the pane on the terminal. |
claude plugin validate tool-snake
claude plugin test tool-snake
The video in media/ is built entirely in code, with no After Effects. An HTML page draws every frame from a single time value, Playwright renders it at 60 fps, and ffmpeg encodes it. It runs this mod's own game logic, so the board, chips and the final 9 of 14 are what the mod produces for those tool calls. Source and re-render steps are in video/.
hooks/register.tsx 92 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Feed } from '../types'
5
6const PANE = 'tool-snake'
7const EMPTY: Feed = { working: false, turn: 0, foods: [] }
8
9const feed = atom({ plugin: 'tool-snake', key: 'feed' } as const, EMPTY)
10const best = atom({ plugin: 'tool-snake', key: 'best' } as const, 0)
11
12type Final = { type: 'final'; eaten: number; total: number; score: number }
13
14const isFinal = (d: unknown): d is Final =>
15 typeof d === 'object' && d !== null && (d as { type?: unknown }).type === 'final'
16
17export const finalLine = (f: Final): string =>
18 f.total === 0
19 ? 'Claude is done.'
20 : `Claude is done. You ate ${f.eaten} of its ${f.total} tool call${f.total === 1 ? '' : 's'}.`
21
22export const register: Register = on => {
23 let counter = 0
24
25 on('session.start', async ($, e, next) => {
26 await $.command.register({
27 name: 'snake',
28 description: 'tool-snake: open the game pane',
29 })
30 return next(e)
31 })
32
33 on('command.run', { command: 'snake' }, async $ => {
34 await $.ui.open({ id: PANE, title: 'tool snake' })
35 return { text: 'tool-snake: click the game to play, Esc to give the keys back.' }
36 })
37
38 on('prompt.submit', async ($, e, next) => {
39 await update($, feed, f => ({ working: true, turn: f.turn + 1, foods: [] }))
40 // A prompt the person entered counts as asking, so the pane opens at any width.
41 void $.ui.open({ id: PANE, title: 'tool snake' })
42 return next(e)
43 })
44
45 on('tool.call', async ($, e, next) => {
46 if (e.agentId === undefined) {
47 counter += 1
48 const id = e.tool_use_id ?? `t${counter}`
49 await update($, feed, f => ({ ...f, foods: [...f.foods, { id, tool: e.tool }].slice(-60) }))
50 }
51 return next(e)
52 })
53
54 on('turn.complete', async ($, e, next) => {
55 if (e.agentId === undefined) {
56 await update($, feed, f => ({ ...f, working: false }))
57 }
58 return next(e)
59 })
60
61 on('ui.message', async ($, e, next) => {
62 if (e.requestId === PANE && isFinal(e.data)) {
63 const f = e.data
64 const top = await read($, best)
65 const isBest = f.score > top
66 if (isBest) await update($, best, () => f.score)
67 $.ui.toast(`tool-snake: ${finalLine(f)}${isBest ? ' New best!' : ''}`)
68 $.audio.speak(finalLine(f)).catch(() => undefined)
69 }
70 return next(e)
71 })
72
73 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
74 const els = $.ui.resolve(e)
75 const { Box, Text } = els
76 const Client = 'Client' in els ? els.Client : undefined
77 const f = await read($, feed)
78 const top = await read($, best)
79
80 return (
81 <Box flexDirection="column">
82 <Text dimColor>every tool call Claude makes drops food · best {top}</Text>
83 {Client ? (
84 <Client key="game" module="./game.tsx" props={f} width={46} height={17} />
85 ) : (
86 <Text dimColor>The game needs the terminal or desktop app.</Text>
87 )}
88 </Box>
89 )
90 })
91}
92hooks/game.tsx 263 lines1import type { ClientKeyEvent, ClientSurface, RenderElement } from 'claude-code'
2
3import type { Feed } from '../types'
4
5// Board in cells; each cell is drawn 2 characters wide so it looks square.
6export const W = 22
7export const H = 12
8
9type P = { x: number; y: number }
10type Dot = P & { id: string; tool: string }
11type Dir = 'up' | 'down' | 'left' | 'right'
12
13type S = {
14 snake: P[]
15 dir: Dir
16 next: Dir
17 dots: Dot[]
18 seen: string[]
19 score: number
20 eaten: number
21 total: number
22 turn: number
23 alive: boolean
24 wasWorking: boolean
25 seed: number
26}
27
28// The latest props per instance, read by the tick (closures from the first call hold old props).
29const latest = new WeakMap<object, Feed>()
30
31const COLORS: Record<string, string> = {
32 Bash: '#facc15',
33 Edit: '#e879f9',
34 MultiEdit: '#e879f9',
35 Write: '#fb923c',
36 Read: '#22d3ee',
37 Grep: '#60a5fa',
38 Glob: '#60a5fa',
39}
40const letter = (tool: string): string => (tool.replace(/^mcp__/, '')[0] ?? '?').toUpperCase()
41const colorOf = (tool: string): string => COLORS[tool] ?? '#e5e7eb'
42
43const OPPOSITE: Record<Dir, Dir> = { up: 'down', down: 'up', left: 'right', right: 'left' }
44const KEYS: Record<string, Dir> = {
45 up: 'up', down: 'down', left: 'left', right: 'right',
46 w: 'up', s: 'down', a: 'left', d: 'right',
47}
48
49// Small deterministic random so a restart doesn't need Math.random.
50const rand = (s: S, n: number): number => {
51 s.seed = (s.seed * 1103515245 + 12345) & 0x7fffffff
52 return s.seed % n
53}
54
55export const fresh = (turn: number, seed = 7): S => ({
56 snake: [{ x: 10, y: 8 }, { x: 9, y: 8 }, { x: 8, y: 8 }],
57 dir: 'right',
58 next: 'right',
59 dots: [],
60 seen: [],
61 score: 0,
62 eaten: 0,
63 total: 0,
64 turn,
65 alive: true,
66 wasWorking: false,
67 seed,
68})
69
70const occupied = (s: S, p: P): boolean =>
71 s.snake.some(c => c.x === p.x && c.y === p.y) || s.dots.some(d => d.x === p.x && d.y === p.y)
72
73// One step of the world: new food from Claude's tool calls, then the snake moves.
74export const step = (prev: S, feed: Feed): { s: S; final?: { eaten: number; total: number; score: number } } => {
75 const s: S = { ...prev, snake: [...prev.snake], dots: [...prev.dots], seen: [...prev.seen] }
76 let final: { eaten: number; total: number; score: number } | undefined
77
78 if (feed.turn !== s.turn) {
79 s.turn = feed.turn
80 s.eaten = 0
81 s.total = 0
82 s.dots = []
83 }
84
85 for (const f of feed.foods) {
86 if (s.seen.includes(f.id)) continue
87 s.seen = [...s.seen, f.id].slice(-200)
88 s.total += 1
89 for (let tries = 0; tries < 40; tries++) {
90 const p = { x: rand(s, W), y: rand(s, H) }
91 if (!occupied(s, p)) {
92 s.dots.push({ ...p, id: f.id, tool: f.tool })
93 break
94 }
95 }
96 }
97
98 if (s.wasWorking && !feed.working) {
99 final = { eaten: s.eaten, total: s.total, score: s.score }
100 }
101 s.wasWorking = feed.working
102
103 // The snake only moves while Claude works.
104 if (!feed.working || !s.alive) return { s, final }
105
106 if (s.next !== OPPOSITE[s.dir]) s.dir = s.next
107 const head = s.snake[0]!
108 const d = { up: [0, -1], down: [0, 1], left: [-1, 0], right: [1, 0] }[s.dir]
109 const nh = { x: (head.x + d[0]! + W) % W, y: (head.y + d[1]! + H) % H }
110
111 if (s.snake.some(c => c.x === nh.x && c.y === nh.y)) {
112 s.alive = false
113 return { s, final }
114 }
115
116 s.snake.unshift(nh)
117 const hit = s.dots.findIndex(p => p.x === nh.x && p.y === nh.y)
118 if (hit >= 0) {
119 s.dots.splice(hit, 1)
120 s.score += 1
121 s.eaten += 1
122 } else {
123 s.snake.pop()
124 }
125 return { s, final }
126}
127
128export const press = (s: S, k: ClientKeyEvent): S | undefined => {
129 if (k.key === 'r' && !s.alive) return { ...fresh(s.turn, s.seed), score: s.score, total: s.total, eaten: s.eaten, wasWorking: s.wasWorking }
130 const dir = KEYS[k.key]
131 return dir ? { ...s, next: dir } : undefined
132}
133
134// Palette
135const BOARD_A = '#15181e'
136const BOARD_B = '#181b22'
137const FRAME = '#2a2f3a'
138const INK = '#0b0d10'
139const MUTED = '#6b7280'
140const HEAD_ALIVE = '#86efac'
141const HEAD_DEAD = '#f87171'
142
143// Body fades from bright to deep green toward the tail.
144const BODY = ['#4ade80', '#3fcf73', '#35bf66', '#2cae5a', '#239e4e', '#1b8e43', '#157f3a']
145const bodyColor = (i: number, len: number): string =>
146 BODY[Math.min(BODY.length - 1, Math.floor((i / Math.max(1, len - 1)) * (BODY.length - 1)))]!
147
148const EYES: Record<Dir, string> = { up: '\u02D9\u02D9', down: '..', left: ': ', right: ' :' }
149
150type Run = { text: string; color?: string; bg?: string; bold?: boolean }
151
152export default function Game(props: Feed, surface: ClientSurface<S>): RenderElement {
153 latest.set(surface, props)
154 const { Box, Text } = surface.elements
155
156 let s = surface.state
157 if (s === undefined) {
158 s = fresh(props.turn)
159 const start = s
160 surface.every(110, () => {
161 const cur = surface.state ?? start
162 const { s: n, final } = step(cur, latest.get(surface) ?? props)
163 if (final) surface.post({ type: 'final', ...final })
164 surface.setState(n)
165 })
166 surface.onKey(k => {
167 const n = press(surface.state ?? start, k)
168 if (n) surface.setState(n)
169 })
170 }
171
172 const line = (key: string, runs: Run[]): RenderElement => {
173 const merged: Run[] = []
174 for (const r of runs) {
175 const last = merged[merged.length - 1]
176 if (last && last.color === r.color && last.bg === r.bg && last.bold === r.bold) last.text += r.text
177 else merged.push({ ...r })
178 }
179 return (
180 <Text key={key}>
181 {merged.map((r, i) => (
182 <Text key={`${key}-${i}`} color={r.color} backgroundColor={r.bg} bold={r.bold}>
183 {r.text}
184 </Text>
185 ))}
186 </Text>
187 )
188 }
189
190 // Header: title chip, state chip, numbers.
191 const state = !s.alive
192 ? { label: ' CRASHED ', bg: HEAD_DEAD }
193 : props.working
194 ? { label: ' LIVE ', bg: '#22c55e' }
195 : props.turn === 0
196 ? { label: ' READY ', bg: MUTED }
197 : { label: ' DONE ', bg: '#38bdf8' }
198
199 const header = line('h', [
200 { text: ' TOOL SNAKE ', color: INK, bg: '#4ade80', bold: true },
201 { text: ' ' },
202 { text: state.label, color: INK, bg: state.bg, bold: true },
203 { text: ' score ', color: MUTED },
204 { text: String(s.score), color: '#f9fafb', bold: true },
205 { text: ' ate ', color: MUTED },
206 { text: `${s.eaten}/${s.total}`, color: '#f9fafb', bold: true },
207 { text: ' calls', color: MUTED },
208 ])
209
210 // Board with a rounded frame.
211 const rows: RenderElement[] = [line('top', [{ text: '\u256D' + '\u2500'.repeat(W * 2) + '\u256E', color: FRAME }])]
212 const len = s.snake.length
213 for (let y = 0; y < H; y++) {
214 const runs: Run[] = [{ text: '\u2502', color: FRAME }]
215 for (let x = 0; x < W; x++) {
216 const body = s.snake.findIndex(c => c.x === x && c.y === y)
217 const dot = body < 0 ? s.dots.find(p => p.x === x && p.y === y) : undefined
218 if (body === 0) {
219 runs.push({ text: EYES[s.dir], color: INK, bg: s.alive ? HEAD_ALIVE : HEAD_DEAD, bold: true })
220 } else if (body > 0) {
221 runs.push({ text: ' ', bg: s.alive ? bodyColor(body, len) : '#7f1d1d' })
222 } else if (dot) {
223 runs.push({ text: letter(dot.tool) + ' ', color: INK, bg: colorOf(dot.tool), bold: true })
224 } else {
225 runs.push({ text: ' ', bg: (x + y) % 2 === 0 ? BOARD_A : BOARD_B })
226 }
227 }
228 runs.push({ text: '\u2502', color: FRAME })
229 rows.push(line(`r${y}`, runs))
230 }
231 rows.push(line('bot', [{ text: '\u2570' + '\u2500'.repeat(W * 2) + '\u256F', color: FRAME }]))
232
233 // Legend and hint.
234 const chip = (t: string, name: string, tool: string): Run[] => [
235 { text: ` ${t} `, color: INK, bg: colorOf(tool), bold: true },
236 { text: ` ${name} `, color: MUTED },
237 ]
238 const legend = line('legend', [
239 ...chip('B', 'bash', 'Bash'),
240 ...chip('E', 'edit', 'Edit'),
241 ...chip('R', 'read', 'Read'),
242 ...chip('W', 'write', 'Write'),
243 ...chip('G', 'grep', 'Grep'),
244 ])
245
246 const hint = !s.alive
247 ? 'crashed into yourself · press r to restart'
248 : props.working
249 ? 'click to play · arrows / wasd · esc to type'
250 : props.turn === 0
251 ? 'send Claude a prompt, food drops as it works'
252 : `Claude is done \u2713 you ate ${s.eaten} of its ${s.total} tool calls`
253
254 return (
255 <Box flexDirection="column">
256 {header}
257 {rows}
258 {legend}
259 <Text color={!s.alive ? HEAD_DEAD : props.working ? MUTED : '#38bdf8'}>{hint}</Text>
260 </Box>
261 )
262}
263types/index.d.ts 14 lines1export type Food = { id: string; tool: string }
2
3export type Feed = {
4 working: boolean
5 turn: number
6 foods: Food[]
7}
8
9declare module 'claude-code' {
10 interface PluginState {
11 'tool-snake': { feed: Feed; best: number }
12 }
13}
14