SLOPSHOPPER

games

[Experimental] Mini games to play while Claude works — /2048, /mines (Minesweeper), /dino (Chrome's offline runner) and /gomoku (Gomoku against the computer, a…

newpanecommandtoastnetworktimer
★ 2v0.1.1MITupdated 2026-10-06musingfox/cc-plugins/games
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · games
│ ┃ 2048 ✕ › fix the failing auth test and add an audit log call │ ┃ Score 0 Best 0 │ ┃ · · · · ⏺ Read(src/auth.ts) │ ┃ 2 · · · ⎿ Read 6 lines │ ┃ · · · · ⏺ Update(src/auth.ts) │ ┃ · · 2 · ⎿ Added 2 lines, removed 1 line │ ┃ Esc hands the keys back; /2048 takes them ⏺ Bash(bun test) │ ┃ again. ⎿ 3 pass, 1 fail │ ┃ w: ↑ a: ← s: ↓ d: → n: new q: close │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /2048 │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · 2048
Score 0 Best 0 · · · · 2 · · · · · · · · · 2 · Esc hands the keys back; /2048 takes them again. w: ↑ a: ← s: ↓ d: → n: new q: close
Pane · Minesweeper
Mines 10 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . e digs, f flags. Esc hands the keys back; /mines takes them again. w: ↑ a: ← s: ↓ d: → e: dig f: flag n: new q: close
Pane · Dino
▣ client module ./dino-client.ts q: close
Pane · gomoku
No game. /gomoku solo, /gomoku match or /gomoku new starts one.
README

games

Mini games to play in a pane while Claude works: 2048, Minesweeper, Chrome's dino runner and Gomoku, which you can play against the computer, a friend or whoever is waiting. Everything happens in Claude Code's own UI, as one Claude Mod (hooks/register.ts), and nothing a game shows enters the conversation.

CommandGameNeeds the network
/20482048No
/minesMinesweeper, 9×9 with 10 minesNo
/dinoChrome's offline dino runnerNo
/gomoku soloGomoku against the computerNo
/gomoku new, /gomoku join <code>, /gomoku matchGomoku against a personYes, the relay

Install

Add the marketplace once, then install the plugin:

/plugin marketplace add musingfox/cc-plugins
/plugin install games

Built and tested against Claude Code 2.1.291, in the terminal. The games draw in the terminal and in the desktop app; /dino needs one of those two, since it runs inside a Client element that other surfaces do not have.

How every game behaves

  • Start it any time. Each command runs even while Claude is still answering, so you can send a prompt and open a game right away.
  • The pane takes the keys when it opens, as long as the prompt is empty. With text in the prompt, the pane opens without them.
  • Esc hands the keys back to the prompt and leaves the game on screen. Running the game's command again takes the keys back, and the game is still where you left it.
  • q closes the pane. 2048, Minesweeper and Gomoku keep their game for the rest of the session (Gomoku across sessions too); the dino run ends.
  • Letters, not arrows. Board games move with w a s d: a pane's hotkeys take letters and digits only, and the arrow keys walk the pane's buttons.
  • Colours follow /theme. Every colour is one of Claude Code's theme colours.

2048

/2048 opens a 4×4 board. Slide the tiles; two equal tiles that meet merge into their sum, and every slide adds a new 2 or 4. Reach 2048 to win, then keep going for a higher score.

KeyDoes
w a s dSlide up, left, down, right
nNew game
qClose the pane

The best score is kept across sessions.

Minesweeper

/mines opens a 9×9 field with 10 mines. Dig every cell that is not a mine to win.

KeyDoes
w a s dMove the cursor, drawn inverse
eDig the cell under the cursor
fFlag or unflag it
nNew game
qClose the pane
  • The first dig is always safe: the mines are laid after it, clear of that cell and its neighbours.
  • Digging a shown number whose flags already match it digs the rest of its neighbours.
  • The field is drawn in ASCII (. hidden, F flag, * mine), because characters of ambiguous width shift the columns in a terminal set up for CJK text.

Dino

/dino opens Chrome's offline runner. Click the pane once before you play: the game runs inside a Client element, which takes the keyboard only from a click.

KeyDoes
space, ↑ or wStart, jump, or run again after a crash
↓ or sDuck on the ground; in the air, drop fast
EscHand the keys back to the prompt
qClose the pane, while the pane rather than the game holds the keys
  • The run waits for your first key, then speeds up the longer you last. The score counts ten a second, and the best one is kept across sessions.
  • Cacti come from the start. After 20 seconds birds join them at two heights: a low bird hits a standing dino, so duck under it; a high bird flies over a standing dino and hits a jumping one.
  • A terminal sends no key release, so a duck lasts half a second past the last ↓. Hold ↓ and the key repeats, keeping the dino down.
  • Crashes are tested pixel against pixel, so the dino dies only on a touch you can see.
  • The field is pixel art in half blocks (▀ ▄ █). A terminal set to draw those ambiguous-width characters two columns wide breaks the picture.

Gomoku

Five or more of your stones in a row, across, down or diagonally, wins. The board is 15×15, black moves first, and the players take turns placing one stone each.

KeyDoes
w a s dMove the cursor, drawn inverse
ePlace a stone on your turn
qClose the pane; the game goes on

The last stone placed is drawn highlighted. /gomoku reopens the pane, and /gomoku leave gives up the current game (or stops looking for an opponent).

Against the computer: /gomoku solo

You play black and move first; the computer answers each move at once. It plays offline, with no relay and no network. It looks one move ahead: it completes its own five, blocks your fours and open threes, and otherwise builds its longest lines. It is a fair opponent for a game between prompts and can be beaten.

With a friend: /gomoku new and /gomoku join <code>

  1. Run /gomoku new. It opens a room, makes you black, and prints a line to send your friend: /gomoku join <code>.
  2. Your friend, with the plugin installed, runs that command and takes white.
  3. Play in turns. With your pane closed, a toast still says when your friend joins, moves or ends the game.

With anyone: /gomoku match

/gomoku match waits for the next player who asks for a match on the same relay and starts a game with them; black goes to either player at random. It waits up to 5 minutes, and a toast says when someone joins. /gomoku leave stops looking.

You can only be in one game at a time: finish it or /gomoku leave before starting another with new, join, match or solo.

The relay

Games against a person pass their moves through a relay. The plugin uses the shared one at https://games-relay.musingfox.com unless you set your own, so playing a friend or a stranger needs no setup.

  • What it sees: the room code, each player's display name, a random player id, and the board. It never sends one player's id to the other.
  • What it keeps: a room lives until 15 minutes after its last move or join; then it is deleted and the game shows as closed. Waiting for a reply does not count as a move, so a player who takes more than 15 minutes over one move closes the game.
  • How often it is asked: while you wait for your opponent, every 3 s with the pane open and every 30 s with it closed; never while it is your turn. While matching, every 3 s.
  • Each client checks every move it receives and refuses one that is not a single legal stone. That catches slips, not an opponent who edits their copy of the plugin.

To run your own relay, on your Cloudflare account or on your own machine, see relay/README.md; then point relay_url at it. Both players must use the same relay.

Settings

Set these in /config, under the games plugin.

OptionMeaning
relay_urlThe relay for games against a person. Empty means the shared relay.
player_nameThe name your opponent sees. Empty means your login name.
Source 11 files
hooks/register.ts 26 lines
1import type { Register } from 'claude-code'
2import { COMMAND_2048, register2048 } from './pane-2048.ts'
3import { COMMAND_DINO, registerDino } from './pane-dino.ts'
4import { COMMAND_GOMOKU, registerGomoku } from './pane-gomoku.ts'
5import type { GomokuOptions } from './pane-gomoku.ts'
6import { COMMAND_MINES, registerMines } from './pane-mines.ts'
7
8// The engine takes one unmatched session.start per plugin and never follows $ across an
9// import, so the games' commands register here.
10export const register: Register = (on, options) => {
11  on('session.start', async ($, e, next) => {
12    for (const command of [COMMAND_2048, COMMAND_MINES, COMMAND_DINO, COMMAND_GOMOKU]) {
13      try {
14        await $.command.register(command)
15      } catch {
16        // A refused command leaves that one game unreachable; the others still register.
17      }
18    }
19    return next(e)
20  })
21  register2048(on)
22  registerMines(on)
23  registerDino(on)
24  registerGomoku(on, (options ?? {}) as GomokuOptions)
25}
26
hooks/pane-2048.ts 126 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, On } from 'claude-code'
3import { GOAL, move, newGame, SIZE } from './game2048.ts'
4import type { Dir } from './game2048.ts'
5import type { Game } from '../types'
6
7const PANE = '2048'
8const BEST_KEY = 'best2048'
9const CELL_WIDTH = 6
10
11const game = atom({ plugin: 'games', key: 'game2048' } as const, null)
12const best = atom({ plugin: 'games', key: 'best2048' } as const, 0)
13
14// Hotkeys only take a letter or digit, so the arrows cannot drive the board.
15const MOVES: { dir: Dir; hotkey: string; glyph: string }[] = [
16  { dir: 'up', hotkey: 'w', glyph: '↑' },
17  { dir: 'left', hotkey: 'a', glyph: '←' },
18  { dir: 'down', hotkey: 's', glyph: '↓' },
19  { dir: 'right', hotkey: 'd', glyph: '→' },
20]
21
22// Theme keys, so the tiles follow the person's /theme.
23const TILE_COLORS: Record<number, string> = {
24  2: 'text',
25  4: 'text',
26  8: 'suggestion',
27  16: 'suggestion',
28  32: 'warning',
29  64: 'warning',
30  128: 'claude',
31  256: 'claude',
32  512: 'permission',
33  1024: 'permission',
34}
35
36function tileColor(value: number): string {
37  return value === 0 ? 'subtle' : (TILE_COLORS[value] ?? 'success')
38}
39
40async function remember($: EngineInterface, score: number) {
41  try {
42    await $.store.set(BEST_KEY, score)
43  } catch {
44    // The best score still shows for this session; only the next one will not remember it.
45  }
46}
47
48async function start($: EngineInterface) {
49  await update($, game, () => newGame(Math.random))
50}
51
52async function play($: EngineInterface, dir: Dir) {
53  const after = await update($, game, (g) => (g ? move(g, dir, Math.random) : g))
54  if (!after) return
55  const before = await read($, best)
56  if (after.score > before) {
57    await update($, best, (b) => Math.max(b, after.score))
58    await remember($, after.score)
59  }
60}
61
62function statusOf(g: Game): { text: string; color?: string } {
63  if (g.isOver) return { text: 'No moves left. n starts a new game.', color: 'error' }
64  if (g.hasWon) return { text: `${GOAL}! Keep going for a higher score.`, color: 'success' }
65  return { text: 'Esc hands the keys back; /2048 takes them again.' }
66}
67
68export const COMMAND_2048 = {
69  name: '2048',
70  description: 'Play 2048 in a pane while Claude works',
71  immediate: true,
72} as const
73
74async function loadBest($: EngineInterface) {
75  try {
76    const stored = await $.store.get(BEST_KEY)
77    if (typeof stored === 'number') await update($, best, (b) => Math.max(b, stored))
78  } catch {
79    // Without the store the best score starts from this session.
80  }
81}
82
83export function register2048(on: On) {
84  on('command.run', { command: '2048' }, async ($) => {
85    if (!(await read($, game))) await start($)
86    await loadBest($)
87    await $.ui.open({ id: PANE, title: '2048', focus: true })
88    return {}
89  })
90
91  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
92    const { Box, Text, Button } = $.ui.resolve(e)
93    const g = await read($, game)
94    if (!g) return Text({ dimColor: true, children: ['Run /2048 to start.'] })
95    const top = await read($, best)
96    const rows = [...Array(SIZE).keys()].map((r) =>
97      Box({
98        key: `row:${r}`,
99        children: g.board.slice(r * SIZE, (r + 1) * SIZE).map((v) =>
100          Text({ color: tileColor(v), bold: v >= GOAL, children: [String(v || '·').padStart(CELL_WIDTH)] }),
101        ),
102      }),
103    )
104    const status = statusOf(g)
105    return Box({
106      flexDirection: 'column',
107      children: [
108        Box({ key: 'score', children: [Text({ children: [`Score ${g.score}   Best ${Math.max(top, g.score)}`] })] }),
109        Box({ flexDirection: 'column', children: rows }),
110        Text({ color: status.color, dimColor: !status.color, children: [status.text] }),
111        Box({
112          flexDirection: 'row',
113          gap: 2,
114          children: [
115            ...MOVES.map((m) =>
116              Button({ key: m.dir, label: m.glyph, hotkey: m.hotkey, plain: true, onPress: () => play($, m.dir) }),
117            ),
118            Button({ key: 'new', label: 'new', hotkey: 'n', plain: true, onPress: () => start($) }),
119            Button({ key: 'close', label: 'close', hotkey: 'q', plain: true, onPress: () => $.ui.close({ id: PANE }) }),
120          ],
121        }),
122      ],
123    })
124  })
125}
126
hooks/pane-dino.ts 68 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, On } from 'claude-code'
3import { FIELD_ROWS } from './dino.ts'
4
5const PANE = 'dino'
6const BEST_KEY = 'bestDino'
7// The score line, the sky, the ground and the hint.
8const CLIENT_ROWS = FIELD_ROWS + 3
9
10const best = atom({ plugin: 'games', key: 'bestDino' } as const, 0)
11
12export const COMMAND_DINO = {
13  name: 'dino',
14  description: 'Play the Chrome dino runner in a pane while Claude works',
15  immediate: true,
16} as const
17
18async function loadBest($: EngineInterface) {
19  try {
20    const stored = await $.store.get(BEST_KEY)
21    if (typeof stored === 'number') await update($, best, (b) => Math.max(b, stored))
22  } catch {
23    // Without the store the best score starts from this session.
24  }
25}
26
27async function remember($: EngineInterface, score: number) {
28  try {
29    await $.store.set(BEST_KEY, score)
30  } catch {
31    // The best score still shows for this session; only the next one will not remember it.
32  }
33}
34
35export function registerDino(on: On) {
36  on('command.run', { command: 'dino' }, async ($) => {
37    await loadBest($)
38    await $.ui.open({ id: PANE, title: 'Dino', focus: true })
39    return {}
40  })
41
42  // The run's state lives in the Client; it posts only a finished run's score.
43  on('ui.message', { requestId: PANE }, async ($, e) => {
44    const score = (e.data as { score?: unknown } | null)?.score
45    if (typeof score !== 'number') return {}
46    const top = await update($, best, (b) => Math.max(b, score))
47    if (top === score) await remember($, score)
48    return { props: { best: top } }
49  })
50
51  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
52    // A Client draws only where the surface runs its module.
53    if (e.surface !== 'terminal' && e.surface !== 'desktop') {
54      const { Text } = $.ui.resolve(e)
55      return Text({ dimColor: true, children: ['Dino needs the terminal or the desktop app.'] })
56    }
57    const { Box, Button, Client } = $.ui.resolve(e)
58    const top = await read($, best)
59    return Box({
60      flexDirection: 'column',
61      children: [
62        Client({ key: 'dino', module: './dino-client.ts', width: '100%', height: CLIENT_ROWS, props: { best: top } }),
63        Button({ key: 'close', label: 'close', hotkey: 'q', plain: true, onPress: () => $.ui.close({ id: PANE }) }),
64      ],
65    })
66  })
67}
68
hooks/pane-gomoku.ts 369 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, On } from 'claude-code'
3import { hasOpponent, isMyTurn, isOver, moveCursor, newGame, newSoloGame, place, playSolo, reconcile, SIZE, snapshotOf, STONES, winnerOf } from './gomoku.ts'
4import type { View } from './gomoku.ts'
5import type { Gomoku } from '../types'
6
7const PANE = 'gomoku'
8const STORE_GAME = 'gomoku'
9const STORE_PLAYER = 'gomokuPlayer'
10const TICK_MS = 3000
11// With the pane closed the client polls every tenth tick, 30 s, to spend fewer requests.
12const CLOSED_EVERY = 10
13// The lobby drops a waiting player after 30 s without a call, so matching polls every tick.
14const MATCH_TIMEOUT_MS = 5 * 60_000
15const CODE = /^[a-z0-9]{4,12}$/
16// What an empty relay_url means, so clearing the option in /config falls back to it.
17export const SHARED_RELAY = 'https://games-relay.musingfox.com'
18
19export type GomokuOptions = { relay_url?: string; player_name?: string }
20
21export const COMMAND_GOMOKU = {
22  name: 'gomoku',
23  description: 'Play Gomoku against the computer, a friend or a stranger while Claude works',
24  argumentHint: '[solo | new | join <code> | match | leave]',
25  immediate: true,
26} as const
27
28const game = atom({ plugin: 'games', key: 'gomoku' } as const, null)
29const matching = atom({ plugin: 'games', key: 'gomokuMatch' } as const, null)
30
31const MOVES = [
32  { dir: 'up', hotkey: 'w', glyph: '↑' },
33  { dir: 'left', hotkey: 'a', glyph: '←' },
34  { dir: 'down', hotkey: 's', glyph: '↓' },
35  { dir: 'right', hotkey: 'd', glyph: '→' },
36] as const
37
38const GLYPHS: Record<string, string> = { '.': '·', x: '●', o: '○' }
39
40type Reply = { status: number; view: View | null; data: Record<string, unknown> | null; error: string | null }
41
42// Module state: a reload starts it over, and the next prompt or /gomoku starts polling again.
43let relay = ''
44let playerName = ''
45let poll: { cancel(): void } | null = null
46let ticks = 0
47let isPaneOpen = false
48let isBusy = false
49
50async function remember($: EngineInterface, g: Gomoku | null) {
51  await update($, game, () => g)
52  try {
53    await $.store.set(STORE_GAME, g)
54  } catch {
55    // The game still plays this session; only a restart would lose it.
56  }
57}
58
59async function current($: EngineInterface): Promise<Gomoku | null> {
60  const held = await read($, game)
61  if (held) return held
62  try {
63    const stored = (await $.store.get(STORE_GAME)) as Gomoku | null | undefined
64    if (stored) await update($, game, () => stored)
65    return stored ?? null
66  } catch {
67    return null
68  }
69}
70
71async function identity($: EngineInterface): Promise<{ player: string; name: string }> {
72  let player: unknown = null
73  try {
74    player = await $.store.get(STORE_PLAYER)
75  } catch {
76    // A fresh id below; it only has to outlive this game.
77  }
78  if (typeof player !== 'string') {
79    player = crypto.randomUUID()
80    try {
81      await $.store.set(STORE_PLAYER, player)
82    } catch {
83      // Kept for this session only.
84    }
85  }
86  const name = playerName || (await $.env.get('USER')) || 'player'
87  return { player: player as string, name }
88}
89
90async function call($: EngineInterface, method: 'GET' | 'POST', path: string, body?: object): Promise<Reply> {
91  if (!relay) return { status: 0, view: null, data: null, error: 'No relay_url is set for the games plugin.' }
92  try {
93    const res = await $.http.fetch(`${relay}${path}`, {
94      method,
95      headers: { 'content-type': 'application/json' },
96      ...(body ? { body: JSON.stringify(body) } : {}),
97    })
98    const data = JSON.parse(res.text) as View & { error?: string; view?: View }
99    if (res.ok) return { status: res.status, view: data, data, error: null }
100    return { status: res.status, view: data.view ?? null, data, error: data.error ?? `relay answered ${res.status}` }
101  } catch {
102    return { status: 0, view: null, data: null, error: 'The relay could not be reached.' }
103  }
104}
105
106function announce($: EngineInterface, before: Gomoku, after: Gomoku) {
107  if (isPaneOpen) return
108  const them = after.names[1 - after.seat] ?? 'Your friend'
109  if (after.isClosed && !before.isClosed) $.ui.toast('Gomoku: the room closed after 15 minutes without a move.')
110  else if (!hasOpponent(before) && hasOpponent(after)) $.ui.toast(`Gomoku: ${them} joined. /gomoku to play.`)
111  else if (isOver(after) && !isOver(before)) $.ui.toast(`Gomoku: the game is over. /gomoku to see it.`)
112  else if (isMyTurn(after) && !isMyTurn(before)) $.ui.toast(`Gomoku: ${them} moved. Your turn, /gomoku.`)
113}
114
115async function sendMove($: EngineInterface, g: Gomoku): Promise<Reply> {
116  const me = await identity($)
117  return call($, 'POST', `/rooms/${g.code}/move`, { player: me.player, seq: g.seq, snapshot: snapshotOf(g) })
118}
119
120// One round with the room: read it, and send again a move of this client's it missed.
121async function sync($: EngineInterface) {
122  const g = await current($)
123  if (!g || isBusy) return
124  isBusy = true
125  try {
126    const me = await identity($)
127    let reply = await call($, 'GET', `/rooms/${g.code}?player=${me.player}`)
128    if (!reply.view) return void (await remember($, { ...g, note: reply.error }))
129    let step = reconcile(g, reply.view)
130    if (step.send === 'move') {
131      reply = await sendMove($, step.game)
132      step = reply.view ? reconcile(step.game, reply.view) : { game: { ...step.game, note: reply.error }, send: 'none' }
133    }
134    const latest = (await current($)) ?? g
135    // A move made while the round was out wins over what the room said before it.
136    const after = latest.seq > g.seq ? latest : { ...step.game, cursor: latest.cursor }
137    await remember($, after)
138    announce($, g, after)
139  } finally {
140    isBusy = false
141  }
142}
143
144// One call to the lobby while matching: still waiting, paired (then the game starts), or
145// out of time.
146async function lobbyRound($: EngineInterface) {
147  const waiting = await read($, matching)
148  if (!waiting || isBusy) return
149  isBusy = true
150  try {
151    const me = await identity($)
152    const timedOut = (await $.clock.now()) - waiting.since >= MATCH_TIMEOUT_MS
153    if (timedOut) {
154      await call($, 'POST', '/lobby/leave', { player: me.player })
155      await update($, matching, () => null)
156      if (!isPaneOpen) $.ui.toast('Gomoku: no opponent turned up in 5 minutes.')
157      return
158    }
159    const reply = await call($, 'POST', '/lobby/match', me)
160    const code = reply.data?.code
161    if (typeof code !== 'string') return
162    const room = await call($, 'GET', `/rooms/${code}?player=${me.player}`)
163    if (!room.view || room.view.you === null) return
164    const fresh = reconcile(newGame(code, room.view.you, room.view.names), room.view).game
165    await remember($, fresh)
166    await update($, matching, () => null)
167    const them = fresh.names[1 - fresh.seat] ?? 'someone'
168    if (!isPaneOpen) $.ui.toast(`Gomoku: matched with ${them}. /gomoku to play.`)
169  } finally {
170    isBusy = false
171  }
172}
173
174function startPolling($: EngineInterface) {
175  if (poll) return
176  poll = $.clock.every(TICK_MS, async () => {
177    ticks += 1
178    if (await read($, matching)) return void (await lobbyRound($))
179    if (!isPaneOpen && ticks % CLOSED_EVERY !== 0) return
180    const g = await read($, game)
181    if (!g || g.isSolo || g.isClosed || isOver(g)) return
182    if (g.isSynced && isMyTurn(g)) return
183    await sync($)
184  })
185}
186
187async function play($: EngineInterface) {
188  const g = await current($)
189  if (g?.isSolo) {
190    const answered = playSolo(g)
191    if (answered) await remember($, answered)
192    return
193  }
194  const moved = g && place(g)
195  if (!g || !moved) return
196  await remember($, moved)
197  const reply = await sendMove($, moved)
198  if (reply.view && reply.status === 200) await remember($, { ...moved, isSynced: true })
199  // Refused or unreached, the move stays here and the next round sends it again or corrects it.
200  else await sync($)
201}
202
203async function start($: EngineInterface, code: string, wanted?: 0 | 1): Promise<string | null> {
204  const me = await identity($)
205  const reply = await call($, 'POST', `/rooms/${code}/join`, { ...me, ...(wanted === undefined ? {} : { seat: wanted }) })
206  if (!reply.view || reply.view.you === null) return reply.error ?? 'Could not join the room.'
207  const fresh = newGame(code, reply.view.you, reply.view.names)
208  await remember($, reconcile(fresh, reply.view).game)
209  return null
210}
211
212async function openPane($: EngineInterface) {
213  isPaneOpen = true
214  await $.ui.open({ id: PANE, title: 'Gomoku', focus: true })
215}
216
217// A plugin's own $.ui.close runs only the hooks beneath it, so its ui.close hook never hears it.
218async function closePane($: EngineInterface) {
219  isPaneOpen = false
220  await $.ui.close({ id: PANE })
221}
222
223export function registerGomoku(on: On, options: GomokuOptions) {
224  relay = (options.relay_url || SHARED_RELAY).replace(/\/+$/, '')
225  playerName = options.player_name ?? ''
226
227  on('command.run', { command: 'gomoku' }, async ($, e) => {
228    const [verb = '', arg = ''] = (e.args ?? '').trim().toLowerCase().split(/\s+/)
229    if (verb === 'leave') {
230      if (await read($, matching)) {
231        await call($, 'POST', '/lobby/leave', { player: (await identity($)).player })
232        await update($, matching, () => null)
233      }
234      await remember($, null)
235      await closePane($)
236      return { text: 'Left the Gomoku game.' }
237    }
238    if (verb === 'solo') {
239      const g = await current($)
240      if (g && !g.isSolo && !g.isClosed && !isOver(g)) return { text: 'Gomoku: finish this game first, or /gomoku leave.' }
241      if (await read($, matching)) return { text: 'Gomoku: still looking for an opponent; /gomoku leave first.' }
242      await remember($, newSoloGame((await identity($)).name))
243      await openPane($)
244      return {}
245    }
246    if (verb === 'match') {
247      const g = await current($)
248      if (g && !g.isSolo && !g.isClosed && !isOver(g)) return { text: 'Gomoku: finish this game first, or /gomoku leave.' }
249      await remember($, null)
250      const since = await $.clock.now()
251      await update($, matching, () => ({ since }))
252      startPolling($)
253      await openPane($)
254      await lobbyRound($)
255      return {}
256    }
257    if (verb === 'new' || verb === 'join') {
258      const g = await current($)
259      if (g && !g.isSolo && !g.isClosed && !isOver(g)) return { text: 'Gomoku: finish this game first, or /gomoku leave.' }
260      if (await read($, matching)) return { text: 'Gomoku: still looking for an opponent; /gomoku leave first.' }
261      const code = verb === 'new' ? crypto.randomUUID().replace(/-/g, '').slice(0, 6) : arg
262      if (!CODE.test(code)) return { text: 'usage: /gomoku join <code>, the code your friend got from /gomoku new' }
263      const failed = await start($, code, verb === 'new' ? 0 : undefined)
264      if (failed) return { text: `Gomoku: ${failed}` }
265      startPolling($)
266      await openPane($)
267      return verb === 'new' ? { text: `Gomoku room ${code}. Send your friend: /gomoku join ${code}` } : {}
268    }
269    if (verb !== '') return { text: 'usage: /gomoku [solo | new | join <code> | match | leave]' }
270    if (!(await current($)) && !(await read($, matching)))
271      return {
272        text: 'No Gomoku game yet. /gomoku solo plays the computer, /gomoku match finds an opponent, /gomoku new opens a room for a friend.',
273      }
274    startPolling($)
275    await openPane($)
276    void sync($)
277    return {}
278  })
279
280  // A game left from an earlier session resumes polling with the first prompt.
281  on('turn.start', async ($, e, next) => {
282    if (!poll && (await current($))) startPolling($)
283    return next(e)
284  })
285
286  // The person's own close (the engine's mark, ctrl+x x) arrives here.
287  on('ui.close', { id: PANE }, async ($, e, next) => {
288    isPaneOpen = false
289    return next(e)
290  })
291
292  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
293    const { Box, Text, Button } = $.ui.resolve(e)
294    if (await read($, matching))
295      return Box({
296        flexDirection: 'column',
297        children: [
298          Box({ key: 'status', children: [Text({ children: ['Looking for an opponent…'] })] }),
299          Text({ dimColor: true, children: ['A toast says when someone joins. /gomoku leave stops looking.'] }),
300          Button({ key: 'close', label: 'close', hotkey: 'q', plain: true, onPress: () => closePane($) }),
301        ],
302      })
303    const g = await read($, game)
304    if (!g) return Text({ dimColor: true, children: ['No game. /gomoku solo, /gomoku match or /gomoku new starts one.'] })
305    const me = g.names[g.seat] ?? 'you'
306    const them = g.names[1 - g.seat]
307    const winner = winnerOf(g)
308    const status = g.isClosed
309    ? { text: 'Closed. /gomoku new starts another game.' }
310    : !hasOpponent(g)
311      ? { text: `Waiting for a friend: /gomoku join ${g.code}` }
312      : winner !== null
313        ? winner === g.seat
314          ? { text: 'You win!', color: 'success' }
315          : { text: `${them} wins.`, color: 'error' }
316        : isOver(g)
317          ? { text: 'The board is full: a draw.' }
318          : isMyTurn(g)
319            ? { text: 'Your move: e places a stone.', color: 'success' }
320            : { text: `${them}'s move…` }
321    const rows = [...Array(SIZE).keys()].map((r) =>
322      Box({
323        key: `row:${r}`,
324        children: [...Array(SIZE).keys()].map((c) => {
325          const k = r * SIZE + c
326          const cell = g.board[k]!
327          return Text({
328            inverse: k === g.cursor,
329            color: k === g.last ? 'warning' : cell === '.' ? 'subtle' : undefined,
330            bold: k === g.last,
331            children: [` ${GLYPHS[cell]}`],
332          })
333        }),
334      }),
335    )
336    const mine = GLYPHS[STONES[g.seat]]
337    const theirs = GLYPHS[STONES[1 - g.seat]!]
338    return Box({
339      flexDirection: 'column',
340      children: [
341        Box({
342          key: 'players',
343          children: [Text({ children: [`${g.isSolo ? 'Solo' : `Room ${g.code}`}   ${mine} ${me} (you)   vs   ${theirs} ${them ?? '…'}`] })],
344        }),
345        Box({ key: 'status', children: [Text({ color: status.color, dimColor: !status.color, children: [status.text] })] }),
346        ...(g.note ? [Text({ color: 'warning', children: [g.note] })] : []),
347        Box({ flexDirection: 'column', children: rows }),
348        Box({
349          flexDirection: 'row',
350          gap: 2,
351          children: [
352            ...MOVES.map((m) =>
353              Button({
354                key: m.dir,
355                label: m.glyph,
356                hotkey: m.hotkey,
357                plain: true,
358                onPress: () => update($, game, (x) => (x ? moveCursor(x, m.dir) : x)),
359              }),
360            ),
361            Button({ key: 'place', label: 'place', hotkey: 'e', plain: true, onPress: () => play($) }),
362            Button({ key: 'close', label: 'close', hotkey: 'q', plain: true, onPress: () => closePane($) }),
363          ],
364        }),
365      ],
366    })
367  })
368}
369
hooks/pane-mines.ts 104 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, On } from 'claude-code'
3import { COLS, countAt, dig, flag, MINE_COUNT, moveCursor, newMines, ROWS } from './mines.ts'
4import type { Dir } from './mines.ts'
5import type { Mines } from '../types'
6
7const PANE = 'mines'
8
9const game = atom({ plugin: 'games', key: 'mines' } as const, null)
10
11const MOVES: { dir: Dir; hotkey: string; glyph: string }[] = [
12  { dir: 'up', hotkey: 'w', glyph: '↑' },
13  { dir: 'left', hotkey: 'a', glyph: '←' },
14  { dir: 'down', hotkey: 's', glyph: '↓' },
15  { dir: 'right', hotkey: 'd', glyph: '→' },
16]
17
18// Theme keys, so the numbers follow the person's /theme.
19const NUMBER_COLORS = ['', 'suggestion', 'success', 'error', 'permission', 'claude', 'warning', 'text', 'inactive']
20
21// ASCII glyphs: an ambiguous-width one (·, ⚑) drifts the grid in a CJK terminal.
22function cellOf(g: Mines, k: number): { glyph: string; color?: string } {
23  const isMine = g.mines?.[k] ?? false
24  if (g.state === 'lost' && isMine) return { glyph: '*', color: 'error' }
25  if (g.flagged[k]) return { glyph: 'F', color: 'warning' }
26  if (!g.revealed[k]) return { glyph: '.', color: 'subtle' }
27  const n = countAt(g.mines!, k)
28  return n === 0 ? { glyph: ' ' } : { glyph: String(n), color: NUMBER_COLORS[n] }
29}
30
31function statusOf(g: Mines): { text: string; color?: string } {
32  if (g.state === 'lost') return { text: 'Boom. n starts a new game.', color: 'error' }
33  if (g.state === 'won') return { text: 'Cleared! n starts a new game.', color: 'success' }
34  return { text: 'e digs, f flags. Esc hands the keys back; /mines takes them again.' }
35}
36
37async function act($: EngineInterface, change: (g: Mines) => Mines) {
38  await update($, game, (g) => (g ? change(g) : g))
39}
40
41export const COMMAND_MINES = {
42  name: 'mines',
43  description: 'Play Minesweeper in a pane while Claude works',
44  immediate: true,
45} as const
46
47export function registerMines(on: On) {
48  on('command.run', { command: 'mines' }, async ($) => {
49    if (!(await read($, game))) await update($, game, () => newMines())
50    await $.ui.open({ id: PANE, title: 'Minesweeper', focus: true })
51    return {}
52  })
53
54  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
55    const { Box, Text, Button } = $.ui.resolve(e)
56    const g = await read($, game)
57    if (!g) return Text({ dimColor: true, children: ['Run /mines to start.'] })
58    const flags = g.flagged.filter(Boolean).length
59    const rows = [...Array(ROWS).keys()].map((r) =>
60      Box({
61        key: `row:${r}`,
62        children: [...Array(COLS).keys()].map((c) => {
63          const k = r * COLS + c
64          const cell = cellOf(g, k)
65          return Text({
66            color: cell.color,
67            inverse: k === g.cursor || k === g.hit,
68            bold: k === g.hit,
69            children: [` ${cell.glyph}`],
70          })
71        }),
72      }),
73    )
74    const status = statusOf(g)
75    return Box({
76      flexDirection: 'column',
77      children: [
78        Box({ key: 'count', children: [Text({ children: [`Mines ${MINE_COUNT - flags}`] })] }),
79        Box({ flexDirection: 'column', children: rows }),
80        Text({ color: status.color, dimColor: !status.color, children: [status.text] }),
81        Box({
82          flexDirection: 'row',
83          gap: 2,
84          children: [
85            ...MOVES.map((m) =>
86              Button({
87                key: m.dir,
88                label: m.glyph,
89                hotkey: m.hotkey,
90                plain: true,
91                onPress: () => act($, (g) => moveCursor(g, m.dir)),
92              }),
93            ),
94            Button({ key: 'dig', label: 'dig', hotkey: 'e', plain: true, onPress: () => act($, (g) => dig(g, Math.random)) }),
95            Button({ key: 'flag', label: 'flag', hotkey: 'f', plain: true, onPress: () => act($, flag) }),
96            Button({ key: 'new', label: 'new', hotkey: 'n', plain: true, onPress: () => update($, game, () => newMines()) }),
97            Button({ key: 'close', label: 'close', hotkey: 'q', plain: true, onPress: () => $.ui.close({ id: PANE }) }),
98          ],
99        }),
100      ],
101    })
102  })
103}
104
hooks/game2048.ts 77 lines
1import type { Board, Game } from '../types'
2
3export const SIZE = 4
4export const GOAL = 2048
5
6export type Dir = 'up' | 'down' | 'left' | 'right'
7
8export type Slid = { board: Board; gained: number; moved: boolean }
9
10function lineIndexes(dir: Dir, i: number): number[] {
11  const steps = [...Array(SIZE).keys()]
12  if (dir === 'left') return steps.map((c) => i * SIZE + c)
13  if (dir === 'right') return steps.map((c) => i * SIZE + (SIZE - 1 - c))
14  if (dir === 'up') return steps.map((r) => r * SIZE + i)
15  return steps.map((r) => (SIZE - 1 - r) * SIZE + i)
16}
17
18// Each tile merges at most once per move, so [2, 2, 4] becomes [4, 4], not [8].
19function slideLine(line: number[]): { line: number[]; gained: number } {
20  const tiles = line.filter((v) => v !== 0)
21  const out: number[] = []
22  let gained = 0
23  for (let i = 0; i < tiles.length; i++) {
24    if (tiles[i] === tiles[i + 1]) {
25      const merged = tiles[i]! * 2
26      out.push(merged)
27      gained += merged
28      i++
29    } else out.push(tiles[i]!)
30  }
31  while (out.length < SIZE) out.push(0)
32  return { line: out, gained }
33}
34
35export function slide(board: Board, dir: Dir): Slid {
36  const next = [...board]
37  let gained = 0
38  for (let i = 0; i < SIZE; i++) {
39    const idx = lineIndexes(dir, i)
40    const slid = slideLine(idx.map((k) => board[k]!))
41    idx.forEach((k, j) => (next[k] = slid.line[j]!))
42    gained += slid.gained
43  }
44  return { board: next, gained, moved: next.some((v, k) => v !== board[k]) }
45}
46
47// rand returns [0, 1), as Math.random does.
48export function spawn(board: Board, rand: () => number): Board {
49  const empty = board.flatMap((v, k) => (v === 0 ? [k] : []))
50  if (empty.length === 0) return board
51  const next = [...board]
52  next[empty[Math.floor(rand() * empty.length)]!] = rand() < 0.9 ? 2 : 4
53  return next
54}
55
56export function canMove(board: Board): boolean {
57  return (['up', 'down', 'left', 'right'] as const).some((dir) => slide(board, dir).moved)
58}
59
60export function newGame(rand: () => number): Game {
61  const board = spawn(spawn(Array(SIZE * SIZE).fill(0), rand), rand)
62  return { board, score: 0, hasWon: false, isOver: false }
63}
64
65export function move(game: Game, dir: Dir, rand: () => number): Game {
66  if (game.isOver) return game
67  const slid = slide(game.board, dir)
68  if (!slid.moved) return game
69  const board = spawn(slid.board, rand)
70  return {
71    board,
72    score: game.score + slid.gained,
73    hasWon: game.hasWon || board.some((v) => v >= GOAL),
74    isOver: !canMove(board),
75  }
76}
77
hooks/dino.ts 192 lines
1// Units are pixels and ticks. A text cell is one pixel wide and two tall: a half block
2// (▀ ▄ █) draws the top, the bottom or both. One tick is TICK_MS.
3export const TICK_MS = 50
4export const FIELD_ROWS = 8
5export const FIELD_PX = FIELD_ROWS * 2
6export const DINO_X = 3
7export const DEFAULT_WIDTH = 60
8
9// A jump peaks near JUMP_PEAK pixels after JUMP_TICKS / 2 ticks; stepping in whole ticks
10// overshoots by about one, which the dino's head needs room for under the field's top.
11const JUMP_PEAK = 7
12const JUMP_TICKS = 16
13const JUMP_V = (4 * JUMP_PEAK) / JUMP_TICKS
14const GRAVITY = (8 * JUMP_PEAK) / JUMP_TICKS ** 2
15const START_SPEED = 1.4
16const MAX_SPEED = 2.8
17const ACCEL = 0.0012
18const GAP_SPREAD = 60
19const STRIDE_TICKS = 3
20const FLAP_TICKS = 4
21// A terminal sends no key release, so a duck lasts this long past the last ↓; holding ↓
22// repeats the key before it runs out.
23export const DUCK_TICKS = 10
24// Birds join once the run has warmed up, as in Chrome.
25const BIRDS_AFTER = 400
26const BIRD_CHANCE = 0.35
27
28// Rows top first; '#' is a pixel.
29const STAND = [
30  '.....#####',
31  '.....#.###',
32  '.....#####',
33  '#...####..',
34  '##.#####..',
35  '.######...',
36]
37const STAND_LEGS = {
38  still: ['..#..#....', '..##.##...'],
39  left: ['..#..#....', '..##......'],
40  right: ['...#..#...', '......##..'],
41}
42const DUCK = ['........####', '#..######.##', '###########.', '.########...']
43const DUCK_LEGS = { left: ['..#..#......'], right: ['...#..#.....'] }
44
45const BIRD_UP = ['...#....', '...##...', '##.#####', '.######.', '........']
46const BIRD_DOWN = ['........', '........', '##.#####', '.######.', '...##...']
47
48export type Ink = 0 | 1 | 2 | 3
49export const DINO: Ink = 1
50export const TREE: Ink = 2
51export const BIRD: Ink = 3
52
53type Kind = { frames: string[][]; y: number; ink: Ink }
54
55// A mid bird flies through a standing dino and over a ducking one; a high bird flies over
56// a standing dino and into a jumping one.
57export const KINDS: Kind[] = [
58  { frames: [['..#..', '#.#.#', '#####', '..#..']], y: 0, ink: TREE },
59  { frames: [['..#..', '#.#.#', '#.###', '###..', '..#..']], y: 0, ink: TREE },
60  { frames: [BIRD_UP, BIRD_DOWN], y: 5, ink: BIRD },
61  { frames: [BIRD_UP, BIRD_DOWN], y: 9, ink: BIRD },
62]
63const TREE_KINDS = [0, 1]
64const BIRD_KINDS = [2, 3]
65
66export type Obstacle = { x: number; kind: number }
67
68export type Run = {
69  phase: 'ready' | 'running' | 'over'
70  y: number
71  vy: number
72  duck: number
73  speed: number
74  obstacles: Obstacle[]
75  gap: number
76  ticks: number
77}
78
79export function newRun(): Run {
80  return { phase: 'ready', y: 0, vy: 0, duck: 0, speed: START_SPEED, obstacles: [], gap: 20, ticks: 0 }
81}
82
83export const scoreOf = (run: Run) => Math.floor(run.ticks / 2)
84
85const isDucking = (run: Run) => run.duck > 0 && run.y === 0
86
87export function dinoSprite(run: Run): string[] {
88  const stride = Math.floor(run.ticks / STRIDE_TICKS) % 2 ? 'left' : 'right'
89  if (isDucking(run)) return [...DUCK, ...DUCK_LEGS[stride]]
90  const legs = run.phase !== 'running' || run.y > 0 ? 'still' : stride
91  return [...STAND, ...STAND_LEGS[legs]]
92}
93
94export function obstacleSprite(o: Obstacle, ticks: number): string[] {
95  const { frames } = KINDS[o.kind]!
96  return frames[Math.floor(ticks / FLAP_TICKS) % frames.length]!
97}
98
99// Space or ↑ does it all: start a waiting run, jump from the ground, or run again after a crash.
100export function jump(run: Run): Run {
101  if (run.phase === 'over') return { ...newRun(), phase: 'running' }
102  if (run.phase === 'ready') return { ...run, phase: 'running', vy: JUMP_V }
103  return run.y === 0 ? { ...run, vy: JUMP_V, duck: 0 } : run
104}
105
106// On the ground ↓ ducks; in the air it drops the dino fast, as Chrome's does.
107export function duck(run: Run): Run {
108  if (run.phase !== 'running') return run
109  return run.y > 0 ? { ...run, vy: Math.min(run.vy, -JUMP_V) } : { ...run, duck: DUCK_TICKS }
110}
111
112// Each '#' of a sprite whose bottom-left pixel sits at (x, y), as "x,height".
113function pixelsOf(sprite: string[], x: number, y: number): Set<string> {
114  const out = new Set<string>()
115  sprite.forEach((line, r) => {
116    for (let c = 0; c < line.length; c++) if (line[c] === '#') out.add(`${x + c},${y + sprite.length - 1 - r}`)
117  })
118  return out
119}
120
121const obstaclePixels = (o: Obstacle, ticks: number) =>
122  pixelsOf(obstacleSprite(o, ticks), Math.floor(o.x), KINDS[o.kind]!.y)
123
124// Pixel against pixel, so a crash is always one the player could see.
125function hits(run: Run): boolean {
126  const dino = pixelsOf(dinoSprite(run), DINO_X, Math.floor(run.y))
127  return run.obstacles.some((o) => [...obstaclePixels(o, run.ticks)].some((p) => dino.has(p)))
128}
129
130export function step(run: Run, width: number, rand: () => number): Run {
131  if (run.phase !== 'running') return run
132  let y = run.y + run.vy
133  let vy = run.vy - GRAVITY
134  if (y <= 0) {
135    y = 0
136    vy = 0
137  }
138  const speed = Math.min(MAX_SPEED, run.speed + ACCEL)
139  const obstacles = run.obstacles
140    .map((o) => ({ ...o, x: o.x - speed }))
141    .filter((o) => o.x + KINDS[o.kind]!.frames[0]![0]!.length > 0)
142  let gap = run.gap - speed
143  if (gap <= 0) {
144    const pool = run.ticks >= BIRDS_AFTER && rand() < BIRD_CHANCE ? BIRD_KINDS : TREE_KINDS
145    obstacles.push({ x: width, kind: pool[Math.floor(rand() * pool.length)]! })
146    // Room to land and act again: a whole jump's run plus a few ticks, at the current speed.
147    gap = speed * (JUMP_TICKS + 8) + rand() * GAP_SPREAD
148  }
149  const next: Run = { ...run, y, vy, duck: Math.max(0, run.duck - 1), speed, obstacles, gap, ticks: run.ticks + 1 }
150  return hits(next) ? { ...next, phase: 'over' } : next
151}
152
153// FIELD_PX rows of pixels, top first.
154export function pixelGridOf(run: Run, width: number): Ink[][] {
155  const grid: Ink[][] = [...Array(FIELD_PX)].map(() => Array(width).fill(0))
156  const paint = (pixels: Set<string>, ink: Ink) => {
157    for (const p of pixels) {
158      const [x, h] = p.split(',').map(Number) as [number, number]
159      if (x >= 0 && x < width && h >= 0 && h < FIELD_PX) grid[FIELD_PX - 1 - h]![x] = ink
160    }
161  }
162  for (const o of run.obstacles) paint(obstaclePixels(o, run.ticks), KINDS[o.kind]!.ink)
163  paint(pixelsOf(dinoSprite(run), DINO_X, Math.floor(run.y)), DINO)
164  return grid
165}
166
167export type Seg = { text: string; fg: Ink; bg: Ink }
168
169// Two pixel rows to one text row, runs of one look merged into one segment.
170export function textRowsOf(grid: Ink[][]): Seg[][] {
171  const rows: Seg[][] = []
172  for (let r = 0; r < grid.length; r += 2) {
173    const segs: Seg[] = []
174    grid[r]!.forEach((top, x) => {
175      const bottom = grid[r + 1]![x]!
176      const cell: Seg =
177        top === 0 && bottom === 0
178          ? { text: ' ', fg: 0, bg: 0 }
179          : top === bottom
180            ? { text: '█', fg: top, bg: 0 }
181            : top === 0
182              ? { text: '▄', fg: bottom, bg: 0 }
183              : { text: '▀', fg: top, bg: bottom }
184      const last = segs[segs.length - 1]
185      if (last && last.fg === cell.fg && last.bg === cell.bg) last.text += cell.text
186      else segs.push(cell)
187    })
188    rows.push(segs)
189  }
190  return rows
191}
192
hooks/dino-client.ts 68 lines
1import type { ClientSurface } from 'claude-code'
2import { DEFAULT_WIDTH, duck, jump, newRun, pixelGridOf, scoreOf, step, textRowsOf, TICK_MS } from './dino.ts'
3import type { Ink, Run } from './dino.ts'
4
5type Props = { best: number }
6
7const HINTS: Record<Run['phase'], string> = {
8  ready: 'Click here, then space or ↑ to start.',
9  running: 'space or ↑ jumps, ↓ ducks. Esc hands the keys back.',
10  over: 'Crashed. space runs again.',
11}
12
13// The space bar's name is not in the typings; take it however the surface spells it.
14function isJump(key: string) {
15  return key === 'up' || key === 'w' || key === 'space' || key.trim() === ''
16}
17
18const isDuck = (key: string) => key === 'down' || key === 's'
19
20// Theme keys, so the field follows the person's /theme.
21function colorOf(ink: Ink, run: Run): string | undefined {
22  if (ink === 1) return run.phase === 'over' ? 'error' : 'text'
23  if (ink === 2) return 'success'
24  if (ink === 3) return 'claude'
25  return undefined
26}
27
28// Runs on the surface's frame clock, so the game never waits on a hook round trip.
29export default function Dino(props: Props, surface: ClientSurface<Run>) {
30  const { Box, Text } = surface.elements
31  const width = () => surface.columns || DEFAULT_WIDTH
32  if (surface.state === undefined) {
33    surface.every(TICK_MS, () => {
34      const run = surface.state
35      if (!run || run.phase !== 'running') return
36      const next = step(run, width(), Math.random)
37      surface.setState(next)
38      if (next.phase === 'over') surface.post({ score: scoreOf(next) })
39    })
40    // Leaves the state defined, so the next call does not start a second timer.
41    surface.setState(newRun())
42  }
43  const run = surface.state ?? newRun()
44  surface.onKey(({ key }) => {
45    const now = surface.state ?? newRun()
46    if (isJump(key)) surface.setState(jump(now))
47    else if (isDuck(key)) surface.setState(duck(now))
48  })
49  const score = scoreOf(run)
50  const field = textRowsOf(pixelGridOf(run, width())).map((segs) =>
51    Text({
52      wrap: 'truncate-end',
53      children: segs.map((s) =>
54        s.fg || s.bg ? Text({ color: colorOf(s.fg, run), backgroundColor: colorOf(s.bg, run), children: [s.text] }) : s.text,
55      ),
56    }),
57  )
58  return Box({
59    flexDirection: 'column',
60    children: [
61      Box({ key: 'score', children: [Text({ children: [`Score ${score}   Best ${Math.max(props.best, score)}`] })] }),
62      Box({ key: 'field', flexDirection: 'column', children: field }),
63      Text({ color: 'inactive', wrap: 'truncate-end', children: ['▀'.repeat(width())] }),
64      Text({ color: run.phase === 'over' ? 'error' : undefined, dimColor: run.phase !== 'over', children: [HINTS[run.phase]] }),
65    ],
66  })
67}
68
hooks/gomoku.ts 190 lines
1import type { Gomoku } from '../types'
2
3export const SIZE = 15
4const CELLS = SIZE * SIZE
5const EMPTY = '.'
6// Seat 0 plays black and moves first.
7export const STONES = ['x', 'o'] as const
8
9export type View = { seq: number; snapshot: string | null; names: [string | null, string | null]; you: 0 | 1 | null }
10type Snap = { board: string; last: number | null }
11
12export const emptyBoard = () => EMPTY.repeat(CELLS)
13
14const CENTRE = Math.floor(CELLS / 2)
15
16export function newGame(code: string, seat: 0 | 1, names: Gomoku['names']): Gomoku {
17  return { code, seat, seq: 0, board: emptyBoard(), last: null, names, cursor: CENTRE, isSynced: true, isClosed: false, isSolo: false, note: null }
18}
19
20export const COMPUTER = 'Computer'
21
22// The player takes black and moves first.
23export const newSoloGame = (name: string): Gomoku => ({ ...newGame('', 0, [name, COMPUTER]), isSolo: true })
24
25const DIRS = [
26  [0, 1],
27  [1, 0],
28  [1, 1],
29  [1, -1],
30] as const
31
32// Five or more in a row through `at` wins: freestyle Gomoku.
33export function isFiveAt(board: string, at: number): boolean {
34  const stone = board[at]
35  if (!stone || stone === EMPTY) return false
36  const r0 = Math.floor(at / SIZE)
37  const c0 = at % SIZE
38  return DIRS.some(([dr, dc]) => {
39    let run = 1
40    for (const sign of [1, -1]) {
41      for (let r = r0 + dr * sign, c = c0 + dc * sign; r >= 0 && r < SIZE && c >= 0 && c < SIZE; r += dr * sign, c += dc * sign) {
42        if (board[r * SIZE + c] !== stone) break
43        run++
44      }
45    }
46    return run >= 5
47  })
48}
49
50export function winnerOf(g: Pick<Gomoku, 'board' | 'last'>): 0 | 1 | null {
51  if (g.last === null || !isFiveAt(g.board, g.last)) return null
52  return g.board[g.last] === STONES[0] ? 0 : 1
53}
54
55export const isOver = (g: Gomoku) => winnerOf(g) !== null || !g.board.includes(EMPTY)
56export const hasOpponent = (g: Gomoku) => g.names[1 - g.seat] !== null
57export const isMyTurn = (g: Gomoku) => !g.isClosed && !isOver(g) && hasOpponent(g) && g.seq % 2 === g.seat
58
59const putAt = (g: Gomoku, at: number, stone: string): Gomoku => ({
60  ...g,
61  board: g.board.slice(0, at) + stone + g.board.slice(at + 1),
62  last: at,
63  seq: g.seq + 1,
64  isSynced: false,
65  note: null,
66})
67
68export function place(g: Gomoku): Gomoku | null {
69  if (!isMyTurn(g) || g.board[g.cursor] !== EMPTY) return null
70  return putAt(g, g.cursor, STONES[g.seat])
71}
72
73// The run `stone` would make through `at` along one direction, and how many of its two
74// ends are open.
75function runAt(board: string, at: number, stone: string, dr: number, dc: number): { length: number; open: number } {
76  const r0 = Math.floor(at / SIZE)
77  const c0 = at % SIZE
78  let length = 1
79  let open = 0
80  for (const sign of [1, -1]) {
81    let r = r0 + dr * sign
82    let c = c0 + dc * sign
83    while (r >= 0 && r < SIZE && c >= 0 && c < SIZE && board[r * SIZE + c] === stone) {
84      length++
85      r += dr * sign
86      c += dc * sign
87    }
88    if (r >= 0 && r < SIZE && c >= 0 && c < SIZE && board[r * SIZE + c] === EMPTY) open++
89  }
90  return { length, open }
91}
92
93// What a run is worth: five wins outright, an open four cannot be stopped, and so down.
94function worth({ length, open }: { length: number; open: number }): number {
95  if (length >= 5) return 1_000_000
96  if (open === 0) return 0
97  const table: Record<number, [number, number]> = { 4: [10_000, 100_000], 3: [500, 5_000], 2: [50, 200], 1: [2, 10] }
98  return table[length]![open - 1]!
99}
100
101const valueAt = (board: string, at: number, stone: string) =>
102  DIRS.reduce((sum, [dr, dc]) => sum + worth(runAt(board, at, stone, dr, dc)), 0)
103
104// The computer's move: the empty cell near the stones that best builds its own lines or
105// breaks the player's, ties going to the cell nearest the centre.
106export function bestMove(board: string, stone: string): number {
107  const other = stone === STONES[0] ? STONES[1] : STONES[0]
108  const near = (k: number) => {
109    const r = Math.floor(k / SIZE)
110    const c = k % SIZE
111    for (let dr = -2; dr <= 2; dr++)
112      for (let dc = -2; dc <= 2; dc++) {
113        const rr = r + dr
114        const cc = c + dc
115        if (rr >= 0 && rr < SIZE && cc >= 0 && cc < SIZE && board[rr * SIZE + cc] !== EMPTY) return true
116      }
117    return false
118  }
119  const centreDistance = (k: number) =>
120    Math.abs(Math.floor(k / SIZE) - Math.floor(CENTRE / SIZE)) + Math.abs((k % SIZE) - (CENTRE % SIZE))
121  let best = -1
122  let bestScore = -1
123  for (let k = 0; k < CELLS; k++) {
124    if (board[k] !== EMPTY || !near(k)) continue
125    const score = valueAt(board, k, stone) * 1.1 + valueAt(board, k, other)
126    if (score > bestScore || (score === bestScore && centreDistance(k) < centreDistance(best))) {
127      best = k
128      bestScore = score
129    }
130  }
131  return best === -1 ? CENTRE : best
132}
133
134// The player's stone at the cursor, then the computer's answer unless the game is over.
135export function playSolo(g: Gomoku): Gomoku | null {
136  const mine = place(g)
137  if (!mine) return null
138  if (isOver(mine)) return { ...mine, isSynced: true }
139  const stone = STONES[1 - g.seat]!
140  return { ...putAt(mine, bestMove(mine.board, stone), stone), isSynced: true }
141}
142
143export function moveCursor(g: Gomoku, dir: 'up' | 'down' | 'left' | 'right'): Gomoku {
144  const r = Math.floor(g.cursor / SIZE)
145  const c = g.cursor % SIZE
146  const nr = Math.min(SIZE - 1, Math.max(0, r + (dir === 'down' ? 1 : dir === 'up' ? -1 : 0)))
147  const nc = Math.min(SIZE - 1, Math.max(0, c + (dir === 'right' ? 1 : dir === 'left' ? -1 : 0)))
148  return { ...g, cursor: nr * SIZE + nc }
149}
150
151export const snapshotOf = (g: Gomoku) => JSON.stringify({ board: g.board, last: g.last } satisfies Snap)
152
153function parseSnap(text: string | null): Snap | null {
154  try {
155    const s = JSON.parse(text ?? '') as Snap
156    const isBoard = typeof s.board === 'string' && s.board.length === CELLS && /^[.xo]+$/.test(s.board)
157    const isLast = s.last === null || (Number.isInteger(s.last) && s.last >= 0 && s.last < CELLS)
158    return isBoard && isLast ? s : null
159  } catch {
160    return null
161  }
162}
163
164// The one move from `before` to `after`: exactly one new `stone` on an empty cell, at `last`.
165function isOneMove(before: string, after: Snap, stone: string): boolean {
166  if (after.last === null || after.board[after.last] !== stone || before[after.last] !== EMPTY) return false
167  for (let k = 0; k < CELLS; k++) if (k !== after.last && after.board[k] !== before[k]) return false
168  return true
169}
170
171export type Step = { game: Gomoku; send: 'none' | 'move' }
172
173export const CLOSED_NOTE = 'The room closed after 15 minutes without a move.'
174
175// What the room says against what this client holds. A room one move behind missed this
176// client's last move and gets it again; a room one move ahead is checked against the rules;
177// a room that no longer seats this client has closed.
178export function reconcile(g: Gomoku, view: View): Step {
179  if (view.you === null) return { game: { ...g, isClosed: true, note: CLOSED_NOTE }, send: 'none' }
180  const named = { ...g, names: view.names }
181  if (view.seq === g.seq - 1 && !g.isSynced) return { game: named, send: 'move' }
182  if (view.seq < g.seq) return { game: { ...named, isClosed: true, note: CLOSED_NOTE }, send: 'none' }
183  if (view.seq === g.seq) return { game: { ...named, isSynced: true }, send: 'none' }
184  const snap = parseSnap(view.snapshot)
185  const mover = STONES[(view.seq - 1) % 2]!
186  const fits = snap && (view.seq > g.seq + 1 || isOneMove(g.board, snap, mover))
187  if (!snap || !fits) return { game: { ...named, note: 'Refused a move that breaks the rules.' }, send: 'none' }
188  return { game: { ...named, seq: view.seq, board: snap.board, last: snap.last, isSynced: true, note: null }, send: 'none' }
189}
190
hooks/mines.ts 93 lines
1import type { Mines } from '../types'
2
3export const ROWS = 9
4export const COLS = 9
5export const MINE_COUNT = 10
6
7export type Dir = 'up' | 'down' | 'left' | 'right'
8
9export function neighbors(i: number): number[] {
10  const r = Math.floor(i / COLS)
11  const c = i % COLS
12  const out: number[] = []
13  for (let dr = -1; dr <= 1; dr++)
14    for (let dc = -1; dc <= 1; dc++) {
15      const nr = r + dr
16      const nc = c + dc
17      if ((dr || dc) && nr >= 0 && nr < ROWS && nc >= 0 && nc < COLS) out.push(nr * COLS + nc)
18    }
19  return out
20}
21
22export function countAt(mines: boolean[], i: number): number {
23  return neighbors(i).filter((k) => mines[k]).length
24}
25
26export function newMines(): Mines {
27  const cells = ROWS * COLS
28  return {
29    mines: null,
30    revealed: Array(cells).fill(false),
31    flagged: Array(cells).fill(false),
32    cursor: Math.floor(ROWS / 2) * COLS + Math.floor(COLS / 2),
33    state: 'playing',
34    hit: null,
35  }
36}
37
38// The first dig and its neighbors stay clear, so the opening always shows some board.
39export function layMines(safe: number, rand: () => number): boolean[] {
40  const clear = new Set([safe, ...neighbors(safe)])
41  const open = [...Array(ROWS * COLS).keys()].filter((k) => !clear.has(k))
42  const mines = Array(ROWS * COLS).fill(false)
43  for (let n = 0; n < MINE_COUNT; n++) {
44    const [k] = open.splice(Math.floor(rand() * open.length), 1)
45    mines[k!] = true
46  }
47  return mines
48}
49
50export function moveCursor(g: Mines, dir: Dir): Mines {
51  const r = Math.floor(g.cursor / COLS)
52  const c = g.cursor % COLS
53  const nr = Math.min(ROWS - 1, Math.max(0, r + (dir === 'down' ? 1 : dir === 'up' ? -1 : 0)))
54  const nc = Math.min(COLS - 1, Math.max(0, c + (dir === 'right' ? 1 : dir === 'left' ? -1 : 0)))
55  return { ...g, cursor: nr * COLS + nc }
56}
57
58export function flag(g: Mines): Mines {
59  if (g.state !== 'playing' || g.revealed[g.cursor]) return g
60  const flagged = [...g.flagged]
61  flagged[g.cursor] = !flagged[g.cursor]
62  return { ...g, flagged }
63}
64
65function reveal(mines: boolean[], revealed: boolean[], flagged: boolean[], start: number[]) {
66  const queue = start.filter((k) => !revealed[k] && !flagged[k])
67  while (queue.length) {
68    const k = queue.pop()!
69    if (revealed[k]) continue
70    revealed[k] = true
71    if (!mines[k] && countAt(mines, k) === 0)
72      queue.push(...neighbors(k).filter((n) => !revealed[n] && !flagged[n]))
73  }
74}
75
76// Digging a shown number whose flags all sit around it digs the rest of its neighbors.
77export function dig(g: Mines, rand: () => number): Mines {
78  if (g.state !== 'playing' || g.flagged[g.cursor]) return g
79  const mines = g.mines ?? layMines(g.cursor, rand)
80  let start = [g.cursor]
81  if (g.revealed[g.cursor]) {
82    const around = neighbors(g.cursor)
83    if (around.filter((k) => g.flagged[k]).length !== countAt(mines, g.cursor)) return g
84    start = around
85  }
86  const revealed = [...g.revealed]
87  reveal(mines, revealed, g.flagged, start)
88  const hit = start.find((k) => mines[k] && revealed[k]) ?? null
89  if (hit !== null) return { ...g, mines, revealed, state: 'lost', hit }
90  const won = revealed.every((shown, k) => shown || mines[k])
91  return { ...g, mines, revealed, state: won ? 'won' : 'playing' }
92}
93
types/index.d.ts 37 lines
1// Row-major, 4 * 4 cells, 0 for empty.
2export type Board = number[]
3
4export type Game = { board: Board; score: number; hasWon: boolean; isOver: boolean }
5
6// Row-major cells; `mines` stays null until the first dig lays them around it.
7export type Mines = {
8  mines: boolean[] | null
9  revealed: boolean[]
10  flagged: boolean[]
11  cursor: number
12  state: 'playing' | 'won' | 'lost'
13  hit: number | null
14}
15
16// One Gomoku game as this client holds it; `seq` counts the moves made.
17export type Gomoku = {
18  code: string
19  seat: 0 | 1
20  seq: number
21  board: string
22  last: number | null
23  names: [string | null, string | null]
24  cursor: number
25  isSynced: boolean
26  isClosed: boolean
27  // Against the computer on this machine: no room, no relay.
28  isSolo: boolean
29  note: string | null
30}
31
32declare module 'claude-code' {
33  interface PluginState {
34    games: { game2048: Game | null; best2048: number; mines: Mines | null; bestDino: number; gomoku: Gomoku | null; gomokuMatch: { since: number } | null }
35  }
36}
37