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

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.
| Command | Game | Needs the network |
|---|---|---|
/2048 | 2048 | No |
/mines | Minesweeper, 9×9 with 10 mines | No |
/dino | Chrome's offline dino runner | No |
/gomoku solo | Gomoku against the computer | No |
/gomoku new, /gomoku join <code>, /gomoku match | Gomoku against a person | Yes, the relay |
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.
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.w a s d: a pane's hotkeys take letters and digits only, and the arrow keys walk the pane's buttons./theme. Every colour is one of Claude Code's theme colours./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.
| Key | Does |
|---|---|
w a s d | Slide up, left, down, right |
n | New game |
q | Close the pane |
The best score is kept across sessions.
/mines opens a 9×9 field with 10 mines. Dig every cell that is not a mine to win.
| Key | Does |
|---|---|
w a s d | Move the cursor, drawn inverse |
e | Dig the cell under the cursor |
f | Flag or unflag it |
n | New game |
q | Close the pane |
. hidden, F flag, * mine), because characters of ambiguous width shift the columns in a terminal set up for CJK text./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.
| Key | Does |
|---|---|
space, ↑ or w | Start, jump, or run again after a crash |
↓ or s | Duck on the ground; in the air, drop fast |
| Esc | Hand the keys back to the prompt |
q | Close the pane, while the pane rather than the game holds the keys |
▀ ▄ █). A terminal set to draw those ambiguous-width characters two columns wide breaks the picture.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.
| Key | Does |
|---|---|
w a s d | Move the cursor, drawn inverse |
e | Place a stone on your turn |
q | Close 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).
/gomoku soloYou 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.
/gomoku new and /gomoku join <code>/gomoku new. It opens a room, makes you black, and prints a line to send your friend: /gomoku join <code>./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.
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.
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.
Set these in /config, under the games plugin.
| Option | Meaning |
|---|---|
relay_url | The relay for games against a person. Empty means the shared relay. |
player_name | The name your opponent sees. Empty means your login name. |
hooks/register.ts 26 lines1import 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}
26hooks/pane-2048.ts 126 lines1import { 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}
126hooks/pane-dino.ts 68 lines1import { 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}
68hooks/pane-gomoku.ts 369 lines1import { 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}
369hooks/pane-mines.ts 104 lines1import { 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}
104hooks/game2048.ts 77 lines1import 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}
77hooks/dino.ts 192 lines1// 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}
192hooks/dino-client.ts 68 lines1import 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}
68hooks/gomoku.ts 190 lines1import 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}
190hooks/mines.ts 93 lines1import 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}
93types/index.d.ts 37 lines1// 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