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.

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.

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.
/plugin marketplace add MDmubarak786/claude-mods
/plugin install snake@modhub
Try it for one session without installing:
claude --plugin-dir ./mods/snake
/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.* to eat it: the snake grows by one and you score a point. A new * appears on a free cell./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.
| Command | What it does |
|---|---|
/snake | Open the pane. Where no pane can be drawn, print the score and state. |
/snake new | Start a new game. The best score is kept. |
/snake speed 100 | Milliseconds between moves, 60 to 500. Default 150. Applies at once. |
/snake stats | Games played, best score. |
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.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./snake speed 150 is the comfortable default.MIT, see the repository root.
hooks/register.ts 267 lines1// 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}
267hooks/game.ts 112 lines1// 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}
112types/index.d.ts 30 lines1export 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