Play Connect Four against an AI in a Claude Code pane: /connect-four

A Claude Code mod: type /connect-four and a pane opens where you play Connect Four against an AI.
⚪ ⚪ ⚪ ⚪ ⚪ ⚪ ⚪
⚪ ⚪ ⚪ ⚪ ⚪ ⚪ ⚪
⚪ ⚪ ⚪ 🟡 ⚪ ⚪ ⚪
⚪ ⚪ 🔴 🔴 ⚪ ⚪ ⚪
⚪ 🟡 🔴 🟡 ⚪ ⚪ ⚪
🔴 🟡 🟡 🔴 🔴 ⚪ ⚪
1 2 3 4 5 6 7
Press 1–7 (columns, left to right) to drop your red piece. Four in a row wins.
⠹ AI is thinking ▰▰▰▱▱▱▱
You 2 · AI 1 · Draws 0
n: New game q: Close
🔴 you · 🟡 the AI · ⚪ empty. In Claude Code the pieces are drawn as red and yellow ●.
| Key | Action |
|---|---|
1–7 or click ↓ | drop your (red) piece into that column |
n | new game (the score is kept) |
q / Esc | close the pane |
Your piece shows right away; while the AI (yellow) thinks, a progress bar runs and input is locked. The winning four is highlighted.
Minimax with alpha-beta pruning, 7 plies deep, centre columns first. It takes immediate wins and blocks your threats. Change DEPTH in hooks/game.ts to make it weaker or stronger.
Texts are English or German. German is used when Claude Code's language setting says so ("language": "german" in ~/.claude/settings.json) or, without that setting, when the locale (LC_ALL, LC_MESSAGES, LANG) starts with de. Everything else gets English.
Requires Claude Code 2.1.287 or later (tested with 2.1.288). Works in the terminal and in the Code tab of the Claude Desktop app.
claude plugin marketplace add nachtgold/claude-code-connect-four
claude plugin install connect-four@connect-four
Start claude (or run /reload-plugins in an open session), then type /connect-four.
To try it without installing:
git clone https://github.com/nachtgold/claude-code-connect-four.git
claude --plugin-dir ./claude-code-connect-four
claude plugin validate .
claude plugin test .
tsconfig.json extends the types Claude Code writes to .claude-plugin/types/ once it has loaded the mod; after that, tsc -p . type-checks it.
MIT
hooks/register.tsx 131 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { isGerman, texts } from './i18n'
5import type { Lang } from './i18n'
6import { COLS, ORDERED, ROWS, aiMove, dropRow, newGame, pickBest, playerMove, scoreColumn } from './game'
7
8const PANE = 'connect-four'
9const game = atom({ plugin: 'connect-four', key: 'game' } as const, newGame())
10const thinking = atom({ plugin: 'connect-four', key: 'thinking' } as const, { isThinking: false, done: 0, total: 0 })
11const MIN_THINK_MS = 400
12const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
13let isBusy = false
14let lang: Promise<Lang> | undefined
15
16/** German when Claude Code's `language` setting or the locale says so, English otherwise. */
17const detectLang = async ($: EngineInterface): Promise<Lang> => {
18 const { language } = await $.settings.read()
19 if (typeof language === 'string' && language.trim()) return isGerman(language) ? 'de' : 'en'
20 const locale =
21 (await $.env.get('LC_ALL')) || (await $.env.get('LC_MESSAGES')) || (await $.env.get('LANG')) ||
22 Intl.DateTimeFormat().resolvedOptions().locale
23 return isGerman(locale) ? 'de' : 'en'
24}
25const t$ = async ($: EngineInterface) => texts(await (lang ??= detectLang($)))
26
27/** Shows the person's piece first, then lets the AI think column by column so the pane redraws in between. */
28const turn = async ($: EngineInterface, col: number) => {
29 if (isBusy) return
30 isBusy = true
31 try {
32 let isAiTurn = false
33 await update($, game, cur => {
34 const next = playerMove(cur, col)
35 isAiTurn = next !== cur && !next.winner
36 return next
37 })
38 if (!isAiTurn) return
39
40 const { board } = await read($, game)
41 const columns = ORDERED.filter(c => dropRow(board, c) >= 0)
42 const started = await $.clock.now()
43 const scores = new Map<number, number>()
44 await update($, thinking, () => ({ isThinking: true, done: 0, total: columns.length }))
45 for (const [i, c] of columns.entries()) {
46 await $.clock.sleep(30)
47 const v = scoreColumn(board, c)
48 if (v !== undefined) scores.set(c, v)
49 await update($, thinking, t => ({ ...t, done: i + 1 }))
50 }
51 const rest = MIN_THINK_MS - ((await $.clock.now()) - started)
52 if (rest > 0) await $.clock.sleep(rest)
53 await update($, game, cur => aiMove(cur, pickBest(scores)))
54 } finally {
55 await update($, thinking, () => ({ isThinking: false, done: 0, total: 0 }))
56 isBusy = false
57 }
58}
59
60export const register: Register = on => {
61 on('session.start', async ($, e, next) => {
62 await $.command.register({
63 name: 'connect-four',
64 description: (await t$($)).commandDescription,
65 })
66 return next(e)
67 })
68
69 on('command.run', { command: 'connect-four' }, async $ => {
70 await $.ui.open({ id: PANE, title: 'Connect Four', focus: true, closeOnEscape: true, rows: 15, columns: 44 })
71 return { text: (await t$($)).opened }
72 })
73
74 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
75 const { Box, Text, Button } = $.ui.resolve(e)
76 const g = await read($, game)
77 const t = await read($, thinking)
78 const tx = await t$($)
79
80 const status =
81 g.winner === 1 ? tx.youWon
82 : g.winner === 2 ? tx.aiWon
83 : g.winner === 3 ? tx.draw
84 : t.isThinking
85 ? `${SPINNER[t.done % SPINNER.length]} ${tx.thinking} ${'▰'.repeat(t.done)}${'▱'.repeat(t.total - t.done)}`
86 : g.lastMove < 0 ? tx.yourTurn
87 : tx.aiPlayed((g.lastMove % COLS) + 1)
88
89 const cell = (i: number) => {
90 const v = g.board[i]
91 const inLine = g.line.includes(i)
92 if (v === 0) return <Text dimColor>·</Text>
93 return (
94 <Text color={v === 1 ? 'red' : 'yellow'} bold={inLine || i === g.lastMove} inverse={inLine}>
95 ●
96 </Text>
97 )
98 }
99
100 return (
101 <Box flexDirection="column">
102 <Box flexDirection="row">
103 {Array.from({ length: COLS }, (_, c) => (
104 <Box key={`col-${c}`} flexDirection="column" alignItems="center" width={5}>
105 {Array.from({ length: ROWS }, (_, r) => cell(r * COLS + c))}
106 <Button
107 key={`drop-${c}`}
108 plain
109 hotkey={String(c + 1)}
110 label="↓"
111 dimColor={t.isThinking || g.winner !== 0 || dropRow(g.board, c) < 0}
112 autoFocus={c === 3 ? true : undefined}
113 onPress={() => turn($, c)}
114 />
115 </Box>
116 ))}
117 </Box>
118 <Text dimColor>{tx.hint}</Text>
119 <Text bold={g.winner !== 0}>{status}</Text>
120 <Text dimColor>{tx.score(g.score.you, g.score.ai, g.score.draw)}</Text>
121 <Box flexDirection="row" gap={2}>
122 <Button key="new" hotkey="n" plain label={tx.newGame} variant="primary" dimColor={t.isThinking}
123 onPress={() => (isBusy ? undefined : update($, game, cur => newGame(cur.score)))} />
124 <Button key="close" hotkey="q" plain label={tx.close} role="dismiss"
125 onPress={() => $.ui.close({ id: PANE })} />
126 </Box>
127 </Box>
128 )
129 })
130}
131hooks/i18n.ts 40 lines1export type Lang = 'en' | 'de'
2
3const TEXTS = {
4 en: {
5 commandDescription: 'Play Connect Four against the AI',
6 opened: 'Connect Four opened. Keys 1–7 drop a piece, n = new game, Esc closes.',
7 youWon: 'You won! 🎉',
8 aiWon: 'The AI won.',
9 draw: 'Draw.',
10 thinking: 'AI is thinking',
11 hint: 'Press 1–7 (columns, left to right) to drop your red piece. Four in a row wins.',
12 yourTurn: 'Your turn (red).',
13 aiPlayed: (col: number) => `AI played column ${col}. Your turn.`,
14 score: (you: number, ai: number, draw: number) => `You ${you} · AI ${ai} · Draws ${draw}`,
15 newGame: 'New game',
16 close: 'Close',
17 },
18 de: {
19 commandDescription: 'Connect Four gegen die KI spielen',
20 opened: 'Connect Four geöffnet. Tasten 1–7 werfen einen Stein, n = neues Spiel, Esc schließt.',
21 youWon: 'Du hast gewonnen! 🎉',
22 aiWon: 'Die KI hat gewonnen.',
23 draw: 'Unentschieden.',
24 thinking: 'KI denkt nach',
25 hint: 'Drücke 1–7 (Spalten von links), um deinen roten Stein einzuwerfen. Vier in einer Reihe gewinnt.',
26 yourTurn: 'Du bist dran (rot).',
27 aiPlayed: (col: number) => `KI spielte Spalte ${col}. Du bist dran.`,
28 score: (you: number, ai: number, draw: number) => `Du ${you} · KI ${ai} · Remis ${draw}`,
29 newGame: 'Neues Spiel',
30 close: 'Schließen',
31 },
32} as const
33
34export type Texts = (typeof TEXTS)[Lang]
35
36export const isGerman = (value: unknown) =>
37 typeof value === 'string' && /^(de\b|de[-_]|german|deutsch)/i.test(value.trim())
38
39export const texts = (lang: Lang): Texts => TEXTS[lang]
40hooks/game.ts 141 lines1import type { Cell, Game } from '../types'
2
3export const ROWS = 6
4export const COLS = 7
5export const YOU = 1
6export const AI = 2
7const DEPTH = 7
8const ORDER = [3, 2, 4, 1, 5, 0, 6]
9
10export const emptyBoard = (): Cell[] => Array(ROWS * COLS).fill(0)
11
12export const newGame = (score = { you: 0, ai: 0, draw: 0 }): Game => ({
13 board: emptyBoard(),
14 winner: 0,
15 line: [],
16 lastMove: -1,
17 score,
18})
19
20export const dropRow = (board: Cell[], col: number): number => {
21 for (let r = ROWS - 1; r >= 0; r--) if (board[r * COLS + col] === 0) return r
22 return -1
23}
24
25const DIRS: ReadonlyArray<readonly [number, number]> = [[0, 1], [1, 0], [1, 1], [1, -1]]
26
27export const findLine = (board: Cell[]): { who: Cell; line: number[] } | undefined => {
28 for (let r = 0; r < ROWS; r++)
29 for (let c = 0; c < COLS; c++) {
30 const who = board[r * COLS + c]
31 if (!who) continue
32 for (const [dr, dc] of DIRS) {
33 const line = [0, 1, 2, 3].map(i => [r + dr * i, c + dc * i] as const)
34 if (line.every(([y, x]) => y >= 0 && y < ROWS && x >= 0 && x < COLS && board[y * COLS + x] === who))
35 return { who, line: line.map(([y, x]) => y * COLS + x) }
36 }
37 }
38 return undefined
39}
40
41const windowScore = (cells: Cell[]): number => {
42 const ai = cells.filter(v => v === AI).length
43 const you = cells.filter(v => v === YOU).length
44 if (ai && you) return 0
45 if (ai === 3) return 5
46 if (ai === 2) return 2
47 if (you === 3) return -6
48 if (you === 2) return -2
49 return 0
50}
51
52const evaluate = (board: Cell[]): number => {
53 let score = 0
54 for (let r = 0; r < ROWS; r++) if (board[r * COLS + 3] === AI) score += 3
55 for (let r = 0; r < ROWS; r++)
56 for (let c = 0; c < COLS; c++)
57 for (const [dr, dc] of DIRS) {
58 const er = r + dr * 3, ec = c + dc * 3
59 if (er < 0 || er >= ROWS || ec < 0 || ec >= COLS) continue
60 score += windowScore([0, 1, 2, 3].map(i => board[(r + dr * i) * COLS + c + dc * i] ?? 0))
61 }
62 return score
63}
64
65const minimax = (board: Cell[], depth: number, alpha: number, beta: number, aiTurn: boolean): number => {
66 const won = findLine(board)
67 if (won) return won.who === AI ? 100000 + depth : -100000 - depth
68 if (board.every(v => v !== 0)) return 0
69 if (depth === 0) return evaluate(board)
70 let best = aiTurn ? -Infinity : Infinity
71 for (const c of ORDER) {
72 const r = dropRow(board, c)
73 if (r < 0) continue
74 board[r * COLS + c] = aiTurn ? AI : YOU
75 const v = minimax(board, depth - 1, alpha, beta, !aiTurn)
76 board[r * COLS + c] = 0
77 if (aiTurn) { best = Math.max(best, v); alpha = Math.max(alpha, v) }
78 else { best = Math.min(best, v); beta = Math.min(beta, v) }
79 if (alpha >= beta) break
80 }
81 return best
82}
83
84export const ORDERED = ORDER
85
86/** How good dropping an AI piece in `col` is; undefined when the column is full. */
87export const scoreColumn = (board: Cell[], col: number, depth = DEPTH): number | undefined => {
88 const r = dropRow(board, col)
89 if (r < 0) return undefined
90 const work = [...board]
91 work[r * COLS + col] = AI
92 return minimax(work, depth - 1, -Infinity, Infinity, false)
93}
94
95export const pickBest = (scores: Map<number, number>): number => {
96 let best = -1, bestScore = -Infinity
97 for (const c of ORDER) {
98 const v = scores.get(c)
99 if (v !== undefined && v > bestScore) { bestScore = v; best = c }
100 }
101 return best
102}
103
104export const bestMove = (board: Cell[], depth = DEPTH): number => {
105 const scores = new Map<number, number>()
106 for (const c of ORDER) {
107 const v = scoreColumn(board, c, depth)
108 if (v !== undefined) scores.set(c, v)
109 }
110 return pickBest(scores)
111}
112
113const place = (game: Game, col: number, who: Cell): Game => {
114 const r = dropRow(game.board, col)
115 if (r < 0) return game
116 const board = [...game.board]
117 board[r * COLS + col] = who
118 const won = findLine(board)
119 const full = board.every(v => v !== 0)
120 const winner = won ? (won.who as 1 | 2) : full ? 3 : 0
121 const score = { ...game.score }
122 if (winner === 1) score.you++
123 if (winner === 2) score.ai++
124 if (winner === 3) score.draw++
125 return { board, winner, line: won?.line ?? [], lastMove: r * COLS + col, score }
126}
127
128/** The person drops a piece in `col`; the game unchanged when that is not allowed. */
129export const playerMove = (game: Game, col: number): Game =>
130 game.winner || dropRow(game.board, col) < 0 ? game : place(game, col, YOU)
131
132/** The AI drops a piece in `col`. */
133export const aiMove = (game: Game, col: number): Game =>
134 game.winner || col < 0 ? game : place(game, col, AI)
135
136/** The person's move and the AI's answer at once. */
137export const play = (game: Game, col: number): Game => {
138 const after = playerMove(game, col)
139 return after === game || after.winner ? after : aiMove(after, bestMove(after.board))
140}
141types/index.d.ts 16 lines1export type Thinking = { isThinking: boolean; done: number; total: number }
2export type Cell = 0 | 1 | 2
3export type Game = {
4 board: Cell[]
5 winner: 0 | 1 | 2 | 3
6 line: number[]
7 lastMove: number
8 score: { you: number; ai: number; draw: number }
9}
10
11declare module 'claude-code' {
12 interface PluginState {
13 'connect-four': { game: Game; thinking: Thinking }
14 }
15}
16