SLOPSHOPPER

connect-four

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

newpanecommand
v1.0.0MITupdated 2026-10-07nachtgold/claude-code-connect-four
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 │ ┃ · · · · · · · │ ┃ · · · · · · · ⏺ Read(src/auth.ts) │ ┃ · · · · · · · ⎿ Read 6 lines │ ┃ · · · · · · · ⏺ Update(src/auth.ts) │ ┃ · · · · · · · ⎿ Added 2 lines, removed 1 line │ ┃ · · · · · · · ⏺ Bash(bun test) │ ┃ 1: ↓ 2: ↓ 3: ↓ 4: ↓ 5: ↓ 6: ↓ 7: ↓ ⎿ 3 pass, 1 fail │ ┃ Press 1–7 (columns, left to right) to drop │ ┃ your red piece. Four in a row wins. ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ Your turn (red). │ ┃ You 0 · AI 0 · Draws 0 ✻ Worked for 42s · done 4:20 PM │ ┃ n: New game q: Close │ › /connect-four │ ⎿ connect-four: Connect Four opened. Keys 1–7 drop a piece, n = ne │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Connect Four
· · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · 1: ↓ 2: ↓ 3: ↓ 4: ↓ 5: ↓ 6: ↓ 7: ↓ Press 1–7 (columns, left to right) to drop your red piece. Four in a row wins. Your turn (red). You 0 · AI 0 · Draws 0 n: New game q: Close
README

Connect Four for Claude Code

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 ●.

Controls

KeyAction
1–7 or click ↓drop your (red) piece into that column
nnew game (the score is kept)
q / Escclose 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.

AI

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.

Language

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.

Install

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

Development

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.

License

MIT

Source 4 files
hooks/register.tsx 131 lines
1import { 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}
131
hooks/i18n.ts 40 lines
1export 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]
40
hooks/game.ts 141 lines
1import 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}
141
types/index.d.ts 16 lines
1export 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