SLOPSHOPPER

snake

Snake in a pane. Steer with w, a, s and d, eat the food, don't hit the walls or yourself. Pause with p, pick the speed, high score kept. Zero tokens.

newpanecommandtoasttimer
v0.1.0MITupdated 2026-10-13MDmubarak786/claude-mods/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 │ ┃ SNAKE score 0 best 0 · length 3 · 150 ms │ ┃ Press w, a, s or d to start. The snake keeps ⏺ Read(src/auth.ts) │ ┃ moving until you steer it. ⎿ 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 │ ┃ │. . . . . . . . . . . . . . . . . . . . │ │ ┃ │. . . . . . . . . . . . . . . . . . . . │ │ ┃ └──────────────────────────────────────────┘ │ ┃ │ ┃ w: Up a: Left s: Down d: Right p: Resume │ ┃ │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ How to play │ │ ┃ │ 1. Press w, a, s or d. The snake sets │ │ ┃ │ off and keeps moving; each press turns │ │ ┃ │ it. │ │ ┃ │ 2. Run over the * to eat it: the snake │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Snake
SNAKE score 0 best 0 · length 3 · 150 ms Press w, a, s or d to start. The snake keeps moving until you steer it. ┌──────────────────────────────────────────────────────────┐ │. . . . . . . . . . . . . . . . . . . . │ │. . . . . . . . . . . . . . . . . . . . │ │. . . . . . . . . . . . . . . . . . . . │ │. . . . . . . . . . . . . . . . . . . . │ │. . . . . . . . . . . . . . * . . . . . │ │. . . > . . . . . . . . . . . . . . │ │. . . . . . . . . . . . . . . . . . . . │ │. . . . . . . . . . . . . . . . . . . . │ │. . . . . . . . . . . . . . . . . . . . │ │. . . . . . . . . . . . . . . . . . . . │ └──────────────────────────────────────────────────────────┘ w: Up a: Left s: Down d: Right p: Resume ╭──────────────────────────────────────────────────────────╮ │ How to play │ │ 1. Press w, a, s or d. The snake sets off and keeps │ │ moving; each press turns it. │ │ 2. Run over the * to eat it: the snake grows by one and │ │ you score a point. │ │ 3. Hitting a wall or your own body ends the game. p │ │ pauses. /snake speed 100 makes it faster. │ │ Keys need the pane focused: click it if nothing happens. │ │ h hides this. │ ╰──────────────────────────────────────────────────────────╯ n: New game h: Hide help Esc closes and pauses
README

snake

Snake in a pane beside the conversation. Steer with w, a, s and d, eat the food to grow and score, and don't hit the walls or yourself. The snake moves on a clock while the pane is open; p pauses, Esc closes and pauses, and the high score survives restarts.

It's a game you play, not a watcher: it never calls a model, never reads what Claude is doing, and costs no tokens.

Snake in a pane: three turns, three meals, then a pause.

Recorded in a terminal. The Desktop app draws the same pane, with each key shown as a badge.

SNAKE  score 4  best 12 · length 7 · 150 ms
Eat the * and don't hit the walls or yourself.
┌────────────────────────────────────────┐
│ . . . . . . . . . . . . . . . . . . . .│
│ . . . . . . . . . . . . * . . . . . . .│
│ . . . . . . . . . . . . . . . . . . . .│
│ . . . . .             > . . . . . . . .│
│ . . . . . . . . . . . . . . . . . . . .│
│ . . . . . . . . . . . . . . . . . . . .│
│ . . . . . . . . . . . . . . . . . . . .│
│ . . . . . . . . . . . . . . . . . . . .│
│ . . . . . . . . . . . . . . . . . . . .│
│ . . . . . . . . . . . . . . . . . . . .│
└────────────────────────────────────────┘
w: Up   a: Left   s: Down   d: Right   p: Pause

n: New game   h: Help   Esc closes and pauses

The board is colored, which the text above can't show: the body is a run of green cells, the head is on the accent color and points the way it's going, the food sits on yellow, and the border turns red when you crash. Colors follow your theme.

Install

/plugin marketplace add MDmubarak786/claude-mods
/plugin install snake@modhub

Try it for one session without installing:

claude --plugin-dir ./mods/snake

