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…

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.

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.
/plugin marketplace add MDmubarak786/claude-mods
/plugin install connect-four@modhub
Try it for one session without installing:
claude --plugin-dir ./mods/connect-four
/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.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 |
|---|---|
/connect-four | Open the pane. Where no pane can be drawn, print the board and whose move it is. |
/connect-four new | Start a new game. The record is kept. |
/connect-four level easy, normal, or hard | Change the level. |
/connect-four as x or /connect-four as o | Choose your disc. Starts a new game. |
/connect-four stats | Games, your wins, Claude's wins, draws. |
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.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./connect-four.MIT, see the repository root.
hooks/register.ts 313 lines1// 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}
313hooks/game.ts 240 lines1// 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}
240types/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 /** 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