Tic-tac-toe against Claude, in a pane. Press a digit to play a square; Claude answers in half a second at three levels, from beatable to perfect. Zero tokens.

Tic-tac-toe against Claude, in a pane beside the conversation. Press a square's digit to play there; Claude answers half a second later. Three levels: easy plays at random, normal blocks and takes wins but can be beaten, hard plays perfect minimax and never loses.
It's a game you play, not a watcher: Claude's moves come from a few lines of rules in the mod, so it never calls a model, never reads what Claude is doing, and costs no tokens. Good for the thirty seconds while a long tool call runs.

Recorded in a terminal. The Desktop app draws the same pane, with each key shown as a badge.
XO you X · Claude O level normal you 2 · Claude 1 · draws 3
Claude played 5. Your move.
X 2 3
4 O 6
7 8 9
n: New game u: Undo s: Play O l: Level: normal h: Help Esc closes
The squares are colored, which the text above can't show: your marks on green, Claude's on Claude's color, the winning three on the accent color. On the Desktop app each empty square is a button with its digit as a key badge; in a terminal the digit is the label.
/plugin marketplace add MDmubarak786/claude-mods
/plugin install xo@modhub
Try it for one session without installing:
claude --plugin-dir ./mods/xo
/xo. The pane opens with an empty board and you are X. A fresh board shows a short how-to until you hide it with h, which also shows it again.The keys work while the pane has the focus. If pressing a digit types into the prompt instead, click the pane first.
| Command | What it does |
|---|---|
/xo | Open the pane. Where no pane can be drawn, print the board and whose move it is. |
/xo new | Start a new game. The record is kept. |
/xo level easy, normal, or hard | Change the level. Hard never loses: a draw is the best you can get. |
/xo as x or /xo as o | Choose your mark. Starts a new game. |
/xo stats | Games, your wins, Claude's wins, draws. |
From claude plugin validate ./mods/xo:
hooks: session.start, classic.SessionStart{source=clear|resume|fork}, command.run{command=xo}, ui.close{id=xo}, ui.render{component=Pane}
calls: $.clock.after (via scheduleClaude), $.clock.now (via load, startNew), $.command.register, $.session.surfaces, $.state.get, $.state.set, $.store.get (via load), $.store.set (via save), $.ui.invalidate, $.ui.log, $.ui.open, $.ui.resolve, $.ui.toast
$.store keeps the current game, your mark, the level, and the record, so a game survives a restart. If the store can't be read at start, the mod plays in memory and writes nothing over the saved record.$.clock seeds each game and delays Claude's reply by half a second. The pending reply is cancelled when the pane closes, when you undo, and when a new game starts.ui.close is observed only to cancel that reply.claude plugin validate --strict and claude plugin test passing: the rules, every possible game against the hard level as X and as O to show it never loses, the normal level blocking and taking wins, mounted pane tests for a move, Claude's reply on the mock clock, a win, undo, swap and level from the footer, the commands, an unreadable store, and a narrow pane. The test kit can't fire ui.close, so the reply being cancelled on close is covered by the undo and new-game paths./xo.MIT, see the repository root.
hooks/register.ts 297 lines1// xo: tic-tac-toe against Claude, in a pane.
2//
3// /xo open the game
4// /xo new start a new game
5// /xo level hard easy, normal (default), or hard, which never loses
6// /xo as o play O instead of X (starts a new game)
7// /xo stats games, wins, losses, draws
8//
9// In the pane: press a square's digit, or Tab or click to it and press Enter.
10// Claude answers half a second later. n starts a new game, u takes your last
11// move back, s swaps your side, l cycles the level, h shows the how-to. Claude's
12// moves come from the rules in game.ts: nothing here calls a model, reads what
13// Claude is doing, or leaves the machine.
14
15import { atom, read, update } from 'claude-code'
16import type { Game, Level, Mark, Stats } from '../types'
17import { DEFAULT_LEVEL, LEVELS, claudeMark, claudeMove, isGame, isLevel, newGame, nextStarter, play, undo } from './game'
18
19const PANE = 'xo'
20const THINK_MS = 500
21const EMPTY_STATS: Stats = { games: 0, wins: 0, losses: 0, draws: 0 }
22const EMPTY: Game = { seed: 0, board: new Array(9).fill(null), you: 'X', turn: 'X', moves: [], winner: null, winningLine: null, level: DEFAULT_LEVEL, starter: 'you', stats: EMPTY_STATS }
23const game = atom({ plugin: 'xo', key: 'game' } as const, EMPTY)
24
25// Claude's pending reply, while the pane is open. Dies with the module on reload.
26let thinking: { cancel: () => void } | null = null
27// The how-to block: shown on a fresh board until h hides it, for the session.
28let help: boolean | null = null
29// False after the store could not be read: then nothing is written over the
30// saved record until a later load reads it.
31let storeOk = true
32
33async function save($, g: Game) {
34 if (!storeOk) return
35 try {
36 await $.store.set('game', g)
37 } catch {
38 // The drawing still updates; the store catches up on the next change.
39 }
40}
41
42// Apply a change through update's read-modify-write, so Claude's reply and a
43// press landing together never overwrite each other, then mirror it to the store.
44async function change($, fn: (cur: Game) => Game): Promise<Game> {
45 let out: Game | null = null
46 await update($, game, (cur) => {
47 out = fn(cur)
48 return out
49 })
50 if (out) await save($, out)
51 return out ?? (await read($, game))
52}
53
54async function load($) {
55 let saved: unknown = undefined
56 let readable = true
57 try {
58 saved = await $.store.get('game')
59 } catch {
60 readable = false
61 }
62 storeOk = readable
63 if (isGame(saved)) {
64 await update($, game, () => saved as Game)
65 return
66 }
67 if (!readable) {
68 const cur = await read($, game)
69 if (!cur.seed) await update($, game, () => newGame(Date.now(), EMPTY_STATS, DEFAULT_LEVEL, 'X', 'you'))
70 $.ui.log('xo could not read its saved game; playing without saving')
71 return
72 }
73 const fresh = newGame(await $.clock.now(), EMPTY_STATS, DEFAULT_LEVEL, 'X', 'you')
74 await update($, game, () => fresh)
75 await save($, fresh)
76}
77
78function stopThinking() {
79 if (thinking) thinking.cancel()
80 thinking = null
81}
82
83function isClaudesTurn(g: Game): boolean {
84 return !g.winner && g.seed !== 0 && g.turn === claudeMark(g)
85}
86
87function endToast($, g: Game) {
88 if (g.winner === 'you') $.ui.toast('You win! New game: n', { timeoutMs: 5000 })
89 else if (g.winner === 'claude') $.ui.toast('Claude wins. New game: n', { timeoutMs: 5000 })
90 else if (g.winner === 'draw') $.ui.toast("A draw. New game: n", { timeoutMs: 5000 })
91}
92
93// Claude replies after a short pause, if it is still Claude's move by then.
94function scheduleClaude($, g: Game) {
95 stopThinking()
96 if (!isClaudesTurn(g)) return
97 thinking = $.clock.after(THINK_MS, async () => {
98 thinking = null
99 try {
100 const after = await change($, (cur) => {
101 if (!isClaudesTurn(cur)) return cur
102 return play(cur, claudeMove(cur), cur.turn).game
103 })
104 if (after.winner) endToast($, after)
105 } catch {
106 // A failed reply is skipped; the next press or open tries again.
107 }
108 })
109}
110
111async function startNew($, patch: Partial<Pick<Game, 'you' | 'level'>> = {}) {
112 stopThinking()
113 const now = await $.clock.now()
114 const g = await change($, (cur) => {
115 const you = patch.you ?? cur.you
116 const level = patch.level ?? cur.level
117 const starter = cur.seed ? nextStarter(cur) : 'you'
118 return newGame(now, cur.stats, level, you, starter)
119 })
120 return g
121}
122
123function summary(g: Game): string {
124 if (!g.seed) return 'No game yet.'
125 const state = g.winner === 'you' ? 'You won.' : g.winner === 'claude' ? 'Claude won.' : g.winner === 'draw' ? 'A draw.' : g.turn === g.you ? 'Your move.' : "Claude's move."
126 const row = (r: number) => g.board.slice(r * 3, r * 3 + 3).map((m, c) => m ?? String(r * 3 + c + 1)).join(' ')
127 return 'XO: ' + [0, 1, 2].map(row).join(' / ') + '. You are ' + g.you + ', level ' + g.level + '. ' + state + ' /xo opens the pane.'
128}
129
130export function register(on) {
131 on('session.start', async ($, e, next) => {
132 help = null
133 try {
134 await load($)
135 } catch (error) {
136 $.ui.log('xo could not load its game: ' + error)
137 }
138 try {
139 await $.command.register({ name: 'xo', description: 'Play tic-tac-toe against Claude', argumentHint: '[new | level easy|normal|hard | as x|o | stats]', immediate: true })
140 } catch (error) {
141 $.ui.log('could not register /xo: ' + error)
142 }
143 return next(e)
144 })
145
146 // /clear and /resume reset $.state; the game lives on in the store.
147 on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
148 await load($)
149 return next(e)
150 }).catch(async ($, e, next) => next(e))
151
152 on('command.run', { command: 'xo' }, async ($, e) => {
153 const args = e.args.trim().toLowerCase()
154 if (args === 'new') {
155 const g = await startNew($)
156 scheduleClaude($, g)
157 return { text: 'New game. ' + summary(g) }
158 }
159 const level = /^level\s+(\w+)$/.exec(args)
160 if (level) {
161 if (!isLevel(level[1])) return { text: 'Levels: easy, normal, hard.' }
162 const g = await change($, (cur) => ({ ...cur, level: level[1] as Level }))
163 return { text: 'Level ' + g.level + (g.level === 'hard' ? ': Claude plays perfectly, so a draw is the best you can get.' : '.') }
164 }
165 const side = /^as\s+([xo])$/.exec(args)
166 if (side) {
167 const you = side[1].toUpperCase() as Mark
168 const g = await startNew($, { you })
169 scheduleClaude($, g)
170 return { text: 'You are ' + you + '. New game. ' + summary(g) }
171 }
172 if (args === 'stats') {
173 const s = (await read($, game)).stats
174 return { text: 'Games ' + s.games + ' · you ' + s.wins + ' · Claude ' + s.losses + ' · draws ' + s.draws }
175 }
176 if (args) return { text: 'Usage: /xo, /xo new, /xo level easy|normal|hard, /xo as x|o, or /xo stats' }
177 // Under claude -p nothing draws, and ui.open still reports the pane as placed:
178 // the surfaces list is the reliable sign.
179 let surfaces = []
180 try {
181 surfaces = await $.session.surfaces()
182 } catch {
183 surfaces = []
184 }
185 if (!surfaces.length) return { text: summary(await read($, game)) }
186 const placed = await $.ui.open({ id: PANE, title: 'XO', focus: true, closeOnEscape: true, rows: 24 })
187 if (!placed.isPlaced) return { text: summary(await read($, game)) }
188 scheduleClaude($, await read($, game))
189 return {}
190 }).catch(async () => ({ text: 'xo: the command failed.' }))
191
192 // Closing the pane stops a pending reply; opening it again schedules one.
193 on('ui.close', { id: PANE }, async ($, e, next) => {
194 stopThinking()
195 return next(e)
196 }).catch(async ($, e, next) => next(e))
197
198 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
199 if (e.requestId !== PANE) return next(e)
200 const g = await read($, game)
201 const { Box, Text, Button } = $.ui.resolve(e)
202 if (!g.seed) return Text({ children: ['No game loaded. /xo new starts one.'] })
203 const columns = e.props.bodyColumns || 60
204 const wide = columns >= 50
205 const me = claudeMark(g)
206 const yourTurn = !g.winner && g.turn === g.you
207 const last = g.moves.length ? g.moves[g.moves.length - 1] : -1
208 const showHelp = help ?? (wide && g.moves.length === 0)
209
210 const status = g.winner === 'you' ? { text: 'You win! Press n for a new game.', color: 'success', bold: true }
211 : g.winner === 'claude' ? { text: 'Claude wins. Press n for a new game.', color: 'error', bold: true }
212 : g.winner === 'draw' ? { text: 'A draw. Press n for a new game.', color: 'warning', bold: true }
213 : !yourTurn ? { text: 'Claude is thinking…', color: 'claude' }
214 : g.moves.length === 0 ? { text: 'Your move: press a digit, or click a square.', color: 'suggestion' }
215 : last >= 0 && g.board[last] === me ? { text: 'Claude played ' + (last + 1) + '. Your move.', color: 'suggestion' }
216 : { text: 'Your move.', color: 'suggestion' }
217
218 const header = Box({ flexDirection: 'column', children: [
219 Box({ flexDirection: 'row', columnGap: 2, flexWrap: 'wrap', children: [
220 Text({ bold: true, children: ['XO'] }),
221 Text({ children: ['you ', Text({ bold: true, color: 'success', children: [g.you] }), ' · Claude ', Text({ bold: true, color: 'claude', children: [me] })] }),
222 Text({ children: ['level ' + g.level] }),
223 Text({ dimColor: true, children: ['you ' + g.stats.wins + ' · Claude ' + g.stats.losses + ' · draws ' + g.stats.draws] }),
224 ] }),
225 Text({ color: status.color, bold: status.bold, wrap: 'wrap', children: [status.text] }),
226 ...(g.level === 'hard' && !g.winner ? [Text({ dimColor: true, children: ['Claude plays perfectly on hard: a draw is the best you can get.'] })] : []),
227 ] })
228
229 // Every square is a fixed-width Box so the grid lines up on any surface; the
230 // Box's color says whose mark it is, since a Button takes no color of its own.
231 // An empty square's digit is its hotkey. The desktop draws a hotkey as a key
232 // badge beside the label, so there the label is left blank rather than
233 // doubling the digit; a taken square has no hotkey and shows its mark.
234 const desktop = e.surface === 'desktop'
235 const square = (i: number) => {
236 const m = g.board[i]
237 const lit = g.winningLine?.includes(i)
238 const backgroundColor = lit ? 'suggestion' : m === g.you ? 'success' : m ? 'claude' : undefined
239 const label = m ?? (desktop ? ' ' : String(i + 1))
240 return Box({ width: 6, justifyContent: 'center', backgroundColor, children: [
241 Button({ key: 'sq-' + i, label, hotkey: m ? undefined : String(i + 1), dimColor: !m, onPress: async () => {
242 let error: string | undefined
243 const after = await change($, (cur) => {
244 const r = play(cur, i, cur.you)
245 error = r.error
246 return r.game
247 })
248 if (error) {
249 $.ui.toast(error)
250 return
251 }
252 if (after.winner) endToast($, after)
253 else scheduleClaude($, after)
254 } }),
255 ] })
256 }
257 const grid = Box({ flexDirection: 'column', children: [0, 1, 2].map((r) => Box({ flexDirection: 'row', columnGap: 1, children: [0, 1, 2].map((c) => square(r * 3 + c)) })) })
258
259 const howTo = Box({ flexDirection: 'column', borderStyle: 'round', borderColor: 'suggestion', paddingX: 1, children: [
260 Text({ bold: true, children: ['How to play'] }),
261 Text({ wrap: 'wrap', children: ['1. Press the digit shown in a square to play there, or click it. You are ' + g.you + '.'] }),
262 Text({ wrap: 'wrap', children: ['2. Claude answers after a moment. Three in a row, column, or diagonal wins.'] }),
263 Text({ wrap: 'wrap', children: ['3. u takes your last move back, s swaps sides, l changes the level (easy, normal, hard).'] }),
264 Text({ dimColor: true, wrap: 'wrap', children: ['Keys need the pane focused: click it if nothing happens. h hides this.'] }),
265 ] })
266
267 const footer = Box({ flexDirection: 'row', columnGap: 3, flexWrap: 'wrap', children: [
268 Button({ key: 'new', label: 'New game', hotkey: 'n', plain: true, onPress: async () => scheduleClaude($, await startNew($)) }),
269 Button({ key: 'undo', label: 'Undo', hotkey: 'u', plain: true, onPress: async () => {
270 stopThinking()
271 let error: string | undefined
272 const after = await change($, (cur) => {
273 const r = undo(cur)
274 error = r.error
275 return r.game
276 })
277 if (error) {
278 $.ui.toast(error)
279 // Nothing was taken back: if Claude was about to move, it still is.
280 scheduleClaude($, after)
281 }
282 } }),
283 Button({ key: 'swap', label: 'Play ' + me, hotkey: 's', plain: true, onPress: async () => scheduleClaude($, await startNew($, { you: me })) }),
284 Button({ key: 'level', label: 'Level: ' + g.level, hotkey: 'l', plain: true, onPress: async () => {
285 await change($, (cur) => ({ ...cur, level: LEVELS[(LEVELS.indexOf(cur.level) + 1) % LEVELS.length] }))
286 } }),
287 Button({ key: 'help', label: showHelp ? 'Hide help' : 'Help', hotkey: 'h', plain: true, dimColor: true, onPress: () => {
288 help = !showHelp
289 $.ui.invalidate('ui.render')
290 } }),
291 Text({ dimColor: true, children: ['Esc closes'] }),
292 ] })
293
294 return Box({ flexDirection: 'column', rowGap: 1, children: showHelp ? [header, grid, howTo, footer] : [header, grid, footer] })
295 })
296}
297hooks/game.ts 186 lines1// The rules of tic-tac-toe and the three opponents, pure: no `$` here, so the
2// tests and the hooks module share one implementation.
3
4import type { Game, Level, Mark, Stats } from '../types'
5
6export const LEVELS: Level[] = ['easy', 'normal', 'hard']
7export const DEFAULT_LEVEL: Level = 'normal'
8
9export const LINES: number[][] = [
10 [0, 1, 2], [3, 4, 5], [6, 7, 8],
11 [0, 3, 6], [1, 4, 7], [2, 5, 8],
12 [0, 4, 8], [2, 4, 6],
13]
14
15function hash(s: string): number {
16 let h = 2166136261
17 for (let i = 0; i < s.length; i++) {
18 h ^= s.charCodeAt(i)
19 h = Math.imul(h, 16777619)
20 }
21 return h >>> 0
22}
23
24function rng(seed: number): () => number {
25 let a = seed
26 return () => {
27 a = (a + 0x6d2b79f5) | 0
28 let t = Math.imul(a ^ (a >>> 15), 1 | a)
29 t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
30 return ((t ^ (t >>> 14)) >>> 0) / 4294967296
31 }
32}
33
34export function other(m: Mark): Mark {
35 return m === 'X' ? 'O' : 'X'
36}
37
38export function claudeMark(g: Game): Mark {
39 return other(g.you)
40}
41
42export function winnerOf(board: (Mark | null)[]): { mark: Mark; line: number[] } | null {
43 for (const line of LINES) {
44 const [a, b, c] = line
45 if (board[a] && board[a] === board[b] && board[a] === board[c]) return { mark: board[a] as Mark, line }
46 }
47 return null
48}
49
50export function isFull(board: (Mark | null)[]): boolean {
51 return board.every(Boolean)
52}
53
54export function empties(board: (Mark | null)[]): number[] {
55 return board.map((m, i) => (m ? -1 : i)).filter((i) => i >= 0)
56}
57
58export function newGame(seed: number, stats: Stats, level: Level, you: Mark, starter: 'you' | 'claude'): Game {
59 return { seed, board: new Array(9).fill(null), you, turn: starter === 'you' ? you : other(you), moves: [], winner: null, winningLine: null, level, starter, stats }
60}
61
62export type MoveResult = { game: Game; error?: string }
63
64// Play `mark` at `pos`. The caller says whose move it is; the rules check it.
65export function play(g: Game, pos: number, mark: Mark): MoveResult {
66 if (g.winner) return { game: g, error: 'The game is over. Press n for a new one.' }
67 if (mark !== g.turn) return { game: g, error: mark === g.you ? "It's Claude's move." : "It's your move." }
68 if (!Number.isInteger(pos) || pos < 0 || pos > 8) return { game: g, error: 'Squares are 1 to 9.' }
69 if (g.board[pos]) return { game: g, error: 'Square ' + (pos + 1) + ' is taken.' }
70 const board = g.board.slice()
71 board[pos] = mark
72 const moves = [...g.moves, pos]
73 const won = winnerOf(board)
74 if (won) {
75 const winner = won.mark === g.you ? 'you' : 'claude'
76 const stats = { ...g.stats, games: g.stats.games + 1, wins: g.stats.wins + (winner === 'you' ? 1 : 0), losses: g.stats.losses + (winner === 'claude' ? 1 : 0) }
77 return { game: { ...g, board, moves, turn: other(mark), winner, winningLine: won.line, stats } }
78 }
79 if (isFull(board)) {
80 return { game: { ...g, board, moves, turn: other(mark), winner: 'draw', winningLine: null, stats: { ...g.stats, games: g.stats.games + 1, draws: g.stats.draws + 1 } } }
81 }
82 return { game: { ...g, board, moves, turn: other(mark) } }
83}
84
85// Take back your last move and everything after it. Only while the game is on.
86export function undo(g: Game): MoveResult {
87 if (g.winner) return { game: g, error: 'The game is over. Press n for a new one.' }
88 const yours = g.moves.map((pos, i) => (g.board[pos] === g.you ? i : -1)).filter((i) => i >= 0)
89 if (!yours.length) return { game: g, error: 'Nothing of yours to take back yet.' }
90 const moves = g.moves.slice(0, yours[yours.length - 1])
91 const board: (Mark | null)[] = new Array(9).fill(null)
92 // Replay to find each square's mark: the starter's mark goes first and they alternate.
93 let mark: Mark = g.starter === 'you' ? g.you : other(g.you)
94 for (const pos of moves) {
95 board[pos] = mark
96 mark = other(mark)
97 }
98 return { game: { ...g, board, moves, turn: g.you } }
99}
100
101// Who starts next: the loser; after a draw, whoever didn't start this one.
102export function nextStarter(g: Game): 'you' | 'claude' {
103 if (g.winner === 'you') return 'claude'
104 if (g.winner === 'claude') return 'you'
105 return g.starter === 'you' ? 'claude' : 'you'
106}
107
108// Minimax from `mark`'s point of view, in moves: a quicker win and a slower loss
109// score higher, so a perfect opponent finishes and never gives a line away.
110function score(board: (Mark | null)[], me: Mark, toMove: Mark): number {
111 const won = winnerOf(board)
112 if (won) return won.mark === me ? empties(board).length + 1 : -(empties(board).length + 1)
113 const free = empties(board)
114 if (!free.length) return 0
115 let best = toMove === me ? -Infinity : Infinity
116 for (const pos of free) {
117 board[pos] = toMove
118 const s = score(board, me, other(toMove))
119 board[pos] = null
120 best = toMove === me ? Math.max(best, s) : Math.min(best, s)
121 }
122 return best
123}
124
125function winningMove(board: (Mark | null)[], mark: Mark): number | null {
126 for (const pos of empties(board)) {
127 board[pos] = mark
128 const won = !!winnerOf(board)
129 board[pos] = null
130 if (won) return pos
131 }
132 return null
133}
134
135function pick<T>(list: T[], r: () => number): T {
136 return list[Math.floor(r() * list.length)]
137}
138
139// Claude's square for the position, by level. Deterministic from the game's
140// seed and the move number, so a game replays the same way.
141export function claudeMove(g: Game): number {
142 const me = claudeMark(g)
143 const board = g.board.slice()
144 const free = empties(board)
145 if (!free.length) return -1
146 const r = rng(hash(g.seed + ':move:' + g.moves.length))
147 if (g.level === 'easy') return pick(free, r)
148 const win = winningMove(board, me)
149 if (win !== null) return win
150 const block = winningMove(board, g.you)
151 if (block !== null) return block
152 if (g.level === 'normal') {
153 if (!board[4]) return 4
154 const corners = [0, 2, 6, 8].filter((p) => !board[p])
155 return corners.length ? pick(corners, r) : pick(free, r)
156 }
157 let best = -Infinity
158 let bestMoves: number[] = []
159 for (const pos of free) {
160 board[pos] = me
161 const s = score(board, me, g.you)
162 board[pos] = null
163 if (s > best) {
164 best = s
165 bestMoves = [pos]
166 } else if (s === best) bestMoves.push(pos)
167 }
168 return pick(bestMoves, r)
169}
170
171export function isLevel(x: unknown): x is Level {
172 return x === 'easy' || x === 'normal' || x === 'hard'
173}
174
175export function isGame(x: unknown): x is Game {
176 if (!x || typeof x !== 'object') return false
177 const g = x as Record<string, unknown>
178 const mark = (m: unknown) => m === 'X' || m === 'O'
179 const stats = g.stats as Record<string, unknown> | undefined
180 return typeof g.seed === 'number' && Array.isArray(g.board) && g.board.length === 9 && g.board.every((m) => m === null || mark(m)) &&
181 mark(g.you) && mark(g.turn) && Array.isArray(g.moves) && g.moves.every((p) => Number.isInteger(p) && p >= 0 && p <= 8) &&
182 (g.winner === null || g.winner === 'you' || g.winner === 'claude' || g.winner === 'draw') && isLevel(g.level) &&
183 (g.starter === 'you' || g.starter === 'claude') && !!stats && typeof stats === 'object' &&
184 ['games', 'wins', 'losses', 'draws'].every((k) => Number.isInteger(stats[k]))
185}
186types/index.d.ts 29 lines1export type Mark = 'X' | 'O'
2export type Level = 'easy' | 'normal' | 'hard'
3export type Stats = { games: number; wins: number; losses: number; draws: number }
4export type Game = {
5 /** Seed of this game's opponent choices; 0 until a game exists. */
6 seed: number
7 /** The nine squares in reading order; null is empty. */
8 board: (Mark | null)[]
9 /** Which mark you play. Kept across games. */
10 you: Mark
11 /** Whose mark moves next. */
12 turn: Mark
13 /** The squares played so far, in order. */
14 moves: number[]
15 winner: 'you' | 'claude' | 'draw' | null
16 /** The three squares of the winning line. */
17 winningLine: number[] | null
18 level: Level
19 /** Who moved first this game. The loser starts the next one. */
20 starter: 'you' | 'claude'
21 stats: Stats
22}
23
24declare module 'claude-code' {
25 interface PluginState {
26 xo: { game: Game }
27 }
28}
29