How to play

  1. Run /snake. The pane opens with a three-long snake waiting in the middle. A fresh game shows a short how-to until the first move; h hides or shows it any time.
  2. Press w, a, s or d, or click the arrow buttons. The snake sets off and keeps moving, one cell every 150 ms; each press turns it. A turn straight back into the body is ignored.
  3. Run over the * to eat it: the snake grows by one and you score a point. A new * appears on a free cell.
  4. Hitting a wall or your own body ends the game. A toast says the score and whether it's a new best. n starts a new game.
  5. p pauses and resumes. Esc closes the pane and pauses; /snake again picks up where you were, paused, so you choose when it moves.

The keys work while the pane has the focus. If pressing w types into the prompt instead, click the pane first.

CommandWhat it does
/snakeOpen the pane. Where no pane can be drawn, print the score and state.
/snake newStart a new game. The best score is kept.
/snake speed 100Milliseconds between moves, 60 to 500. Default 150. Applies at once.
/snake statsGames played, best score.

What it touches

From claude plugin validate ./mods/snake:

hooks: session.start, classic.SessionStart{source=clear|resume|fork}, command.run{command=snake}, ui.close{id=snake}, ui.render{component=Pane}
calls: $.clock.every (via startTimer), $.clock.now (via load, startNew), $.command.register, $.session.surfaces, $.state.get, $.state.set, $.store.get (via load), $.store.set (via save), $.ui.invalidate, $.ui.log, $.ui.open, $.ui.resolve, $.ui.toast
  • $.store keeps the game and the record. A plain move is drawn but not saved; eating, crashing, pausing, and closing the pane save, so a restart loses at most a few cells of travel. A loaded game always waits, paused, for a key. If the store can't be read at start, the mod plays in memory and writes nothing over the saved record.
  • $.clock seeds the food and moves the snake every speed milliseconds while the pane is open and the game runs. The clock stops on pause, on game over, on a new game, and when the pane closes.
  • ui.close is observed to stop the clock and pause.
  • No tool calls are observed, no files, processes, model calls, or network.

Tested with

  • Claude Code 2.1.295, claude plugin validate --strict and claude plugin test passing: the rules (start, turns, reverse ignored, eating, walls, the body, following your own tail), speed clamping, mounted pane tests on the mock clock for starting, moving, eating, pausing, crashing and the toast, the commands, an unreadable store, and a narrow pane. The test kit can't fire ui.close, so pausing on close is covered by the pause key and the load path.
  • Played in the Desktop app, 2.1.293, through hot reloading.

Limitations

  • One game at a time per machine. Two sessions playing at once overwrite each other; the last write wins.
  • The arrow keys are not hotkeys a mod can take; the letters w, a, s, d are.
  • The board redraws on every move, 200 cells at a time. At the fastest speed on a slow terminal it may stutter; /snake speed 150 is the comfortable default.

License

MIT, see the repository root.

