SLOPSHOPPER

snake

Snake in a pane while Claude works; it pauses when Claude is done. /snake turns it on or off.

newpanespinnercommand
v0.1.0no licenseupdated 2026-10-10simplybychris/claude-code-mods/snake
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · snake
│ ┃ Snake ✕ › fix the failing auth test and add an audit log call │ ┃ ▣ client module ./game.tsx │ ⏺ Read(src/auth.ts) │ ⎿ 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 │ ⎿ snake: Snake is on. It plays while Claude works. /snake again tu │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Snake
▣ client module ./game.tsx
README

Snake

Gra w węża w panelu obok rozmowy, na czas czekania na Claude'a. Gra się, gdy Claude pracuje, i staje, kiedy skończy.

Jak używać

  • /snake włącza i wyłącza grę (albo /snake on, /snake off).
  • Kliknij panel, żeby grać. Sterowanie: strzałki albo WASD. Esc oddaje klawiaturę.
  • Najlepszy wynik zostaje zapamiętany, a w trakcie gry widać go też przy spinnerze.

Panel potrzebuje miejsca: najlepiej szerokie okno terminala albo aplikacja Desktop.

Na podstawie moda Snake z hamzafer/claude-code-mods, licencja MIT (plik LICENSE).

Wymaga Claude Code 2.1.287+ w terminalu albo 2.1.286+ w aplikacji Desktop. Brak zależności.

