SLOPSHOPPER

minesweeper

Minesweeper above the Claude Code prompt: open every cell that is not a mine. Plays while Claude works, pauses when the turn ends, keeps your best score; zero…

newbandcommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · minesweeper
› fix the failing auth test and add an audit log call ⏺ 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 › /minesweeper ⎿ minesweeper: $Minesweeper · click the board to play · Esc returns to the prompt · /minesweeper stop closes [ close ] ▣ client module ./boards/minesweeper.tsx ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
[ close ] ▣ client module ./boards/minesweeper.tsx ⟨Claude Code's own drawing⟩
README

minesweeper

Minesweeper above the Claude Code prompt, for the minutes Claude spends working: open every cell that is not a mine. 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-arcade instead.

Play

  1. /minesweeper opens the board above the prompt (/minesweeper stop or the close button closes it).
  2. Click the board to give it the keyboard; Esc gives the keyboard back to the prompt.
  3. click a cell to open it, right-click to flag it. Without a mouse: arrows move the blue cursor, Space or Enter opens, f flags. r deals a new board. The first click never hits a mine.

Your best score is kept in the plugin's store across sessions.

Install

npx claude-code-templates@latest --mod games/minesweeper
claude

It is written to .claude/skills/minesweeper/, which Claude Code auto-loads as minesweeper@skills-dir. For one session with hot reload: claude --plugin-dir .claude/skills/minesweeper.

Requirements

  • Claude Code 2.1.287 or later (mods are on by default from that version).
  • An interactive terminal that reports the mouse. Nothing draws in claude -p, the desktop app or mobile.
  • A terminal font with box-drawing and block characters.

Clones of the genre, not affiliated with or endorsed by the original publishers.