Source 3 files
hooks/register.ts 267 lines
1// snake: steer a snake around a board, eat, grow, don't crash, in a pane.
2//
3//   /snake              open the game
4//   /snake new          start a new game
5//   /snake speed 100    milliseconds between moves, 60 to 500 (default 150)
6//   /snake stats        games, best score
7//
8// In the pane: w, a, s and d steer (the first press starts the game), p pauses
9// and resumes, n starts a new game, h shows the how-to. The snake moves on a
10// clock while the pane is open; closing the pane pauses it. Nothing here calls
11// a model, reads what Claude is doing, or leaves the machine.
12
13import { atom, read, update } from 'claude-code'
14import type { Dir, Game, Stats } from '../types'
15import { COLS, DEFAULT_SPEED, MAX_SPEED, MIN_SPEED, ROWS, at, clampSpeed, isGame, newGame, steer, tick } from './game'
16
17const PANE = 'snake'
18const EMPTY_STATS: Stats = { games: 0, best: 0 }
19const EMPTY: Game = { seed: 0, snake: [0], dir: 'right', nextDir: 'right', food: -1, score: 0, ticks: 0, paused: true, over: false, record: false, speed: DEFAULT_SPEED, stats: EMPTY_STATS }
20const game = atom({ plugin: 'snake', key: 'game' } as const, EMPTY)
21
22// The clock that moves the snake, while the pane is open and the game runs.
23let timer: { cancel: () => void } | null = null
24// The how-to block: shown on a fresh game until the first key, or until h has
25// set it either way for the session.
26let help: boolean | null = null
27// False after the store could not be read: then nothing is written over the
28// saved record until a later load reads it.
29let storeOk = true
30
31async function save($, g: Game) {
32  if (!storeOk || !g.seed) return
33  try {
34    await $.store.set('game', g)
35  } catch {
36    // The drawing still updates; the store catches up on the next change.
37  }
38}
39
40// Apply a change through update's read-modify-write and mirror it to the
41// store. Ticks pass `persist: false`: a move every 150 ms is drawn, not saved,
42// until something worth keeping happens.
43async function change($, fn: (cur: Game) => Game, persist = true): Promise<Game> {
44  let out: Game | null = null
45  await update($, game, (cur) => {
46    out = fn(cur)
47    return out
48  })
49  if (out && persist) await save($, out)
50  return out ?? (await read($, game))
51}
52
53async function load($) {
54  let saved: unknown = undefined
55  let readable = true
56  try {
57    saved = await $.store.get('game')
58  } catch {
59    readable = false
60  }
61  storeOk = readable
62  if (isGame(saved)) {
63    // A game is never running at load: it waits for a key. A stored speed is
64    // kept within the clock's range.
65    await update($, game, () => ({ ...(saved as Game), paused: true, speed: clampSpeed((saved as Game).speed) }))
66    return
67  }
68  if (!readable) {
69    const cur = await read($, game)
70    if (!cur.seed) await update($, game, () => newGame(Date.now(), { ...EMPTY_STATS, games: 1 }, DEFAULT_SPEED))
71    $.ui.log('snake could not read its saved game; playing without saving')
72    return
73  }
74  const fresh = newGame(await $.clock.now(), { ...EMPTY_STATS, games: 1 }, DEFAULT_SPEED)
75  await update($, game, () => fresh)
76  await save($, fresh)
77}
78
79function stopTimer() {
80  if (timer) timer.cancel()
81  timer = null
82}
83
84// Move the snake every `speed` ms until it pauses or crashes. Eating and
85// crashing are saved; plain moves are only drawn.
86function startTimer($, g: Game) {
87  stopTimer()
88  if (g.paused || g.over) return
89  timer = $.clock.every(g.speed, async () => {
90    try {
91      let ate = false
92      let ended = false
93      const after = await change($, (cur) => {
94        const n = tick(cur)
95        ate = n.score > cur.score
96        ended = n.over && !cur.over
97        return n
98      }, false)
99      if (after.over || after.paused) stopTimer()
100      if (ate || ended || after.paused) await save($, after)
101      if (ended) $.ui.toast('Game over: ' + after.score + (after.record ? ', a new best! ' : '. ') + 'New game: n', { timeoutMs: 5000 })
102    } catch {
103      // A failed tick is skipped; the next one tries again.
104    }
105  })
106}
107
108async function startNew($) {
109  stopTimer()
110  const now = await $.clock.now()
111  return change($, (cur) => newGame(now, { ...cur.stats, games: cur.stats.games + 1 }, cur.speed))
112}
113
114async function pause($, on: boolean) {
115  const g = await change($, (cur) => (cur.over || cur.paused === on ? cur : { ...cur, paused: on }))
116  if (on) stopTimer()
117  else startTimer($, g)
118  return g
119}
120
121function summary(g: Game): string {
122  if (!g.seed) return 'No game yet.'
123  const state = g.over ? 'Game over.' : g.paused ? 'Paused.' : 'Moving.'
124  return 'Snake: score ' + g.score + ', length ' + g.snake.length + ', best ' + g.stats.best + ', ' + g.speed + ' ms a move. ' + state + ' /snake opens the pane.'
125}
126
127export function register(on) {
128  on('session.start', async ($, e, next) => {
129    help = null
130    try {
131      await load($)
132    } catch (error) {
133      $.ui.log('snake could not load its game: ' + error)
134    }
135    try {
136      await $.command.register({ name: 'snake', description: 'Play snake in a pane', argumentHint: '[new | speed N | stats]', immediate: true })
137    } catch (error) {
138      $.ui.log('could not register /snake: ' + error)
139    }
140    return next(e)
141  })
142
143  // /clear and /resume reset $.state; the game lives on in the store.
144  on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
145    stopTimer()
146    await load($)
147    return next(e)
148  }).catch(async ($, e, next) => next(e))
149
150  on('command.run', { command: 'snake' }, async ($, e) => {
151    const args = e.args.trim().toLowerCase()
152    if (args === 'new') return { text: 'New game. ' + summary(await startNew($)) }
153    const speed = /^speed\s+(\d+)$/.exec(args)
154    if (speed) {
155      const ms = clampSpeed(Number(speed[1]))
156      const g = await change($, (cur) => ({ ...cur, speed: ms }))
157      if (timer) startTimer($, g)
158      return { text: 'A move every ' + ms + ' ms' + (ms !== Number(speed[1]) ? ' (' + MIN_SPEED + ' to ' + MAX_SPEED + ')' : '') + '.' }
159    }
160    if (args === 'stats') {
161      const s = (await read($, game)).stats
162      return { text: 'Games ' + s.games + ' · best ' + s.best }
163    }
164    if (args) return { text: 'Usage: /snake, /snake new, /snake speed N, or /snake stats' }
165    // Under claude -p nothing draws, and ui.open still reports the pane as placed:
166    // the surfaces list is the reliable sign.
167    let surfaces = []
168    try {
169      surfaces = await $.session.surfaces()
170    } catch {
171      surfaces = []
172    }
173    if (!surfaces.length) return { text: summary(await read($, game)) }
174    const placed = await $.ui.open({ id: PANE, title: 'Snake', focus: true, closeOnEscape: true, rows: 26 })
175    if (!placed.isPlaced) return { text: summary(await read($, game)) }
176    return {}
177  }).catch(async () => ({ text: 'snake: the command failed.' }))
178
179  // Closing the pane pauses the game.
180  on('ui.close', { id: PANE }, async ($, e, next) => {
181    stopTimer()
182    await change($, (cur) => (cur.seed && !cur.paused ? { ...cur, paused: true } : cur))
183    return next(e)
184  }).catch(async ($, e, next) => next(e))
185
186  on('ui.render', { component: 'Pane' }, async ($, e, next) => {
187    if (e.requestId !== PANE) return next(e)
188    const g = await read($, game)
189    const { Box, Text, Button } = $.ui.resolve(e)
190    if (!g.seed) return Text({ children: ['No game loaded. /snake new starts one.'] })
191    const columns = e.props.bodyColumns || 60
192    const wide = columns >= 50
193    const showHelp = help ?? (wide && g.ticks === 0)
194
195    const status = g.over ? { text: 'Game over at ' + g.score + (g.record ? ', a new best. ' : '. ') + 'Press n for a new game.', color: 'error', bold: true }
196      : g.ticks === 0 ? { text: 'Press w, a, s or d to start. The snake keeps moving until you steer it.', color: 'suggestion' }
197      : g.paused ? { text: 'Paused. Press p, or a direction, to go on.', color: 'warning' }
198      : { text: 'Eat the * and don\'t hit the walls or yourself.', color: 'suggestion' }
199
200    const header = Box({ flexDirection: 'column', children: [
201      Box({ flexDirection: 'row', columnGap: 2, flexWrap: 'wrap', children: [
202        Text({ bold: true, children: ['SNAKE'] }),
203        Text({ children: ['score ', Text({ bold: true, children: [String(g.score)] })] }),
204        Text({ dimColor: true, children: ['best ' + g.stats.best + ' · length ' + g.snake.length + ' · ' + g.speed + ' ms'] }),
205      ] }),
206      Text({ color: status.color, bold: status.bold, wrap: 'wrap', children: [status.text] }),
207    ] })
208
209    // Every cell is a two-column Box, so the board lines up on any surface; the
210    // background carries what is in it, and the glyphs are plain ASCII.
211    const head = g.snake[0]
212    const body = new Set(g.snake.slice(1))
213    const cell = (r: number, c: number) => {
214      const i = at(r, c)
215      const kind = i === head ? 'head' : body.has(i) ? 'body' : i === g.food ? 'food' : 'empty'
216      const backgroundColor = kind === 'head' ? (g.over ? 'error' : 'suggestion') : kind === 'body' ? 'success' : kind === 'food' ? 'warning' : undefined
217      const glyph = kind === 'head' ? (g.dir === 'up' ? '^' : g.dir === 'down' ? 'v' : g.dir === 'left' ? '<' : '>') : kind === 'food' ? '*' : kind === 'body' ? ' ' : '.'
218      return Box({ width: 2, backgroundColor, children: [Text({ bold: kind !== 'empty', color: backgroundColor ? 'inverseText' : undefined, dimColor: kind === 'empty', children: [glyph] })] })
219    }
220    const board = Box({ flexDirection: 'column', borderStyle: 'single', borderColor: g.over ? 'error' : 'inactive', children: Array.from({ length: ROWS }, (_, r) => Box({ flexDirection: 'row', children: Array.from({ length: COLS }, (_, c) => cell(r, c)) })) })
221
222    const steerButton = (key: string, dir: Dir, label: string) =>
223      Button({ key, label, hotkey: key, plain: true, onPress: async () => {
224        const updated = await change($, (cur) => steer(cur, dir))
225        if (updated.over) {
226          $.ui.toast('The game is over. Press n for a new one.')
227          return
228        }
229        if (!timer) startTimer($, updated)
230      } })
231
232    const controls = Box({ flexDirection: 'row', columnGap: 2, flexWrap: 'wrap', children: [
233      steerButton('w', 'up', 'Up'),
234      steerButton('a', 'left', 'Left'),
235      steerButton('s', 'down', 'Down'),
236      steerButton('d', 'right', 'Right'),
237      Button({ key: 'pause', label: g.paused ? 'Resume' : 'Pause', hotkey: 'p', plain: true, onPress: async () => {
238        const cur = await read($, game)
239        if (cur.over) {
240          $.ui.toast('The game is over. Press n for a new one.')
241          return
242        }
243        await pause($, !cur.paused)
244      } }),
245    ] })
246
247    const howTo = Box({ flexDirection: 'column', borderStyle: 'round', borderColor: 'suggestion', paddingX: 1, children: [
248      Text({ bold: true, children: ['How to play'] }),
249      Text({ wrap: 'wrap', children: ['1. Press w, a, s or d. The snake sets off and keeps moving; each press turns it.'] }),
250      Text({ wrap: 'wrap', children: ['2. Run over the * to eat it: the snake grows by one and you score a point.'] }),
251      Text({ wrap: 'wrap', children: ['3. Hitting a wall or your own body ends the game. p pauses. /snake speed 100 makes it faster.'] }),
252      Text({ dimColor: true, wrap: 'wrap', children: ['Keys need the pane focused: click it if nothing happens. h hides this.'] }),
253    ] })
254
255    const footer = Box({ flexDirection: 'row', columnGap: 3, flexWrap: 'wrap', children: [
256      Button({ key: 'new', label: 'New game', hotkey: 'n', plain: true, onPress: () => startNew($) }),
257      Button({ key: 'help', label: showHelp ? 'Hide help' : 'Help', hotkey: 'h', plain: true, dimColor: true, onPress: () => {
258        help = !showHelp
259        $.ui.invalidate('ui.render')
260      } }),
261      Text({ dimColor: true, children: ['Esc closes and pauses'] }),
262    ] })
263
264    return Box({ flexDirection: 'column', rowGap: 1, children: showHelp ? [header, board, controls, howTo, footer] : [header, board, controls, footer] })
265  })
266}
267
hooks/game.ts 112 lines
1// The rules of snake, pure: no `$` here, so the tests and the hooks module
2// share one implementation.
3
4import type { Dir, Game, Stats } from '../types'
5
6export const COLS = 20
7export const ROWS = 10
8export const DEFAULT_SPEED = 150
9export const MIN_SPEED = 60
10export const MAX_SPEED = 500
11export const DIRS: Dir[] = ['up', 'down', 'left', 'right']
12
13function hash(s: string): number {
14  let h = 2166136261
15  for (let i = 0; i < s.length; i++) {
16    h ^= s.charCodeAt(i)
17    h = Math.imul(h, 16777619)
18  }
19  return h >>> 0
20}
21
22function rng(seed: number): () => number {
23  let a = seed
24  return () => {
25    a = (a + 0x6d2b79f5) | 0
26    let t = Math.imul(a ^ (a >>> 15), 1 | a)
27    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
28    return ((t ^ (t >>> 14)) >>> 0) / 4294967296
29  }
30}
31
32export const at = (row: number, col: number) => row * COLS + col
33export const rowOf = (cell: number) => Math.floor(cell / COLS)
34export const colOf = (cell: number) => cell % COLS
35
36export function opposite(d: Dir): Dir {
37  return d === 'up' ? 'down' : d === 'down' ? 'up' : d === 'left' ? 'right' : 'left'
38}
39
40// Food lands on a free cell. Deterministic from the seed and the food number.
41export function placeFood(snake: number[], seed: number, n: number): number {
42  const taken = new Set(snake)
43  const free = Array.from({ length: COLS * ROWS }, (_, i) => i).filter((i) => !taken.has(i))
44  if (!free.length) return -1
45  const r = rng(hash(seed + ':food:' + n))
46  return free[Math.floor(r() * free.length)]
47}
48
49export function newGame(seed: number, stats: Stats, speed: number): Game {
50  const row = Math.floor(ROWS / 2)
51  const snake = [at(row, 5), at(row, 4), at(row, 3)]
52  return { seed, snake, dir: 'right', nextDir: 'right', food: placeFood(snake, seed, 0), score: 0, ticks: 0, paused: true, over: false, record: false, speed, stats }
53}
54
55// Steer. A turn straight back into the body is ignored; the turn applies at the next tick.
56export function steer(g: Game, d: Dir): Game {
57  if (g.over) return g
58  if (d === opposite(g.dir) && g.snake.length > 1) return g
59  return { ...g, nextDir: d, paused: false }
60}
61
62// One step forward. Eating grows the snake by one and scores; a wall or the
63// body ends the game.
64export function tick(g: Game): Game {
65  if (g.over || g.paused) return g
66  const dir = g.nextDir
67  const head = g.snake[0]
68  let row = rowOf(head)
69  let col = colOf(head)
70  if (dir === 'up') row--
71  if (dir === 'down') row++
72  if (dir === 'left') col--
73  if (dir === 'right') col++
74  const ticks = g.ticks + 1
75  if (row < 0 || row >= ROWS || col < 0 || col >= COLS) return end({ ...g, dir, ticks })
76  const next = at(row, col)
77  const eats = next === g.food
78  // The tail cell frees up this tick unless the snake grows into it.
79  const body = eats ? g.snake : g.snake.slice(0, -1)
80  if (body.includes(next)) return end({ ...g, dir, ticks })
81  const snake = [next, ...body]
82  if (!eats) return { ...g, dir, snake, ticks }
83  const score = g.score + 1
84  const food = placeFood(snake, g.seed, score)
85  const record = g.record || score > g.stats.best
86  const stats: Stats = { ...g.stats, best: Math.max(g.stats.best, score) }
87  // Filling the whole board is a win of sorts: nothing left to eat.
88  return food < 0 ? { ...g, dir, snake, ticks, score, food, stats, record, over: true, paused: true } : { ...g, dir, snake, ticks, score, food, stats, record }
89}
90
91function end(g: Game): Game {
92  return { ...g, over: true, paused: true }
93}
94
95export function clampSpeed(ms: number): number {
96  return Math.max(MIN_SPEED, Math.min(MAX_SPEED, Math.round(ms)))
97}
98
99export function isDir(x: unknown): x is Dir {
100  return x === 'up' || x === 'down' || x === 'left' || x === 'right'
101}
102
103export function isGame(x: unknown): x is Game {
104  if (!x || typeof x !== 'object') return false
105  const g = x as Record<string, unknown>
106  const cell = (c: unknown) => Number.isInteger(c) && (c as number) >= 0 && (c as number) < COLS * ROWS
107  const stats = g.stats as Record<string, unknown> | undefined
108  return typeof g.seed === 'number' && Array.isArray(g.snake) && g.snake.length > 0 && g.snake.every(cell) && isDir(g.dir) && isDir(g.nextDir) &&
109    (g.food === -1 || cell(g.food)) && Number.isInteger(g.score) && Number.isInteger(g.ticks) && typeof g.paused === 'boolean' && typeof g.over === 'boolean' &&
110    typeof g.record === 'boolean' && Number.isInteger(g.speed) && !!stats && typeof stats === 'object' && ['games', 'best'].every((k) => Number.isInteger(stats[k]))
111}
112
types/index.d.ts 30 lines
1export type Dir = 'up' | 'down' | 'left' | 'right'
2export type Stats = { games: number; best: number }
3export type Game = {
4  /** Seed of this game's food placement; 0 until a game exists. */
5  seed: number
6  /** The snake's cells, head first, as row * 20 + column. */
7  snake: number[]
8  dir: Dir
9  /** The direction the next tick turns to, set by a key between ticks. */
10  nextDir: Dir
11  food: number
12  score: number
13  /** Ticks so far; also the food counter. */
14  ticks: number
15  /** Not moving: a fresh game, or paused with p or by closing the pane. */
16  paused: boolean
17  over: boolean
18  /** This game has beaten the best score it started with. */
19  record: boolean
20  /** Milliseconds between ticks. */
21  speed: number
22  stats: Stats
23}
24
25declare module 'claude-code' {
26  interface PluginState {
27    snake: { game: Game }
28  }
29}
30