Snake above the Claude Code prompt: eat, grow, and keep off the walls and yourself. Plays while Claude works, pauses when the turn ends, keeps your best score…

Snake above the Claude Code prompt, for the minutes Claude spends working: eat, grow, and keep off the walls and yourself. The game pauses and its status line says so when Claude finishes the turn, so you never miss a reply. Playing costs no tokens: the mod answers every key and click itself.
A single game split out of sezaakgun/cc-arcade (MIT, by Seza Akgün). Want all nine games, a picker and the pet in one plugin? Install
games/cc-arcadeinstead.
/snake opens the board above the prompt (/snake stop or the close button closes it).p pauses, r restarts. Eat the red food, keep off the walls and your own tail.Your best score is kept in the plugin's store across sessions.
npx claude-code-templates@latest --mod games/snake
claude
It is written to .claude/skills/snake/, which Claude Code auto-loads as snake@skills-dir. For one session with hot reload: claude --plugin-dir .claude/skills/snake.
claude -p, the desktop app or mobile.Clones of the genre, not affiliated with or endorsed by the original publishers.
hooks/register.tsx 95 lines1/* @jsx h */
2import type { Register } from 'claude-code'
3import { isBetter } from './games/best.ts'
4
5// Snake above the Claude Code prompt, split out of cc-arcade (https://github.com/sezaakgun/cc-arcade,
6// MIT, by Seza Akgün) as a single-game mod. `/snake` opens it, `/snake stop` closes it. The board in
7// ./boards is a surface module that runs on the drawing thread; this module mounts it, keeps the best
8// score in $.store and pauses the game when Claude finishes a turn. Playing costs no tokens.
9
10const GAME = 'snake' // what the board posts its score under
11const TITLE = 'Snake'
12const LOWER_IS_BETTER = false
13const UNIT = ''
14
15let open = false
16// bumped at each finished turn; the open game pauses when it sees a new value
17let turnsDone = 0
18let best = 0
19
20export const register: Register = on => {
21 on('session.start', async ($, e, next) => {
22 const r = await next(e)
23 // a store that cannot be read costs the best score, never the game
24 const score = Number(await $.store.get('best').catch(() => undefined))
25 if (score) best = score
26 await $.command.register({
27 name: 'snake',
28 description: `$Snake above the prompt, while Claude works (stop closes)`,
29 argumentHint: '[stop]',
30 immediate: true,
31 }).catch(err => $.ui.log(`snake: /snake not registered: ${err}`))
32 return r
33 })
34
35 on('command.run', { command: 'snake' }, async ($, e) => {
36 const arg = e.args.trim().toLowerCase()
37 if (arg === 'stop' || arg === 'close') {
38 open = false
39 $.ui.invalidate('ui.render')
40 return { text: `$Snake closed` }
41 }
42 open = true
43 $.ui.invalidate('ui.render')
44 const score = best ? ` · best ${best}$` : ''
45 return { text: `$Snake · click the board to play · Esc returns to the prompt · /snake stop closes${score}` }
46 })
47
48 on('turn.complete', async ($, e, next) => {
49 const r = await next(e)
50 // a subagent's turn is not the one the person is waiting for
51 if (e.agentId) return r
52 if (open) {
53 turnsDone++
54 $.ui.invalidate('ui.render')
55 }
56 return r
57 })
58
59 // a finished round, posted by the board: keep the best score and hand it back with the next props
60 on('ui.message', async ($, e, next) => {
61 const data = e.data as { game?: unknown; score?: unknown } | null
62 if (data?.game !== GAME || typeof data.score !== 'number') return next(e)
63 if (isBetter(data.score, best || undefined, LOWER_IS_BETTER)) {
64 best = data.score
65 await $.store.set('best', best).catch(err => $.ui.log(`snake: store write failed: ${err}`))
66 $.ui.toast(`$Snake: new best ${best}$`)
67 }
68 return { props: { best, done: turnsDone, safe: false } }
69 })
70
71 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
72 // the board needs a terminal's keys and mouse; the desktop and mobile surfaces draw their own band
73 if (!open || e.props.hasSurvey || e.surface !== 'terminal') return next(e)
74 const { Box, Button, Client } = $.ui.resolve(e)
75 // the band's own width: the transcript column's while a Pane is docked beside it
76 const cols = e.props.bodyColumns || (e.viewport?.columns ?? 80)
77 // one row for the close button, one for whatever else draws in the band
78 const rows = Math.max(8, e.props.maxRows - 2)
79 const tall = (max: number) => Math.min(rows, max)
80 const close = () => {
81 open = false
82 $.ui.invalidate('ui.render')
83 }
84 return (
85 <Box flexDirection="column">
86 <Box flexDirection="row" columnGap={1}>
87 <Button key="snake:close" label="close" onPress={close} />
88 </Box>
89 <Client key="board:snake" module="./boards/snake.tsx" width={cols} height={rows} props={{ best, done: turnsDone, safe: false }} />
90 {await next(e)}
91 </Box>
92 )
93 })
94}
95hooks/games/best.ts 10 lines1// Which score counts as a new best. Minesweeper's best is its fastest clear, so lower wins there;
2// everywhere else higher wins. A score of zero or less never counts: a Minesweeper board whose
3// first click clears it takes 0 s and would otherwise lock the best forever, and a Pong match you
4// lost posts a negative margin.
5export function isBetter(score: number, prev: number | undefined, lowerIsBetter = false): boolean {
6 if (!Number.isFinite(score) || score <= 0) return false
7 if (prev === undefined || prev <= 0) return true
8 return lowerIsBetter ? score < prev : score > prev
9}
10hooks/boards/snake.tsx 62 lines1/* @jsx h */
2import type { ClientSurface } from 'claude-code'
3import { base, claudeDone, grid, statusLine, type Base, type Props } from './common.tsx'
4import { newSnake, step, turn, type SnakeGame } from '../games/snake.ts'
5
6type State = Base & { game?: SnakeGame }
7
8export default function Snake(props: Props, surface: ClientSurface<State>) {
9 const { Box, Text } = surface.elements
10 const size = () => ({ bw: Math.floor((surface.columns - 2) / 2), bh: surface.rows - 3 })
11
12 if (surface.state === undefined) {
13 surface.setState(base(props))
14 surface.every(100, () => {
15 const s = surface.state
16 if (!s?.game || !s.playing || s.game.over) return
17 const game = step(s.game)
18 if (game.over) surface.post({ game: 'snake', score: game.score })
19 surface.setState({ ...s, game })
20 })
21 surface.onKey(({ key }) => {
22 const s = surface.state
23 if (!s) return
24 // the space bar may arrive as the character or by name
25 if (key === ' ' || key === 'space' || key === 'p') {
26 if (s.game && !s.game.over) surface.setState({ ...s, banner: false, playing: !s.playing })
27 return
28 }
29 // no round yet, or the last one ended: an arrow (or r) starts a new one
30 if (!s.game || s.game.over || key === 'r') {
31 const { bw, bh } = size()
32 if (bw < 4 || bh < 3) return
33 surface.setState({ ...s, banner: false, playing: true, game: turn(newSnake(bw, bh), key) })
34 return
35 }
36 surface.setState({ ...s, banner: false, playing: true, game: turn(s.game, key) })
37 })
38 }
39 claudeDone(props, surface)
40
41 const s = surface.state
42 const g = s?.game
43 const { bw, bh } = g ? { bw: g.w, bh: g.h } : size()
44 const head = g?.snake[0]
45 const body = new Set(g?.snake.slice(1).map(p => `${p[0]},${p[1]}`))
46 const rows = grid(Text, bw, bh, (x, y) =>
47 head && head[0] === x && head[1] === y ? ['██', 'greenBright']
48 : body.has(`${x},${y}`) ? ['▓▓', 'green']
49 : g && g.food[0] === x && g.food[1] === y ? ['██', 'red']
50 : [' '])
51 const text = !g ? 'snake · click here, then an arrow key · wasd works too'
52 : g.over ? `snake · game over · score ${g.score} · an arrow or r plays again`
53 : !s?.playing ? `snake · paused · score ${g.score} · space resumes`
54 : `snake · score ${g.score} · space pauses`
55 return (
56 <Box flexDirection="column">
57 <Box flexDirection="column" borderStyle="round" borderColor={g?.over ? 'red' : 'green'} width={Math.max(0, bw) * 2 + 2}>{rows}</Box>
58 {statusLine(Text, !!s?.banner, `${text} · best ${props?.best ?? 0}`)}
59 </Box>
60 )
61}
62hooks/boards/common.tsx 61 lines1/* @jsx h */
2import type { ClientElements, ClientSurface } from 'claude-code'
3
4// Shared by every board. A board is a surface module: it runs on the drawing thread with its own
5// frame clock, keys (after a click gives it focus; Esc gives the prompt back) and mouse.
6//
7// Never name a local `h` in a board file: every JSX tag compiles to a call of `h`.
8
9export type Props = { best?: number; done?: number; safe?: boolean } | undefined
10export type Base = { playing: boolean; banner: boolean; seenDone: number }
11export type Paint = readonly [glyph: string, color?: string, bg?: string]
12type TextTag = ClientElements['Text']
13
14export const base = (props: Props): Base => ({ playing: false, banner: false, seenDone: props?.done ?? 0 })
15
16// the hooks module bumps props.done when Claude finishes a turn: pause and say so, once per turn
17export function claudeDone<S extends Base>(props: Props, surface: ClientSurface<S>) {
18 const s = surface.state
19 const done = props?.done ?? 0
20 if (s && done !== s.seenDone) surface.setState({ ...s, seenDone: done, banner: true, playing: false })
21}
22
23export function statusLine(Text: TextTag, banner: boolean, text: string) {
24 return banner
25 ? <Text color="yellow" bold wrap="truncate-end">{`● Claude is done · ${text}`}</Text>
26 : <Text dimColor wrap="truncate-end">{text}</Text>
27}
28
29export type Run = [text: string, color?: string, bg?: string]
30
31// one run per stretch of cells sharing both colors. A drawing the engine cannot fit in its node
32// budget is worse than a coarser one, so maxRuns folds neighbours together, pair by pair, until
33// the row fits: a merged pair keeps the left one's colors and every glyph.
34export function runs(cols: number, y: number, at: (x: number, y: number) => Paint, maxRuns = Infinity): Run[] {
35 let out: Run[] = []
36 for (let x = 0; x < cols; x++) {
37 const [glyph, color, bg] = at(x, y)
38 const last = out[out.length - 1]
39 if (last && last[1] === color && last[2] === bg) last[0] += glyph
40 else out.push([glyph, color, bg])
41 }
42 while (out.length > Math.max(1, maxRuns)) {
43 const folded: Run[] = []
44 for (let i = 0; i < out.length; i += 2) {
45 const [t, c, b] = out[i]
46 const next = out[i + 1]
47 folded.push(next ? [t + next[0], c, b] : [t, c, b])
48 }
49 out = folded
50 }
51 return out
52}
53
54// a board as rows of two-character cells (a terminal cell is about twice as tall as wide, so two
55// make a square), one styled span per run of the same colors
56export function grid(Text: TextTag, cols: number, rowCount: number, at: (x: number, y: number) => Paint, maxRuns = Infinity) {
57 return Array.from({ length: Math.max(0, rowCount) }, (_, y) => (
58 <Text>{runs(cols, y, at, maxRuns).map(([t, c, b]) => (b ? <Text color={c} backgroundColor={b}>{t}</Text> : <Text color={c}>{t}</Text>))}</Text>
59 ))
60}
61hooks/games/snake.ts 46 lines1// Snake: pure game logic, drawn by the Snake module in ../surface.tsx.
2
3export type Pt = readonly [number, number]
4export type SnakeGame = { snake: Pt[]; dir: Pt; queued: Pt[]; food: Pt; w: number; h: number; score: number; over: boolean }
5
6const DIRS: Record<string, Pt> = { up: [0, -1], down: [0, 1], left: [-1, 0], right: [1, 0], w: [0, -1], s: [0, 1], a: [-1, 0], d: [1, 0] }
7const same = (a: Pt, b: Pt) => a[0] === b[0] && a[1] === b[1]
8
9// a random free cell; [-1, -1] once the snake fills the board
10export function placeFood(snake: Pt[], w: number, h: number, rand = Math.random): Pt {
11 const taken = new Set(snake.map(p => `${p[0]},${p[1]}`))
12 const free: Pt[] = []
13 for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) if (!taken.has(`${x},${y}`)) free.push([x, y])
14 return free.length ? free[Math.floor(rand() * free.length)] : [-1, -1]
15}
16
17export function newSnake(w: number, h: number, rand = Math.random): SnakeGame {
18 const x = Math.floor(w / 4)
19 const y = Math.floor(h / 2)
20 const snake: Pt[] = [[x, y], [x - 1, y], [x - 2, y]]
21 return { snake, dir: [1, 0], queued: [], food: placeFood(snake, w, h, rand), w, h, score: 0, over: false }
22}
23
24// a key queues a turn (two at most, so a quick up-then-left lands on consecutive ticks);
25// turning back into the neck, or the way it already goes, is ignored
26export function turn(g: SnakeGame, key: string): SnakeGame {
27 const d = Object.hasOwn(DIRS, key.toLowerCase()) ? DIRS[key.toLowerCase()] : undefined
28 const last = g.queued[g.queued.length - 1] ?? g.dir
29 if (!d || g.over || same(d, last) || same(d, [-last[0], -last[1]])) return g
30 return { ...g, queued: [...g.queued, d].slice(0, 2) }
31}
32
33// one tick: move the head, grow on food, end on a wall or the body; the tail cell the snake
34// vacates this tick is free to move into
35export function step(g: SnakeGame, rand = Math.random): SnakeGame {
36 if (g.over) return g
37 const [dir = g.dir, ...queued] = g.queued
38 const head: Pt = [g.snake[0][0] + dir[0], g.snake[0][1] + dir[1]]
39 const eats = same(head, g.food)
40 const body = eats ? g.snake : g.snake.slice(0, -1)
41 if (head[0] < 0 || head[1] < 0 || head[0] >= g.w || head[1] >= g.h || body.some(p => same(p, head))) return { ...g, dir, queued, over: true }
42 const snake = [head, ...body]
43 const food = eats ? placeFood(snake, g.w, g.h, rand) : g.food
44 return { ...g, snake, dir, queued, food, score: g.score + (eats ? 1 : 0), over: food[0] < 0 }
45}
46