Source 4 files
hooks/register.tsx 96 lines
1// Snake: play in a pane while Claude works; it pauses when Claude is done.
2// Nothing opens until /snake; /snake again (or /snake off) turns it off.
3// From hamzafer/claude-code-mods (MIT, see LICENSE). Its state lives in this module
4// instead of the atom helpers, which Claude Code 2.1.288 refuses to load.
5import type { Register } from 'claude-code'
6
7const PANE = 'snake'
8const BEST_KEY = 'best'
9const DEFAULT_ROWS = 18 // when a surface reports no body rows
10
11export const register: Register = on => {
12  let isOn = false // /snake turned it on
13  let isWorking = false // a main-loop turn is running, whether or not Snake is on
14  let score = 0
15  let best = 0
16
17  const isPlaying = () => isOn && isWorking
18
19  on('session.start', async ($, e, next) => {
20    const r = await next(e)
21    await $.command
22      .register({ name: 'snake', description: 'Turn Snake on or off: it plays in a pane while Claude works', argumentHint: '[on|off]' })
23      .catch(() => {})
24    const saved = Number(await $.store.get(BEST_KEY).catch(() => 0)) || 0
25    best = Math.max(best, saved)
26    return r
27  })
28
29  on('turn.start', async ($, e, next) => {
30    isWorking = true
31    if (!isOn) return next(e) // opt-in: nothing until /snake
32    $.ui.invalidate('ui.render')
33    void $.ui.open({ id: PANE, title: 'Snake' }).catch(() => {}) // seats from 144 columns when opened unasked
34    return next(e)
35  })
36
37  on('turn.complete', async ($, e, next) => {
38    const r = await next(e)
39    if (!e.agentId) {
40      isWorking = false
41      $.ui.invalidate('ui.render')
42    }
43    return r
44  })
45
46  on('command.run', { command: 'snake' }, async ($, e) => {
47    const arg = e.args.trim().toLowerCase()
48    if (!['', 'on', 'off', 'stop'].includes(arg)) return { text: 'usage: /snake toggles it, /snake on or /snake off says which' }
49    // A bare /snake toggles; `on`, `off` and `stop` say which.
50    const isOff = arg === 'stop' || arg === 'off' || (arg !== 'on' && isOn)
51    if (isOff) {
52      isOn = false
53      $.ui.invalidate('ui.render')
54      await $.ui.close({ id: PANE })
55      return { text: 'Snake is off. /snake (or /snake on) turns it back on.' }
56    }
57    isOn = true // turned on mid-turn: it plays now, not next turn
58    $.ui.invalidate('ui.render')
59    await $.ui.open({ id: PANE, title: 'Snake', focus: true })
60    return { text: 'Snake is on. It plays while Claude works. /snake again turns it off.' }
61  })
62
63  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
64    if (e.surface !== 'terminal' && e.surface !== 'desktop') {
65      const { Text } = $.ui.resolve(e)
66      return <Text dimColor>Snake plays in the terminal or desktop app.</Text>
67    }
68    const { Client } = $.ui.resolve(e)
69    // An exact size, and the same numbers handed to the game: a percentage height has
70    // nothing to stretch against inside a pane, and the desktop fits a region to its content.
71    const width = Math.max(1, e.props.bodyColumns)
72    const height = e.props.scroll.bodyRows > 1 ? e.props.scroll.bodyRows - 1 : DEFAULT_ROWS
73    const props = { isPlaying: isPlaying(), best, columns: width, rows: height }
74    return <Client key="snake" module="./game.tsx" props={props} width={width} height={height} />
75  })
76
77  // The game posts its score whenever it eats or crashes.
78  on('ui.message', async ($, e) => {
79    const data = e.data as { score?: unknown }
80    if (e.element !== 'snake' || typeof data?.score !== 'number') return {}
81    score = data.score
82    if (score > best) {
83      best = score
84      await $.store.set(BEST_KEY, best)
85    }
86    $.ui.invalidate('ui.render')
87    return {}
88  })
89
90  on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
91    if (!isPlaying() || score === 0) return next(e)
92    const text = e.props.message ?? e.props.word
93    return next({ ...e, props: { ...e.props, message: `${text} · 🐍 ${score} (best ${best})` } })
94  })
95}
96
hooks/game.tsx 95 lines
1// The game itself: runs on the drawing thread with its own clock and keys.
2import type { ClientModule } from 'claude-code'
3
4import { CELL, CHROME_COLS, COLOR, MIN_COLS, MIN_ROWS, gridSize, paint, runs } from './board'
5import { newGame, step, turn } from './snake'
6import type { Dir, Game } from './snake'
7
8// `columns` and `rows`: the room the pane gives, from the hook. The board sizes from these,
9// not from the region's own layout: the desktop fits a region to what it draws, so a
10// region measured while showing one line of text stays one line tall.
11export type GameProps = { isPlaying: boolean; best: number; columns: number; rows: number }
12type Local = { game: Game | null; live: { props: GameProps }; flash: number }
13
14const TICK_MS = 120
15const FLASH_TICKS = 6 // a crash shows for about three quarters of a second
16const KEYS: Record<string, Dir> = { up: 'up', down: 'down', left: 'left', right: 'right', w: 'up', s: 'down', a: 'left', d: 'right' }
17
18const SnakeGame: ClientModule<GameProps, Local> = (props, surface) => {
19  const { Box, Text } = surface.elements
20  const room = (p: GameProps) => ({ columns: p.columns > 0 ? p.columns : surface.columns, rows: p.rows > 0 ? p.rows : surface.rows })
21  const { cols, rows } = gridSize(room(props).columns, room(props).rows)
22
23  if (surface.state === undefined) {
24    const local: Local = { game: null, live: { props }, flash: 0 }
25    surface.every(TICK_MS, () => {
26      const now = surface.state
27      if (!now) return
28      const r0 = room(now.live.props)
29      const size = gridSize(r0.columns, r0.rows)
30      if (size.cols < MIN_COLS || size.rows < MIN_ROWS) return
31      if (!now.game || now.game.cols !== size.cols || now.game.rows !== size.rows) {
32        surface.setState({ ...now, game: newGame(size.cols, size.rows, Date.now() % 100000) }) // a new or resized pane
33        return
34      }
35      if (now.flash > 0) {
36        surface.setState({ ...now, flash: now.flash - 1 }) // a crash holds the board still for a moment
37        return
38      }
39      if (!now.live.props.isPlaying) return
40      const r = step(now.game)
41      if (r.ate || r.crashed) surface.post({ score: r.game.score })
42      surface.setState({ ...now, game: r.game, flash: r.crashed ? FLASH_TICKS : 0 })
43    })
44    surface.onKey(e => {
45      const now = surface.state
46      const dir = KEYS[e.key.toLowerCase()]
47      if (!now?.game || !dir || !now.live.props.isPlaying || now.flash > 0) return // paused: keys do nothing
48      surface.setState({ ...now, game: turn(now.game, dir) })
49    })
50    surface.setState(local)
51    return <Text dimColor>Loading Snake…</Text>
52  }
53
54  const local = surface.state
55  local.live.props = props // the clock and keys read the latest props
56
57  if (cols < MIN_COLS || rows < MIN_ROWS) return <Text dimColor>{`Make the pane bigger to play (${room(props).columns}×${room(props).rows})`}</Text>
58
59  const game = local.game
60  if (!game || game.cols !== cols || game.rows !== rows) return <Text dimColor>Setting up the board…</Text>
61
62  const isPaused = !props.isPlaying
63  const isCrash = local.flash > 0
64  const best = Math.max(props.best, game.score)
65  const blink = Math.floor(Date.now() / 400) % 2
66  const cells = paint(game, blink, isPaused)
67  const frame = isCrash ? COLOR.frameCrash : isPaused ? COLOR.frameOff : COLOR.frame
68  const width = game.cols * CELL + CHROME_COLS
69
70  return (
71    <Box flexDirection="column" alignItems="center" width={room(props).columns}>
72      <Box width={width} justifyContent="space-between">
73        <Text bold color={isPaused ? undefined : COLOR.frame} dimColor={isPaused}>SNAKE</Text>
74        <Text>
75          <Text>{`score ${game.score}`}</Text>
76          <Text dimColor>{` · best ${best}`}</Text>
77        </Text>
78      </Box>
79      <Box key="board" flexDirection="column" borderStyle="round" borderColor={frame}>
80        {Array.from({ length: game.rows }, (_, y) => (
81          <Box key={`row-${y}`} flexDirection="row" height={1}>
82            {runs(game.cols, y, cells).map((r, i) => (
83              <Box key={`run-${y}-${i}`} width={r.n * CELL} height={1} backgroundColor={r.color} />
84            ))}
85          </Box>
86        ))}
87      </Box>
88      {isCrash ? <Text color={COLOR.frameCrash}>crashed</Text> : isPaused ? <Text color="yellow">{"⏸ Claude's done, your turn"}</Text> : <Text dimColor>arrows or WASD</Text>}
89      <Text dimColor>{isPaused ? 'Paused until Claude works again' : 'Click to play · Esc gives the keys back'}</Text>
90    </Box>
91  )
92}
93
94export default SnakeGame
95
hooks/board.ts 67 lines
1// Snake's board: pure functions over plain values (sizes, colors, runs), no $ and no timers.
2import type { Game } from './snake'
3
4export const MIN_COLS = 10
5export const MIN_ROWS = 6
6const MAX_COLS = 36 // a board past this on a wide pane is mostly empty: it is centered instead
7const MAX_ROWS = 24
8export const CELL = 2 // columns a cell is wide: two of a terminal's make a square
9const CHROME_ROWS = 4 // header, footer and the two rows of the frame
10export const CHROME_COLS = 2 // the frame's sides
11
12// Every cell is a box with a background and no glyph: a glyph (the desktop's `█` is wider than
13// its cell) wraps a row into two and shears the grid, a box is exactly as many cells as it says.
14export const COLOR = {
15  bandA: '#161b22',
16  bandB: '#1a2029',
17  head: '#c4ff7a',
18  tailFrom: '#52d65c', // the neck
19  tailTo: '#1f6f33', // the tip
20  food: ['#ff5f5f', '#ffa3a3'] as const, // it blinks between the two
21  frame: '#3fb950',
22  frameOff: '#444c56',
23  frameCrash: '#f85149',
24  rest: '#22272e', // what a paused board fades toward
25}
26
27// Cells across and down for the room given: a cell is two columns wide, the frame takes the
28// sides, a header and a footer line sit around the board.
29export function gridSize(columns: number, rows: number) {
30  return { cols: Math.min(MAX_COLS, Math.floor((columns - CHROME_COLS) / CELL)), rows: Math.min(MAX_ROWS, rows - CHROME_ROWS) }
31}
32
33// The color of every cell that is not empty: a gradient from neck to tip, a blinking food.
34// A paused board fades toward the rest color.
35export function paint(game: Game, blink: number, isPaused: boolean) {
36  const fade = (c: string) => (isPaused ? mix(c, COLOR.rest, 0.6) : c)
37  const cells = new Map<string, string>()
38  cells.set(`${game.food.x},${game.food.y}`, fade(COLOR.food[blink]!))
39  const tail = Math.max(1, game.snake.length - 2)
40  game.snake.forEach((c, i) => cells.set(`${c.x},${c.y}`, fade(i === 0 ? COLOR.head : mix(COLOR.tailFrom, COLOR.tailTo, (i - 1) / tail))))
41  return cells
42}
43
44// One row as runs of same-colored cells, so a row is a few boxes, not one per cell. An empty
45// cell takes a band color by its row: the faint stripes make the grid readable.
46export function runs(cols: number, y: number, cells: Map<string, string>) {
47  const band = y % 2 === 0 ? COLOR.bandA : COLOR.bandB
48  const out: { color: string; n: number }[] = []
49  for (let x = 0; x < cols; x++) {
50    const color = cells.get(`${x},${y}`) ?? band
51    const last = out[out.length - 1]
52    if (last && last.color === color) last.n += 1
53    else out.push({ color, n: 1 })
54  }
55  return out
56}
57
58// A blend of two `#rrggbb` colors, `t` of the way from `a` to `b`.
59export function mix(a: string, b: string, t: number) {
60  const channel = (hex: string, i: number) => parseInt(hex.slice(1 + i * 2, 3 + i * 2), 16)
61  const to = (i: number) =>
62    Math.round(channel(a, i) + (channel(b, i) - channel(a, i)) * Math.min(1, Math.max(0, t)))
63      .toString(16)
64      .padStart(2, '0')
65  return `#${to(0)}${to(1)}${to(2)}`
66}
67
hooks/snake.ts 65 lines
1// Snake's rules: pure functions over a plain game value, no $ and no timers.
2
3export type Dir = 'up' | 'down' | 'left' | 'right'
4export type Cell = { x: number; y: number }
5export type Game = {
6  cols: number
7  rows: number
8  snake: Cell[] // head first
9  dir: Dir
10  nextDir: Dir
11  food: Cell
12  score: number
13  seed: number
14}
15
16const START_LENGTH = 3
17const MOVES: Record<Dir, Cell> = { up: { x: 0, y: -1 }, down: { x: 0, y: 1 }, left: { x: -1, y: 0 }, right: { x: 1, y: 0 } }
18const OPPOSITE: Record<Dir, Dir> = { up: 'down', down: 'up', left: 'right', right: 'left' }
19
20export function newGame(cols: number, rows: number, seed = 1): Game {
21  const y = Math.floor(rows / 2)
22  const x = Math.floor(cols / 2)
23  const snake = Array.from({ length: START_LENGTH }, (_, i) => ({ x: x - i, y }))
24  const placed = placeFood(cols, rows, snake, seed)
25  return { cols, rows, snake, dir: 'right', nextDir: 'right', food: placed.food, score: 0, seed: placed.seed }
26}
27
28// Queues a turn for the next step; a 180° turn is ignored.
29export function turn(game: Game, dir: Dir): Game {
30  if (dir === OPPOSITE[game.dir] || dir === game.nextDir) return game
31  return { ...game, nextDir: dir }
32}
33
34export function step(game: Game): { game: Game; ate: boolean; crashed: boolean } {
35  const dir = game.nextDir
36  const head = game.snake[0]!
37  const next = { x: head.x + MOVES[dir].x, y: head.y + MOVES[dir].y }
38  const ate = next.x === game.food.x && next.y === game.food.y
39  const body = ate ? game.snake : game.snake.slice(0, -1)
40
41  const hitWall = next.x < 0 || next.y < 0 || next.x >= game.cols || next.y >= game.rows
42  const hitSelf = body.some(c => c.x === next.x && c.y === next.y)
43  if (hitWall || hitSelf) {
44    return { game: newGame(game.cols, game.rows, game.seed), ate: false, crashed: true }
45  }
46
47  const snake = [next, ...body]
48  if (!ate) return { game: { ...game, snake, dir }, ate, crashed: false }
49  const placed = placeFood(game.cols, game.rows, snake, game.seed)
50  return { game: { ...game, snake, dir, food: placed.food, seed: placed.seed, score: game.score + 1 }, ate, crashed: false }
51}
52
53// A free cell for the food, from a small seeded random generator.
54function placeFood(cols: number, rows: number, snake: Cell[], seed: number) {
55  let s = seed
56  for (let tries = 0; tries < 1000; tries++) {
57    s = (s * 1103515245 + 12345) % 2147483648
58    const x = s % cols
59    s = (s * 1103515245 + 12345) % 2147483648
60    const y = s % rows
61    if (!snake.some(c => c.x === x && c.y === y)) return { food: { x, y }, seed: s }
62  }
63  return { food: { x: 0, y: 0 }, seed: s }
64}
65