SLOPSHOPPER

xo

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.

newpanecommandtoasttimer
v0.1.0MITupdated 2026-10-13MDmubarak786/claude-mods/mods/xo
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · xo
│ ┃ XO ✕ › fix the failing auth test and add an audit log call │ ┃ XO you X · Claude O level normal you 0 · │ ┃ Your move: press a digit, or click a square. ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ [ 1 ] [ 2 ] [ 3 ] ⏺ Update(src/auth.ts) │ ┃ [ 4 ] [ 5 ] [ 6 ] ⎿ Added 2 lines, removed 1 line │ ┃ [ 7 ] [ 8 ] [ 9 ] ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ How to play │ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ 1. Press the digit shown in a square to │ │ ┃ │ play there, or click it. You are X. │ ✻ Worked for 42s · done 4:20 PM │ ┃ │ 2. Claude answers after a moment. Three │ │ ┃ │ in a row, column, or diagonal wins. │ › /xo │ ┃ │ 3. u takes your last move back, s swaps │ │ ┃ │ sides, l changes the level (easy, │ │ ┃ │ normal, hard). │ │ ┃ │ Keys need the pane focused: click it if │ │ ┃ │ nothing happens. h hides this. │ │ ┃ ╰──────────────────────────────────────────╯ │ ┃ │ ┃ n: New game u: Undo s: Play O l: Level │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · XO
XO you X · Claude O level normal you 0 · Claude 0 · draws Your move: press a digit, or click a square. [ 1 ] [ 2 ] [ 3 ] [ 4 ] [ 5 ] [ 6 ] [ 7 ] [ 8 ] [ 9 ] ╭──────────────────────────────────────────────────────────╮ │ How to play │ │ 1. Press the digit shown in a square to play there, or │ │ click it. You are X. │ │ 2. Claude answers after a moment. Three in a row, │ │ column, or diagonal wins. │ │ 3. u takes your last move back, s swaps sides, l changes │ │ the level (easy, normal, hard). │ │ Keys need the pane focused: click it if nothing happens. │ │ h hides this. │ ╰──────────────────────────────────────────────────────────╯ n: New game u: Undo s: Play O l: Level: normal h: Hi
README

xo

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.

Tic-tac-toe against Claude in a pane: you play the corners and win on the third column.

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.

Install

/plugin marketplace add MDmubarak786/claude-mods
/plugin install xo@modhub

Try it for one session without installing:

claude --plugin-dir ./mods/xo

How to play

  1. Run /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.
  2. Press the digit of a square, 1 to 9 in reading order, or click it. A taken square just gets a toast.
  3. Claude answers after half a second. Three in a row, column, or diagonal wins; a full board is a draw. A toast and the status line say how it ended.
  4. n starts a new game. The loser starts the next one; after a draw, the other side does.
  5. u takes your last move back, together with Claude's reply. s swaps your side and starts a new game. l cycles the level.
  6. Esc closes the pane. The game and the score are kept, and a reply Claude owed you comes when the pane opens again.

The keys work while the pane has the focus. If pressing a digit types into the prompt instead, click the pane first.

CommandWhat it does
/xoOpen the pane. Where no pane can be drawn, print the board and whose move it is.
/xo newStart a new game. The record is kept.
/xo level easy, normal, or hardChange the level. Hard never loses: a draw is the best you can get.
/xo as x or /xo as oChoose your mark. Starts a new game.
/xo statsGames, your wins, Claude's wins, draws.

What it touches

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.
  • No tool calls are observed, no files, processes, model calls, or network.

Tested with

  • Claude Code 2.1.295, 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.
  • Played in the Desktop app, 2.1.293, through hot reloading.

Limitations

  • One game at a time per machine. Two sessions playing at once overwrite each other's moves; the last write wins.
  • Claude's reply is a timer that lives in the session that scheduled it. Close the pane before the half second is up and the reply waits for the next /xo.
  • Hard is perfect, so against it you can only draw. Normal is the level to beat.

License

MIT, see the repository root.

Source 3 files
hooks/register.ts 297 lines
1// 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}
297
hooks/game.ts 186 lines
1// 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}
186
types/index.d.ts 29 lines
1export 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