Source 5 files
hooks/register.tsx 95 lines
1/* @jsx h */
2import type { Register } from 'claude-code'
3import { isBetter } from './games/best.ts'
4
5// Minesweeper 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. `/minesweeper` opens it, `/minesweeper 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 = 'minesweeper' // what the board posts its score under
11const TITLE = 'Minesweeper'
12const LOWER_IS_BETTER = true
13const UNIT = 's'
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: 'minesweeper',
28      description: `$Minesweeper above the prompt, while Claude works (stop closes)`,
29      argumentHint: '[stop]',
30      immediate: true,
31    }).catch(err => $.ui.log(`minesweeper: /minesweeper not registered: ${err}`))
32    return r
33  })
34
35  on('command.run', { command: 'minesweeper' }, 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: `$Minesweeper closed` }
41    }
42    open = true
43    $.ui.invalidate('ui.render')
44    const score = best ? ` · best ${best}$s` : ''
45    return { text: `$Minesweeper · click the board to play · Esc returns to the prompt · /minesweeper 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(`minesweeper: store write failed: ${err}`))
66      $.ui.toast(`$Minesweeper: new best ${best}$s`)
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="minesweeper:close" label="close" onPress={close} />
88        </Box>
89        <Client key="board:minesweeper" module="./boards/minesweeper.tsx" width={cols} height={tall(12)} props={{ best, done: turnsDone, safe: false }} />
90        {await next(e)}
91      </Box>
92    )
93  })
94}
95
hooks/games/best.ts 10 lines
1// 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}
10
hooks/boards/minesweeper.tsx 99 lines
1/* @jsx h */
2import type { ClientSurface } from 'claude-code'
3import { base, claudeDone, grid, statusLine, type Base, type Props } from './common.tsx'
4import { flag, newMines, reveal, type Mines } from '../games/mines.ts'
5
6const NUMBER_COLORS = ['', 'blue', 'green', 'red', 'magenta', 'yellow', 'cyan', 'white', 'gray']
7const CURSOR_KEYS: Record<string, readonly [number, number]> = { left: [-1, 0], right: [1, 0], up: [0, -1], down: [0, 1], a: [-1, 0], d: [1, 0], w: [0, -1], s: [0, 1] }
8type State = Base & { game?: Mines; cursor: readonly [number, number]; seconds: number }
9
10export default function Minesweeper(props: Props, surface: ClientSurface<State>) {
11  const { Box, Text } = surface.elements
12  const size = () => {
13    const bw = Math.max(4, Math.min(16, Math.floor((surface.columns - 2) / 2)))
14    const bh = Math.max(4, Math.min(9, surface.rows - 3))
15    return { bw, bh, count: Math.max(1, Math.round(bw * bh * 0.15)) }
16  }
17  // a click or space reveals, a right-click or f flags; the first action after a finished round
18  // only starts a new board, so a stray click cannot open a cell on it
19  const act = (x: number, y: number, kind: 'reveal' | 'flag') => {
20    const s = surface.state
21    if (!s) return
22    let g = s.game
23    if (!g || g.over || g.won) {
24      const { bw, bh, count } = size()
25      const fresh = newMines(bw, bh, count)
26      if (g) {
27        surface.setState({ ...s, banner: false, playing: false, seconds: 0, game: fresh })
28        return
29      }
30      g = fresh
31    }
32    const game = kind === 'flag' ? flag(g, x, y) : reveal(g, x, y)
33    // a board whose first click clears it takes 0 s; count it as 1 s so it cannot lock the best
34    if (game.won && !g.won) surface.post({ game: 'minesweeper', score: Math.max(1, s.seconds) })
35    const cursor = x >= 0 && y >= 0 && x < game.w && y < game.h ? ([x, y] as const) : s.cursor
36    surface.setState({ ...s, banner: false, playing: game.placed && !game.over && !game.won, game, cursor })
37  }
38
39  if (surface.state === undefined) {
40    surface.setState({ ...base(props), cursor: [0, 0], seconds: 0 })
41    surface.every(1000, () => {
42      const s = surface.state
43      if (s?.game && s.playing && !s.banner) surface.setState({ ...s, seconds: s.seconds + 1 })
44    })
45    surface.onPointer(ev => {
46      if (ev.type !== 'down') return
47      // the border takes the first row and column; each cell is two columns wide
48      act(Math.floor((ev.x - 1) / 2), ev.y - 1, ev.button === 'right' ? 'flag' : 'reveal')
49    })
50    surface.onKey(({ key }) => {
51      const s = surface.state
52      if (!s) return
53      // the space bar may arrive as the character or by name
54      const k = key === 'space' ? ' ' : key.toLowerCase()
55      const bw = s.game?.w ?? size().bw
56      const bh = s.game?.h ?? size().bh
57      if (Object.hasOwn(CURSOR_KEYS, k)) {
58        const [dx, dy] = CURSOR_KEYS[k]
59        const [cx, cy] = s.cursor
60        surface.setState({ ...s, banner: false, cursor: [Math.max(0, Math.min(bw - 1, cx + dx)), Math.max(0, Math.min(bh - 1, cy + dy))] })
61      } else if (k === ' ' || k === 'return') {
62        act(s.cursor[0], s.cursor[1], 'reveal')
63      } else if (k === 'f') {
64        act(s.cursor[0], s.cursor[1], 'flag')
65      } else if (k === 'r') {
66        const { bw: nw, bh: nh, count } = size()
67        surface.setState({ ...s, banner: false, playing: false, seconds: 0, game: newMines(nw, nh, count) })
68      }
69    })
70  }
71  claudeDone(props, surface)
72
73  const s = surface.state
74  const g = s?.game
75  const { bw, bh, count } = g ? { bw: g.w, bh: g.h, count: g.count } : size()
76  const [cx, cy] = s?.cursor ?? [0, 0]
77  const rows = grid(Text, bw, bh, (x, y) => {
78    const t = g?.tiles[y * bw + x]
79    const bg = x === cx && y === cy ? 'blue' : undefined
80    if (!t || (!t.open && !t.flag)) return ['░░', 'gray', bg]
81    if (t.flag && !t.open) return [' F', 'redBright', bg]
82    if (t.mine) return [' *', 'red', bg]
83    return t.n ? [` ${t.n}`, NUMBER_COLORS[t.n], bg] : ['  ', undefined, bg]
84  })
85  const flags = g?.tiles.filter(t => t.flag).length ?? 0
86  const seconds = s?.seconds ?? 0
87  const text = !g ? 'minesweeper · click a cell, or click here then arrows + space · right-click or f flags'
88    : g.over ? 'minesweeper · boom · r or a click plays again'
89    : g.won ? `minesweeper · cleared in ${seconds}s · r or a click plays again`
90    : `minesweeper · ${count - flags} mines left · ${seconds}s · click reveals · right-click or f flags`
91  const best = props?.best ? `${props.best}s` : '—'
92  return (
93    <Box flexDirection="column">
94      <Box flexDirection="column" borderStyle="round" borderColor={g?.over ? 'red' : g?.won ? 'green' : 'gray'} width={bw * 2 + 2}>{rows}</Box>
95      {statusLine(Text, !!s?.banner, `${text} · best ${best}`)}
96    </Box>
97  )
98}
99
hooks/boards/common.tsx 61 lines
1/* @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}
61
hooks/games/mines.ts 63 lines
1// Minesweeper: pure game logic, drawn by the Minesweeper module in ../surface.tsx.
2
3export type Tile = { mine: boolean; open: boolean; flag: boolean; n: number }
4export type Mines = { w: number; h: number; count: number; tiles: Tile[]; placed: boolean; over: boolean; won: boolean }
5
6export const newMines = (w: number, h: number, count: number): Mines => ({
7  w, h, count,
8  tiles: Array.from({ length: w * h }, () => ({ mine: false, open: false, flag: false, n: 0 })),
9  placed: false, over: false, won: false,
10})
11
12const inside = (g: Mines, x: number, y: number) => x >= 0 && y >= 0 && x < g.w && y < g.h
13
14function around(g: Mines, x: number, y: number): number[] {
15  const out: number[] = []
16  for (let dy = -1; dy <= 1; dy++) {
17    for (let dx = -1; dx <= 1; dx++) if ((dx || dy) && inside(g, x + dx, y + dy)) out.push((y + dy) * g.w + x + dx)
18  }
19  return out
20}
21
22// mines go in on the first reveal, never on that cell or next to it, so the first click opens an area
23function place(g: Mines, x: number, y: number, rand: () => number): Tile[] {
24  const safe = new Set([y * g.w + x, ...around(g, x, y)])
25  const spots = g.tiles.map((_, i) => i).filter(i => !safe.has(i))
26  const tiles = g.tiles.map(t => ({ ...t }))
27  // a partial Fisher-Yates shuffle picks `count` distinct spots
28  for (let k = 0; k < Math.min(g.count, spots.length); k++) {
29    const j = k + Math.floor(rand() * (spots.length - k))
30    ;[spots[k], spots[j]] = [spots[j], spots[k]]
31    tiles[spots[k]].mine = true
32  }
33  tiles.forEach((t, i) => { t.n = around(g, i % g.w, Math.floor(i / g.w)).filter(j => tiles[j].mine).length })
34  return tiles
35}
36
37// open a cell: a mine ends the round and shows every mine; a zero opens its whole empty area
38export function reveal(g: Mines, x: number, y: number, rand = Math.random): Mines {
39  if (g.over || g.won || !inside(g, x, y)) return g
40  const start = y * g.w + x
41  if (g.tiles[start].flag || g.tiles[start].open) return g
42  const tiles = g.placed ? g.tiles.map(t => ({ ...t })) : place(g, x, y, rand)
43  if (tiles[start].mine) {
44    tiles.forEach(t => { if (t.mine) t.open = true })
45    return { ...g, tiles, placed: true, over: true }
46  }
47  const stack = [start]
48  while (stack.length) {
49    const i = stack.pop()!
50    const t = tiles[i]
51    if (t.open || t.flag) continue
52    t.open = true
53    if (t.n === 0) stack.push(...around(g, i % g.w, Math.floor(i / g.w)))
54  }
55  return { ...g, tiles, placed: true, won: tiles.every(t => t.mine || t.open) }
56}
57
58export function flag(g: Mines, x: number, y: number): Mines {
59  if (g.over || g.won || !inside(g, x, y) || g.tiles[y * g.w + x].open) return g
60  const tiles = g.tiles.map((t, i) => (i === y * g.w + x ? { ...t, flag: !t.flag } : t))
61  return { ...g, tiles }
62}
63