SLOPSHOPPER

tool-snake

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

paneguardcommandtoastpromptaudio
★ 1v0.2.0MITupdated 2026-10-02Yash1927/claude-code-snake
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · tool-snake
│ ┃ tool snake ✕ › fix the failing auth test and add an audit log call │ ┃ every tool call Claude makes drops food · │ ┃ best 0 ⏺ Read(src/auth.ts) │ ┃ ▣ client module ./game.tsx ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /snake │ ⎿ tool-snake: tool-snake: click the game to play, Esc to give the │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · tool snake
every tool call Claude makes drops food · best 0 ▣ client module ./game.tsx
README

tool-snake

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>

What it does

  • A game pane opens next to the chat when you send Claude a prompt.
  • Every tool call Claude makes drops food, shown as the tool's first letter in the tool's color: B Bash, E Edit, R Read, W Write, G Grep (and Glob). Other tools drop a gray chip.
  • The snake only moves while Claude is working. When Claude finishes, the game pauses, the badge flips from LIVE to DONE, and it tells you, in a toast and out loud, how many of its tool calls you ate: "Claude is done. You ate 9 of its 14 tool calls."
  • Your best score is kept for the session.

Play

  1. Get the mod and start Claude Code with it: `` git clone https://github.com/Yash1927/claude-code-snake claude --plugin-dir claude-code-snake ``
  2. Send Claude a prompt. The game pane opens.
  3. Click the game to give it the keys. Arrows or WASD steer, r restarts after a crash, Esc gives the keys back to the prompt.
  4. /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.

How it works

tool-snake is a Claude Code mod: a plugin of function hooks that runs in the session.

filewhat it does
hooks/register.tsxHooks 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.tsxThe 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.tsxFood from tool calls, eating, pausing when Claude is done, steering, the spoken line, and the pane on the terminal.

Test

claude plugin validate tool-snake
claude plugin test tool-snake

The launch video

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/.

License

MIT © Yash Rastogi (@yashx26)

Source 3 files
hooks/register.tsx 92 lines
1import { 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}
92
hooks/game.tsx 263 lines
1import 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}
263
types/index.d.ts 14 lines
1export 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