SLOPSHOPPER

connect-four

Connect Four against Claude, in a pane. Press a column's digit to drop a disc; Claude answers in half a second at three levels, from beatable to a seven-move…

newpanecommandtoasttimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · connect-four
│ ┃ Connect Four ✕ › fix the failing auth test and add an audit log call │ ┃ CONNECT FOUR you X · Claude O level normal │ ┃ Your move: press a column's digit, or click ⏺ Read(src/auth.ts) │ ┃ it. ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ [ 1 ][ 2 ][ 3 ][ 4 ][ 5 ][ 6 ][ 7 ] ⎿ Added 2 lines, removed 1 line │ ┃ . . . . . . . ⏺ Bash(bun test) │ ┃ . . . . . . . ⎿ 3 pass, 1 fail │ ┃ . . . . . . . │ ┃ . . . . . . . ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ . . . . . . . │ ┃ . . . . . . . ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ ╭──────────────────────────────────────────╮ › /connect-four │ ┃ │ How to play │ │ ┃ │ 1. Press a column's digit, 1 to 7, or │ │ ┃ │ click it: your disc drops to the lowest │ │ ┃ │ free cell. You are X. │ │ ┃ │ 2. Claude answers after a moment. Four │ │ ┃ │ 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. │ │ ┃ ╰──────────────────────────────────────────╯ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Connect Four
CONNECT FOUR you X · Claude O level normal you 0 · Claude Your move: press a column's digit, or click it. [ 1 ][ 2 ][ 3 ][ 4 ][ 5 ][ 6 ][ 7 ] . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . ╭──────────────────────────────────────────────────────────╮ │ How to play │ │ 1. Press a column's digit, 1 to 7, or click it: your │ │ disc drops to the lowest free cell. You are X. │ │ 2. Claude answers after a moment. Four 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

connect-four

Connect Four against Claude, in a pane beside the conversation. Press a column's digit and your disc drops to the lowest free cell; Claude answers half a second later. Three levels: easy drops at random, normal takes wins, blocks threats and won't hand you a win on top of its disc, hard searches seven moves ahead.

It's a game you play, not a watcher: Claude's moves come from the rules in the mod, so it never calls a model, never reads what Claude is doing, and costs no tokens.

Connect Four against Claude in a pane: four drops and a win along the bottom row.

Recorded in a terminal. The Desktop app draws the same pane, with each key shown as a badge.

CONNECT FOUR  you X · Claude O  level normal  you 1 · Claude 0 · draws 0
Claude dropped in column 4. Your move.

  1    2    3    4    5    6    7
  .    .    .    .    .    .    .
  .    .    .    .    .    .    .
  .    .    .    .    .    .    .
  .    .    .    O    .    .    .
  .    .    X    O    .    .    .
  .    X    X    O    X    O    .

n: New game   u: Undo   s: Play O   l: Level: normal   h: Help   Esc closes

The cells are colored, which the text above can't show: your discs on green, Claude's on Claude's color, the winning four on the accent color, the disc just dropped in bold. On the Desktop app the column buttons show their digit as a key badge; in a terminal the digit is the label.

Install

/plugin marketplace add MDmubarak786/claude-mods
/plugin install connect-four@modhub

Try it for one session without installing:

claude --plugin-dir ./mods/connect-four

How to play

  1. Run /connect-four. 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 a column's digit, 1 to 7, or click the button above it. Your disc drops to the lowest free cell. A full column loses its key.
  3. Claude answers after half a second. Four 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
/connect-fourOpen the pane. Where no pane can be drawn, print the board and whose move it is.
/connect-four newStart a new game. The record is kept.
/connect-four level easy, normal, or hardChange the level.
/connect-four as x or /connect-four as oChoose your disc. Starts a new game.
/connect-four statsGames, your wins, Claude's wins, draws.

What it touches

From claude plugin validate ./mods/connect-four:

hooks: session.start, classic.SessionStart{source=clear|resume|fork}, command.run{command=connect-four}, ui.close{id=connect-four}, 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 disc, 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: stacking and every direction of win, a drawn board, undo, the normal level taking wins, blocking and refusing a trap, hard beating easy and never losing to normal across seeds and starters, mounted pane tests for a drop, Claude's reply on the mock clock, a win, a full column, undo, swap, level and help 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.
  • Hard takes at most about 40 ms per move on a laptop. 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 /connect-four.
  • Hard searches seven moves ahead with a simple line-count heuristic, not a perfect solver: it can be beaten with a long plan.

License

MIT, see the repository root.

Source 3 files
hooks/register.ts 313 lines
1// connect-four: Connect Four against Claude, in a pane.
2//
3//   /connect-four                open the game
4//   /connect-four new            start a new game
5//   /connect-four level hard     easy, normal (default), or hard (seven-move lookahead)
6//   /connect-four as o           play O instead of X (starts a new game)
7//   /connect-four stats          games, wins, losses, draws
8//
9// In the pane: press a column's digit, or Tab or click to it and press Enter,
10// and your disc drops to the lowest free cell. Claude answers half a second
11// later. n starts a new game, u takes your last move back, s swaps your side,
12// l cycles the level, h shows the how-to. Claude's moves come from the rules
13// in game.ts: nothing here calls a model, reads what Claude is doing, or
14// leaves the machine.
15
16import { atom, read, update } from 'claude-code'
17import type { Game, Level, Mark, Stats } from '../types'
18import { COLS, DEFAULT_LEVEL, LEVELS, ROWS, at, claudeMark, claudeMove, drop, isGame, isLevel, newGame, nextStarter, undo } from './game'
19
20const PANE = 'connect-four'
21const THINK_MS = 500
22const EMPTY_STATS: Stats = { games: 0, wins: 0, losses: 0, draws: 0 }
23const EMPTY: Game = { seed: 0, cells: new Array(COLS * ROWS).fill(null), you: 'X', turn: 'X', moves: [], winner: null, winningLine: null, level: DEFAULT_LEVEL, starter: 'you', stats: EMPTY_STATS }
24const game = atom({ plugin: 'connect-four', key: 'game' } as const, EMPTY)
25
26// Claude's pending reply, while the pane is open. Dies with the module on reload.
27let thinking: { cancel: () => void } | null = null
28// The how-to block: shown on a fresh board until h hides it, for the session.
29let help: boolean | null = null
30// False after the store could not be read: then nothing is written over the
31// saved record until a later load reads it.
32let storeOk = true
33
34async function save($, g: Game) {
35  if (!storeOk) return
36  try {
37    await $.store.set('game', g)
38  } catch {
39    // The drawing still updates; the store catches up on the next change.
40  }
41}
42
43// Apply a change through update's read-modify-write, so Claude's reply and a
44// press landing together never overwrite each other, then mirror it to the store.
45async function change($, fn: (cur: Game) => Game): Promise<Game> {
46  let out: Game | null = null
47  await update($, game, (cur) => {
48    out = fn(cur)
49    return out
50  })
51  if (out) await save($, out)
52  return out ?? (await read($, game))
53}
54
55async function load($) {
56  let saved: unknown = undefined
57  let readable = true
58  try {
59    saved = await $.store.get('game')
60  } catch {
61    readable = false
62  }
63  storeOk = readable
64  if (isGame(saved)) {
65    await update($, game, () => saved as Game)
66    return
67  }
68  if (!readable) {
69    const cur = await read($, game)
70    if (!cur.seed) await update($, game, () => newGame(Date.now(), EMPTY_STATS, DEFAULT_LEVEL, 'X', 'you'))
71    $.ui.log('connect-four could not read its saved game; playing without saving')
72    return
73  }
74  const fresh = newGame(await $.clock.now(), EMPTY_STATS, DEFAULT_LEVEL, 'X', 'you')
75  await update($, game, () => fresh)
76  await save($, fresh)
77}
78
79function stopThinking() {
80  if (thinking) thinking.cancel()
81  thinking = null
82}
83
84function isClaudesTurn(g: Game): boolean {
85  return !g.winner && g.seed !== 0 && g.turn === claudeMark(g)
86}
87
88function endToast($, g: Game) {
89  if (g.winner === 'you') $.ui.toast('You win! New game: n', { timeoutMs: 5000 })
90  else if (g.winner === 'claude') $.ui.toast('Claude wins. New game: n', { timeoutMs: 5000 })
91  else if (g.winner === 'draw') $.ui.toast("A draw. New game: n", { timeoutMs: 5000 })
92}
93
94// Claude replies after a short pause, if it is still Claude's move by then.
95function scheduleClaude($, g: Game) {
96  stopThinking()
97  if (!isClaudesTurn(g)) return
98  thinking = $.clock.after(THINK_MS, async () => {
99    thinking = null
100    try {
101      const after = await change($, (cur) => {
102        if (!isClaudesTurn(cur)) return cur
103        return drop(cur, claudeMove(cur), cur.turn).game
104      })
105      if (after.winner) endToast($, after)
106    } catch {
107      // A failed reply is skipped; the next press or open tries again.
108    }
109  })
110}
111
112async function startNew($, patch: Partial<Pick<Game, 'you' | 'level'>> = {}) {
113  stopThinking()
114  const now = await $.clock.now()
115  const g = await change($, (cur) => {
116    const you = patch.you ?? cur.you
117    const level = patch.level ?? cur.level
118    const starter = cur.seed ? nextStarter(cur) : 'you'
119    return newGame(now, cur.stats, level, you, starter)
120  })
121  return g
122}
123
124function summary(g: Game): string {
125  if (!g.seed) return 'No game yet.'
126  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."
127  const row = (r: number) => Array.from({ length: COLS }, (_, c) => g.cells[at(r, c)] ?? '.').join('')
128  return 'Connect Four: ' + Array.from({ length: ROWS }, (_, r) => row(r)).join(' / ') + '. You are ' + g.you + ', level ' + g.level + ', ' + g.moves.length + ' moves. ' + state + ' /connect-four opens the pane.'
129}
130
131export function register(on) {
132  on('session.start', async ($, e, next) => {
133    help = null
134    try {
135      await load($)
136    } catch (error) {
137      $.ui.log('connect-four could not load its game: ' + error)
138    }
139    try {
140      await $.command.register({ name: 'connect-four', description: 'Play Connect Four against Claude', argumentHint: '[new | level easy|normal|hard | as x|o | stats]', immediate: true })
141    } catch (error) {
142      $.ui.log('could not register /connect-four: ' + error)
143    }
144    return next(e)
145  })
146
147  // /clear and /resume reset $.state; the game lives on in the store.
148  on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
149    await load($)
150    return next(e)
151  }).catch(async ($, e, next) => next(e))
152
153  on('command.run', { command: 'connect-four' }, async ($, e) => {
154    const args = e.args.trim().toLowerCase()
155    if (args === 'new') {
156      const g = await startNew($)
157      scheduleClaude($, g)
158      return { text: 'New game. ' + summary(g) }
159    }
160    const level = /^level\s+(\w+)$/.exec(args)
161    if (level) {
162      if (!isLevel(level[1])) return { text: 'Levels: easy, normal, hard.' }
163      const g = await change($, (cur) => ({ ...cur, level: level[1] as Level }))
164      return { text: 'Level ' + g.level + (g.level === 'hard' ? ': Claude looks seven moves ahead.' : '.') }
165    }
166    const side = /^as\s+([xo])$/.exec(args)
167    if (side) {
168      const you = side[1].toUpperCase() as Mark
169      const g = await startNew($, { you })
170      scheduleClaude($, g)
171      return { text: 'You are ' + you + '. New game. ' + summary(g) }
172    }
173    if (args === 'stats') {
174      const s = (await read($, game)).stats
175      return { text: 'Games ' + s.games + ' · you ' + s.wins + ' · Claude ' + s.losses + ' · draws ' + s.draws }
176    }
177    if (args) return { text: 'Usage: /connect-four, /connect-four new, /connect-four level easy|normal|hard, /connect-four as x|o, or /connect-four stats' }
178    // Under claude -p nothing draws, and ui.open still reports the pane as placed:
179    // the surfaces list is the reliable sign.
180    let surfaces = []
181    try {
182      surfaces = await $.session.surfaces()
183    } catch {
184      surfaces = []
185    }
186    if (!surfaces.length) return { text: summary(await read($, game)) }
187    const placed = await $.ui.open({ id: PANE, title: 'Connect Four', focus: true, closeOnEscape: true, rows: 26 })
188    if (!placed.isPlaced) return { text: summary(await read($, game)) }
189    scheduleClaude($, await read($, game))
190    return {}
191  }).catch(async () => ({ text: 'connect-four: the command failed.' }))
192
193  // Closing the pane stops a pending reply; opening it again schedules one.
194  on('ui.close', { id: PANE }, async ($, e, next) => {
195    stopThinking()
196    return next(e)
197  }).catch(async ($, e, next) => next(e))
198
199  on('ui.render', { component: 'Pane' }, async ($, e, next) => {
200    if (e.requestId !== PANE) return next(e)
201    const g = await read($, game)
202    const { Box, Text, Button } = $.ui.resolve(e)
203    if (!g.seed) return Text({ children: ['No game loaded. /connect-four new starts one.'] })
204    const columns = e.props.bodyColumns || 60
205    const wide = columns >= 50
206    const me = claudeMark(g)
207    const yourTurn = !g.winner && g.turn === g.you
208    const last = g.moves.length ? g.moves[g.moves.length - 1] : -1
209    const showHelp = help ?? (wide && g.moves.length === 0)
210
211    const status = g.winner === 'you' ? { text: 'You win! Press n for a new game.', color: 'success', bold: true }
212      : g.winner === 'claude' ? { text: 'Claude wins. Press n for a new game.', color: 'error', bold: true }
213      : g.winner === 'draw' ? { text: 'A draw. Press n for a new game.', color: 'warning', bold: true }
214      : !yourTurn ? { text: 'Claude is thinking…', color: 'claude' }
215      : g.moves.length === 0 ? { text: 'Your move: press a column\'s digit, or click it.', color: 'suggestion' }
216      : last >= 0 && g.moves.length % 2 === (g.starter === 'claude' ? 1 : 0) ? { text: 'Claude dropped in column ' + (last + 1) + '. Your move.', color: 'suggestion' }
217      : { text: 'Your move.', color: 'suggestion' }
218
219    const header = Box({ flexDirection: 'column', children: [
220      Box({ flexDirection: 'row', columnGap: 2, flexWrap: 'wrap', children: [
221        Text({ bold: true, children: ['CONNECT FOUR'] }),
222        Text({ children: ['you ', Text({ bold: true, color: 'success', children: [g.you] }), ' · Claude ', Text({ bold: true, color: 'claude', children: [me] })] }),
223        Text({ children: ['level ' + g.level] }),
224        Text({ dimColor: true, children: ['you ' + g.stats.wins + ' · Claude ' + g.stats.losses + ' · draws ' + g.stats.draws] }),
225      ] }),
226      Text({ color: status.color, bold: status.bold, wrap: 'wrap', children: [status.text] }),
227      ...(g.level === 'hard' && !g.winner ? [Text({ dimColor: true, children: ['Claude looks seven moves ahead on hard.'] })] : []),
228    ] })
229
230    // A row of drop buttons, one per column, over the discs. Every cell is a
231    // fixed-width Box so the grid lines up on any surface; color says whose disc
232    // it is. A column's digit is its hotkey; the desktop draws a hotkey as a key
233    // badge beside the label, so there the label is left blank.
234    const desktop = e.surface === 'desktop'
235    // A terminal draws a Button as `[ 1 ]`, five columns, so its cells are five
236    // wide with no gap; the desktop draws a compact key badge in four plus a gap.
237    const cellWidth = desktop ? 4 : 5
238    const gap = desktop ? 1 : 0
239    const landed = last >= 0 ? (() => {
240      // The cell the last disc landed in: the highest filled cell of its column.
241      for (let r = 0; r < ROWS; r++) if (g.cells[at(r, last)]) return at(r, last)
242      return -1
243    })() : -1
244    const dropButton = (c: number) => {
245      const full = g.cells[at(0, c)] !== null
246      return Box({ width: cellWidth, justifyContent: 'center', children: [
247        Button({ key: 'col-' + c, label: desktop ? ' ' : String(c + 1), hotkey: full ? undefined : String(c + 1), dimColor: full, onPress: async () => {
248          let error: string | undefined
249          const after = await change($, (cur) => {
250            const r = drop(cur, c, cur.you)
251            error = r.error
252            return r.game
253          })
254          if (error) {
255            $.ui.toast(error)
256            return
257          }
258          if (after.winner) endToast($, after)
259          else scheduleClaude($, after)
260        } }),
261      ] })
262    }
263    const cell = (r: number, c: number) => {
264      const i = at(r, c)
265      const m = g.cells[i]
266      const lit = g.winningLine?.includes(i)
267      const backgroundColor = lit ? 'suggestion' : m === g.you ? 'success' : m ? 'claude' : undefined
268      return Box({ width: cellWidth, justifyContent: 'center', backgroundColor, children: [Text({ bold: i === landed || !!lit, color: m ? 'inverseText' : undefined, dimColor: !m, children: [m ?? '.'] })] })
269    }
270    const grid = Box({ flexDirection: 'column', children: [
271      Box({ flexDirection: 'row', columnGap: gap, children: Array.from({ length: COLS }, (_, c) => dropButton(c)) }),
272      ...Array.from({ length: ROWS }, (_, r) => Box({ flexDirection: 'row', columnGap: gap, children: Array.from({ length: COLS }, (_, c) => cell(r, c)) })),
273    ] })
274
275    const howTo = Box({ flexDirection: 'column', borderStyle: 'round', borderColor: 'suggestion', paddingX: 1, children: [
276      Text({ bold: true, children: ['How to play'] }),
277      Text({ wrap: 'wrap', children: ['1. Press a column\'s digit, 1 to 7, or click it: your disc drops to the lowest free cell. You are ' + g.you + '.'] }),
278      Text({ wrap: 'wrap', children: ['2. Claude answers after a moment. Four in a row, column, or diagonal wins.'] }),
279      Text({ wrap: 'wrap', children: ['3. u takes your last move back, s swaps sides, l changes the level (easy, normal, hard).'] }),
280      Text({ dimColor: true, wrap: 'wrap', children: ['Keys need the pane focused: click it if nothing happens. h hides this.'] }),
281    ] })
282
283    const footer = Box({ flexDirection: 'row', columnGap: 3, flexWrap: 'wrap', children: [
284      Button({ key: 'new', label: 'New game', hotkey: 'n', plain: true, onPress: async () => scheduleClaude($, await startNew($)) }),
285      Button({ key: 'undo', label: 'Undo', hotkey: 'u', plain: true, onPress: async () => {
286        stopThinking()
287        let error: string | undefined
288        const after = await change($, (cur) => {
289          const r = undo(cur)
290          error = r.error
291          return r.game
292        })
293        if (error) {
294          $.ui.toast(error)
295          // Nothing was taken back: if Claude was about to move, it still is.
296          scheduleClaude($, after)
297        }
298      } }),
299      Button({ key: 'swap', label: 'Play ' + me, hotkey: 's', plain: true, onPress: async () => scheduleClaude($, await startNew($, { you: me })) }),
300      Button({ key: 'level', label: 'Level: ' + g.level, hotkey: 'l', plain: true, onPress: async () => {
301        await change($, (cur) => ({ ...cur, level: LEVELS[(LEVELS.indexOf(cur.level) + 1) % LEVELS.length] }))
302      } }),
303      Button({ key: 'help', label: showHelp ? 'Hide help' : 'Help', hotkey: 'h', plain: true, dimColor: true, onPress: () => {
304        help = !showHelp
305        $.ui.invalidate('ui.render')
306      } }),
307      Text({ dimColor: true, children: ['Esc closes'] }),
308    ] })
309
310    return Box({ flexDirection: 'column', rowGap: 1, children: showHelp ? [header, grid, howTo, footer] : [header, grid, footer] })
311  })
312}
313
hooks/game.ts 240 lines
1// The rules of Connect Four 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 COLS = 7
7export const ROWS = 6
8export const LEVELS: Level[] = ['easy', 'normal', 'hard']
9export const DEFAULT_LEVEL: Level = 'normal'
10const HARD_DEPTH = 7
11
12function hash(s: string): number {
13  let h = 2166136261
14  for (let i = 0; i < s.length; i++) {
15    h ^= s.charCodeAt(i)
16    h = Math.imul(h, 16777619)
17  }
18  return h >>> 0
19}
20
21function rng(seed: number): () => number {
22  let a = seed
23  return () => {
24    a = (a + 0x6d2b79f5) | 0
25    let t = Math.imul(a ^ (a >>> 15), 1 | a)
26    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
27    return ((t ^ (t >>> 14)) >>> 0) / 4294967296
28  }
29}
30
31export function other(m: Mark): Mark {
32  return m === 'X' ? 'O' : 'X'
33}
34
35export function claudeMark(g: Game): Mark {
36  return other(g.you)
37}
38
39export const at = (row: number, col: number) => row * COLS + col
40
41// Every run of four cells on the board, computed once.
42export const LINES: number[][] = (() => {
43  const out: number[][] = []
44  for (let r = 0; r < ROWS; r++) {
45    for (let c = 0; c < COLS; c++) {
46      if (c + 3 < COLS) out.push([0, 1, 2, 3].map((i) => at(r, c + i)))
47      if (r + 3 < ROWS) out.push([0, 1, 2, 3].map((i) => at(r + i, c)))
48      if (c + 3 < COLS && r + 3 < ROWS) out.push([0, 1, 2, 3].map((i) => at(r + i, c + i)))
49      if (c - 3 >= 0 && r + 3 < ROWS) out.push([0, 1, 2, 3].map((i) => at(r + i, c - i)))
50    }
51  }
52  return out
53})()
54
55export function winnerOf(cells: (Mark | null)[]): { mark: Mark; line: number[] } | null {
56  for (const line of LINES) {
57    const m = cells[line[0]]
58    if (m && line.every((i) => cells[i] === m)) return { mark: m, line }
59  }
60  return null
61}
62
63// The row a disc dropped in `col` lands on, or -1 when the column is full.
64export function landing(cells: (Mark | null)[], col: number): number {
65  for (let r = ROWS - 1; r >= 0; r--) if (!cells[at(r, col)]) return r
66  return -1
67}
68
69export function openColumns(cells: (Mark | null)[]): number[] {
70  return Array.from({ length: COLS }, (_, c) => c).filter((c) => landing(cells, c) >= 0)
71}
72
73export function newGame(seed: number, stats: Stats, level: Level, you: Mark, starter: 'you' | 'claude'): Game {
74  return { seed, cells: new Array(COLS * ROWS).fill(null), you, turn: starter === 'you' ? you : other(you), moves: [], winner: null, winningLine: null, level, starter, stats }
75}
76
77export type MoveResult = { game: Game; error?: string }
78
79export function drop(g: Game, col: number, mark: Mark): MoveResult {
80  if (g.winner) return { game: g, error: 'The game is over. Press n for a new one.' }
81  if (mark !== g.turn) return { game: g, error: mark === g.you ? "It's Claude's move." : "It's your move." }
82  if (!Number.isInteger(col) || col < 0 || col >= COLS) return { game: g, error: 'Columns are 1 to ' + COLS + '.' }
83  const row = landing(g.cells, col)
84  if (row < 0) return { game: g, error: 'Column ' + (col + 1) + ' is full.' }
85  const cells = g.cells.slice()
86  cells[at(row, col)] = mark
87  const moves = [...g.moves, col]
88  const won = winnerOf(cells)
89  if (won) {
90    const winner = won.mark === g.you ? 'you' : 'claude'
91    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) }
92    return { game: { ...g, cells, moves, turn: other(mark), winner, winningLine: won.line, stats } }
93  }
94  if (!openColumns(cells).length) {
95    return { game: { ...g, cells, moves, turn: other(mark), winner: 'draw', winningLine: null, stats: { ...g.stats, games: g.stats.games + 1, draws: g.stats.draws + 1 } } }
96  }
97  return { game: { ...g, cells, moves, turn: other(mark) } }
98}
99
100// Take back your last move and everything after it. Only while the game is on.
101export function undo(g: Game): MoveResult {
102  if (g.winner) return { game: g, error: 'The game is over. Press n for a new one.' }
103  const first: Mark = g.starter === 'you' ? g.you : other(g.you)
104  const yours = g.moves.map((_, i) => ((i % 2 === 0 ? first : other(first)) === g.you ? i : -1)).filter((i) => i >= 0)
105  if (!yours.length) return { game: g, error: 'Nothing of yours to take back yet.' }
106  const moves = g.moves.slice(0, yours[yours.length - 1])
107  const cells: (Mark | null)[] = new Array(COLS * ROWS).fill(null)
108  let mark = first
109  for (const col of moves) {
110    cells[at(landing(cells, col), col)] = mark
111    mark = other(mark)
112  }
113  return { game: { ...g, cells, moves, turn: g.you } }
114}
115
116export function nextStarter(g: Game): 'you' | 'claude' {
117  if (g.winner === 'you') return 'claude'
118  if (g.winner === 'claude') return 'you'
119  return g.starter === 'you' ? 'claude' : 'you'
120}
121
122// How good the board looks for `me`: each run of four with only my discs
123// counts for me, only theirs against me, by how full it is. Center columns
124// count a little extra, since most lines pass through them.
125function heuristic(cells: (Mark | null)[], me: Mark): number {
126  let s = 0
127  for (const line of LINES) {
128    let mine = 0
129    let theirs = 0
130    for (const i of line) {
131      if (cells[i] === me) mine++
132      else if (cells[i]) theirs++
133    }
134    if (mine && !theirs) s += mine === 3 ? 8 : mine === 2 ? 2 : 0
135    else if (theirs && !mine) s -= theirs === 3 ? 9 : theirs === 2 ? 2 : 0
136  }
137  for (let r = 0; r < ROWS; r++) if (cells[at(r, 3)] === me) s += 1
138  return s
139}
140
141function search(cells: (Mark | null)[], me: Mark, toMove: Mark, depth: number, alpha: number, beta: number): number {
142  const won = winnerOf(cells)
143  if (won) return won.mark === me ? 1000 + depth : -1000 - depth
144  const open = openColumns(cells)
145  if (!open.length) return 0
146  if (depth === 0) return heuristic(cells, me)
147  const order = [3, 2, 4, 1, 5, 0, 6].filter((c) => open.includes(c))
148  let best = toMove === me ? -Infinity : Infinity
149  for (const col of order) {
150    const i = at(landing(cells, col), col)
151    cells[i] = toMove
152    const s = search(cells, me, other(toMove), depth - 1, alpha, beta)
153    cells[i] = null
154    if (toMove === me) {
155      best = Math.max(best, s)
156      alpha = Math.max(alpha, best)
157    } else {
158      best = Math.min(best, s)
159      beta = Math.min(beta, best)
160    }
161    if (beta <= alpha) break
162  }
163  return best
164}
165
166function winningColumn(cells: (Mark | null)[], mark: Mark): number | null {
167  for (const col of openColumns(cells)) {
168    const i = at(landing(cells, col), col)
169    cells[i] = mark
170    const won = !!winnerOf(cells)
171    cells[i] = null
172    if (won) return col
173  }
174  return null
175}
176
177function pick<T>(list: T[], r: () => number): T {
178  return list[Math.floor(r() * list.length)]
179}
180
181// Claude's column for the position, by level. Deterministic from the game's
182// seed and the move number, so a game replays the same way.
183export function claudeMove(g: Game): number {
184  const me = claudeMark(g)
185  const cells = g.cells.slice()
186  const open = openColumns(cells)
187  if (!open.length) return -1
188  const r = rng(hash(g.seed + ':move:' + g.moves.length))
189  if (g.level === 'easy') return pick(open, r)
190  const win = winningColumn(cells, me)
191  if (win !== null) return win
192  const block = winningColumn(cells, g.you)
193  if (block !== null) return block
194  if (g.level === 'normal') {
195    // Don't hand the opponent a win on top of the disc; prefer the middle.
196    const safe = open.filter((col) => {
197      const row = landing(cells, col)
198      if (row === 0) return true
199      cells[at(row, col)] = me
200      cells[at(row - 1, col)] = g.you
201      const gives = !!winnerOf(cells)
202      cells[at(row, col)] = null
203      cells[at(row - 1, col)] = null
204      return !gives
205    })
206    const from = safe.length ? safe : open
207    const middle = from.filter((c) => c >= 2 && c <= 4)
208    return pick(middle.length ? middle : from, r)
209  }
210  let best = -Infinity
211  let bestCols: number[] = []
212  for (const col of [3, 2, 4, 1, 5, 0, 6].filter((c) => open.includes(c))) {
213    const i = at(landing(cells, col), col)
214    cells[i] = me
215    const s = search(cells, me, g.you, HARD_DEPTH - 1, -Infinity, Infinity)
216    cells[i] = null
217    if (s > best) {
218      best = s
219      bestCols = [col]
220    } else if (s === best) bestCols.push(col)
221  }
222  return pick(bestCols, r)
223}
224
225export function isLevel(x: unknown): x is Level {
226  return x === 'easy' || x === 'normal' || x === 'hard'
227}
228
229export function isGame(x: unknown): x is Game {
230  if (!x || typeof x !== 'object') return false
231  const g = x as Record<string, unknown>
232  const mark = (m: unknown) => m === 'X' || m === 'O'
233  const stats = g.stats as Record<string, unknown> | undefined
234  return typeof g.seed === 'number' && Array.isArray(g.cells) && g.cells.length === COLS * ROWS && g.cells.every((m) => m === null || mark(m)) &&
235    mark(g.you) && mark(g.turn) && Array.isArray(g.moves) && g.moves.every((c) => Number.isInteger(c) && c >= 0 && c < COLS) &&
236    (g.winner === null || g.winner === 'you' || g.winner === 'claude' || g.winner === 'draw') && isLevel(g.level) &&
237    (g.starter === 'you' || g.starter === 'claude') && !!stats && typeof stats === 'object' &&
238    ['games', 'wins', 'losses', 'draws'].every((k) => Number.isInteger(stats[k]))
239}
240
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  /** 42 cells, 7 across by 6 down, row 0 at the top; null is empty. */
8  cells: (Mark | null)[]
9  /** Which disc you play. Kept across games. */
10  you: Mark
11  /** Whose disc drops next. */
12  turn: Mark
13  /** The columns played so far, in order. */
14  moves: number[]
15  winner: 'you' | 'claude' | 'draw' | null
16  /** The four cells 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    'connect-four': { game: Game }
27  }
28}
29