Classic 75-ball bingo, you against Claude. Numbers are called, you mark your card and shout Bingo first; Claude plays a card of its own and claims one call…

Classic 75-ball bingo, you against Claude, in a pane beside the conversation. Numbers are called one at a time. You mark them on your card and shout Bingo when you have a row, column, or diagonal. Claude plays a card of its own, marks it as numbers are called, and claims a line one call late, so you always get a window to shout first.
It's a game you play, not a watcher: it never calls a model, never reads what Claude is doing, and costs no tokens. Call numbers yourself, or turn on Auto and let them come every few seconds while Claude works.

Recorded in a terminal. The Desktop app draws the same pane, with each key shown as a badge.
BINGO you vs Claude called 23/75 [ last G-52 ]
recent: B-7 N-33 O-68 I-21 G-52
On your card: G-52. Tab or click the lit cell, then Enter to mark it.
Your card Claude's card
B I N G O B I N G O
3 17 31 58 65 12 18 31 47 62
9 21 33 52 70 3 21 33 52 70
14 25 FREE 48 68 15 29 FREE 49 68
7 19 40 60 61 8 22 40 60 64
11 30 45 55 73 11 26 44 55 75
12 called, mark it 12 marked 12 a full line 12 not called
c: Call a: Auto off b: Bingo! n: New game h: Help Esc closes · you 2 · Claude 1
The cells are colored, which the text above can't show. Colors follow your theme: a called number you haven't marked sits on a yellow cell, a mark is green, a completed line takes the accent color, uncalled numbers are dim, and the number just called is on a highlighted badge in the header. Claude's marks are drawn on its card in Claude's color, and the line it is about to claim in red. A fresh game in a wide pane opens with a boxed how-to under the cards; h hides or shows it any time, at any width.
/plugin marketplace add MDmubarak786/claude-mods
/plugin install bingo@modhub
Try it for one session without installing:
claude --plugin-dir ./mods/bingo
/bingo. The pane opens with your card on the left and Claude's on the right.The keys work while the pane has the focus. If pressing c types into the prompt instead, click the pane or press ctrl+x then tab.
| Command | What it does |
|---|---|
/bingo | Open the pane. Where no pane can be drawn, print the game's state. |
/bingo new | Start a new game. The win-loss record is kept. |
/bingo speed 5 | Auto calls a number every 5 seconds (1 to 120; default 8). |
/bingo stats | Games played, your wins, Claude's wins. |
Cards follow the standard layout: B holds 1 to 15, I 16 to 30, N 31 to 45 with a FREE center, G 46 to 60, O 61 to 75.
From claude plugin validate ./mods/bingo:
hooks: session.start, classic.SessionStart{source=clear|resume|fork}, command.run{command=bingo}, ui.close{id=bingo}, ui.render{component=Pane}
calls: $.clock.every (via startAuto), $.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 and the record, so a game survives a restart.$.clock seeds each game and runs the Auto caller. The caller stops when the pane closes, when the game ends, or when Auto is turned off.ui.close is observed only to stop the caller.$.ui.invalidate redraws the pane when h shows or hides the how-to, which is kept in memory and not saved.claude plugin validate --strict and claude plugin test passing, including mounted pane tests at wide and narrow widths, the how-to and the status prompts, the Auto caller against a mock clock, and a full game to a Claude win. The test kit can't fire ui.close, so the caller stopping on close is covered only by the equivalent Auto-off path. /bingo stats answered from a live claude -p session. Played in the Desktop app, 2.1.293: calling, marking, false and true claims, Auto, and Claude's claim all seen on screen, and the 0.1.1 grid, colors, legend and how-to looked at there on a dark theme. Say so in an issue if a theme makes a lit cell hard to read.warning, success, or suggestion shows the cells uncolored; the status line still names what to mark.MIT, see the repository root.
hooks/register.ts 349 lines1// bingo: classic 75-ball bingo, you against Claude.
2//
3// /bingo open the game in a pane
4// /bingo new start a new game
5// /bingo speed 5 call a number every 5 seconds when Auto is on (default 8)
6// /bingo stats games, wins, losses
7//
8// In the pane: c calls the next number, a toggles Auto, b shouts Bingo, n starts
9// a new game, h shows or hides the how-to. Tab or click to a cell on your card
10// and press Enter to mark it; only a called number can be marked, and a called
11// number you haven't marked yet is lit so you can find it. Claude marks its own card as numbers are
12// called, and when it completes a line it claims at the NEXT call, so you have
13// until then to shout first. Nothing here calls a model, reads what Claude is
14// doing, or leaves the machine.
15
16import { atom, read, update } from 'claude-code'
17import type { Game, Stats } from '../types'
18import { DEFAULT_SPEED, FREE, LETTERS, callLabel, callNumber, claim, claudeMarked, isGame, lineCells, newGame, toggleMark, yourLines } from './game'
19
20const PANE = 'bingo'
21const EMPTY_STATS: Stats = { games: 0, wins: 0, losses: 0 }
22const EMPTY: Game = { seed: 0, you: [], claude: [], called: [], yourMarks: [FREE], claudeLine: null, winner: null, winningLine: null, auto: false, speed: DEFAULT_SPEED, stats: EMPTY_STATS }
23const game = atom({ plugin: 'bingo', key: 'game' } as const, EMPTY)
24
25// The Auto caller, while the pane is open. Dies with the module on reload.
26let timer: { cancel: () => void } | null = null
27
28// The how-to block: shown until the first call unless h has set it either way.
29let help: boolean | null = null
30
31async function save($, g: Game) {
32 try {
33 await $.store.set('game', g)
34 } catch {
35 // The drawing still updates; the store catches up on the next change.
36 }
37}
38
39// Apply a change through update's read-modify-write, so a timer tick and a
40// press landing together never overwrite each other, then mirror it to the store.
41async function change($, fn: (cur: Game) => Game): Promise<Game> {
42 let out: Game | null = null
43 await update($, game, (cur) => {
44 out = fn(cur)
45 return out
46 })
47 if (out) await save($, out)
48 return out ?? (await read($, game))
49}
50
51async function load($) {
52 let saved: unknown = undefined
53 let readable = true
54 try {
55 saved = await $.store.get('game')
56 } catch {
57 readable = false
58 }
59 if (isGame(saved)) {
60 await update($, game, () => saved as Game)
61 return
62 }
63 if (!readable) {
64 // The store can't be read: keep whatever is in memory, or start a game in
65 // memory only, and never write over the saved record.
66 const cur = await read($, game)
67 if (!cur.you.length) await update($, game, () => newGame(Date.now(), EMPTY_STATS, DEFAULT_SPEED, false))
68 $.ui.log('bingo could not read its saved game; playing without saving')
69 return
70 }
71 const fresh = newGame(await $.clock.now(), { ...EMPTY_STATS, games: 1 }, DEFAULT_SPEED, false)
72 await update($, game, () => fresh)
73 await save($, fresh)
74}
75
76async function startNew($) {
77 stopAuto()
78 help = null
79 const now = await $.clock.now()
80 return change($, (g) => newGame(now, { ...g.stats, games: g.stats.games + 1 }, g.speed, false))
81}
82
83function stopAuto() {
84 if (timer) timer.cancel()
85 timer = null
86}
87
88async function call($) {
89 let before: Game | null = null
90 const next = await change($, (g) => {
91 before = g
92 if (g.winner || !g.you.length) return g
93 const n = callNumber(g)
94 return n.winner ? { ...n, auto: false } : n
95 })
96 const was = before as Game | null
97 if (!was || !was.you.length) return
98 if (was.winner) {
99 stopAuto()
100 $.ui.toast('The game is over. Press n for a new game.')
101 return
102 }
103 if (next === was) {
104 // Nothing left to call: don't tick forever.
105 stopAuto()
106 return
107 }
108 if (next.winner === 'claude') {
109 stopAuto()
110 $.ui.toast('Claude: BINGO! ' + next.winningLine + '. New game: n', { timeoutMs: 6000 })
111 } else if (next.claudeLine && !was.claudeLine) {
112 $.ui.toast('Claude has ' + next.claudeLine + '. Shout before the next call!', { timeoutMs: 5000 })
113 }
114}
115
116async function startAuto($) {
117 const g = await read($, game)
118 if (g.winner) return
119 stopAuto()
120 timer = $.clock.every(Math.max(1, g.speed) * 1000, async () => {
121 try {
122 const cur = await read($, game)
123 if (!cur.auto || cur.winner) {
124 stopAuto()
125 return
126 }
127 await call($)
128 } catch {
129 // A failed tick is skipped; the next one tries again.
130 }
131 })
132}
133
134function summary(g: Game): string {
135 if (!g.you.length) return 'No game yet.'
136 const last = g.called.length ? callLabel(g.called[g.called.length - 1]) : 'none'
137 const state = g.winner === 'you' ? 'You won with ' + g.winningLine + '.' : g.winner === 'claude' ? 'Claude won with ' + g.winningLine + '.' : g.claudeLine ? 'Claude has ' + g.claudeLine + '; shout first!' : 'In play.'
138 return 'Bingo: ' + g.called.length + '/75 called, last ' + last + '. ' + state + ' /bingo opens the pane.'
139}
140
141export function register(on) {
142 on('session.start', async ($, e, next) => {
143 help = null
144 try {
145 await load($)
146 } catch (error) {
147 $.ui.log('bingo could not load its game: ' + error)
148 }
149 try {
150 await $.command.register({ name: 'bingo', description: 'Play bingo against Claude', argumentHint: '[new | speed N | stats]', immediate: true })
151 } catch (error) {
152 $.ui.log('could not register /bingo: ' + error)
153 }
154 return next(e)
155 })
156
157 // /clear and /resume reset $.state; the game lives on in the store.
158 on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
159 await load($)
160 return next(e)
161 }).catch(async ($, e, next) => next(e))
162
163 on('command.run', { command: 'bingo' }, async ($, e) => {
164 const args = e.args.trim()
165 if (args === 'new') {
166 const g = await startNew($)
167 return { text: 'New game. ' + summary(g) }
168 }
169 const speed = /^speed\s+(\d+)$/.exec(args)
170 if (speed) {
171 const s = Math.max(1, Math.min(120, Number(speed[1])))
172 await change($, (g) => ({ ...g, speed: s }))
173 if (timer) await startAuto($)
174 return { text: 'Auto calls a number every ' + s + ' second' + (s === 1 ? '' : 's') + '.' }
175 }
176 if (args === 'stats') {
177 const s = (await read($, game)).stats
178 return { text: 'Games ' + s.games + ' · you ' + s.wins + ' · Claude ' + s.losses }
179 }
180 if (args) return { text: 'Usage: /bingo, /bingo new, /bingo speed N, or /bingo stats' }
181 // Under claude -p nothing draws, and ui.open still reports the pane as placed:
182 // the surfaces list is the reliable sign.
183 let surfaces = []
184 try {
185 surfaces = await $.session.surfaces()
186 } catch {
187 surfaces = []
188 }
189 if (!surfaces.length) return { text: summary(await read($, game)) }
190 const placed = await $.ui.open({ id: PANE, title: 'Bingo', focus: true, closeOnEscape: true, rows: 30 })
191 if (!placed.isPlaced) return { text: summary(await read($, game)) }
192 const g = await read($, game)
193 if (g.auto && !g.winner) await startAuto($)
194 return {}
195 }).catch(async () => ({ text: 'bingo: the command failed.' }))
196
197 // Closing the pane stops the caller.
198 on('ui.close', { id: PANE }, async ($, e, next) => {
199 stopAuto()
200 return next(e)
201 }).catch(async ($, e, next) => next(e))
202
203 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
204 if (e.requestId !== PANE) return next(e)
205 const g = await read($, game)
206 const { Box, Text, Button } = $.ui.resolve(e)
207 if (!g.you.length) return Text({ children: ['No game loaded. /bingo new starts one.'] })
208 const columns = e.props.bodyColumns || 60
209 const wide = columns >= 60
210 const lastNum = g.called.length ? g.called[g.called.length - 1] : 0
211 const recent = g.called.slice(-8).map(callLabel).join(' ')
212 // The how-to opens with a fresh game where there is room for it.
213 const showHelp = help ?? (wide && g.called.length === 0)
214
215 // Called numbers on your card you haven't marked: the thing to do next.
216 const toMark = g.you.map((n, pos) => (pos !== FREE && g.called.includes(n) && !g.yourMarks.includes(pos) ? n : 0)).filter(Boolean)
217 const yourLine = g.winner ? null : yourLines(g)[0] ?? null
218 const litYours = new Set(lineCells(g.winner === 'you' ? g.winningLine : yourLine))
219 const litClaude = new Set(lineCells(g.winner === 'claude' ? g.winningLine : g.claudeLine))
220
221 const status = g.winner === 'you' ? { text: 'BINGO! You won with ' + g.winningLine + '. Press n for a new game.', color: 'success', bold: true }
222 : g.winner === 'claude' ? { text: 'Claude won with ' + g.winningLine + '. Press n for a new game.', color: 'error', bold: true }
223 : yourLine ? { text: 'You have ' + yourLine + '! Press b to shout Bingo' + (g.claudeLine ? ' before the next call.' : '.'), color: 'success', bold: true }
224 : g.claudeLine ? { text: 'Claude has ' + g.claudeLine + ' and claims at the next call. Shout first: b', color: 'warning', bold: true }
225 : toMark.length ? { text: 'On your card: ' + toMark.map(callLabel).join(', ') + '. Tab or click the lit cell, then Enter to mark it.', color: 'warning' }
226 : g.called.length ? { text: 'Nothing new on your card. Press c for the next number.', dim: true }
227 : { text: 'Press c to call the first number.', color: 'suggestion' }
228
229 const header = Box({
230 flexDirection: 'column',
231 children: [
232 Box({ flexDirection: 'row', columnGap: 2, flexWrap: 'wrap', children: [
233 Text({ bold: true, children: ['BINGO'] }),
234 Text({ dimColor: true, children: ['you vs Claude'] }),
235 Text({ children: ['called ' + g.called.length + '/75'] }),
236 Text({ bold: true, color: 'inverseText', backgroundColor: 'claude', children: [lastNum ? ' last ' + callLabel(lastNum) + ' ' : ' no call yet '] }),
237 ] }),
238 Text({ dimColor: true, wrap: 'truncate', children: [recent ? 'recent: ' + recent : 'recent: none'] }),
239 Text({ color: status.color, dimColor: status.dim, bold: status.bold, wrap: 'wrap', children: [status.text] }),
240 ],
241 })
242
243 // Every cell is a fixed-width Box with its content at the right edge, so the
244 // columns line up on a surface that collapses spaces or draws a proportional
245 // font; nothing is aligned by padding. The center cell reads FREE, four wide.
246 const CELL = 4
247 const cell = (children, backgroundColor?: string) => Box({ width: CELL, justifyContent: 'flex-end', backgroundColor, children })
248 const letters = Box({ flexDirection: 'row', columnGap: 1, children: LETTERS.map((l) => cell([Text({ bold: true, children: [l] })])) })
249 const yourRows = [0, 1, 2, 3, 4].map((r) =>
250 Box({
251 flexDirection: 'row',
252 columnGap: 1,
253 children: [0, 1, 2, 3, 4].map((c) => {
254 const pos = r * 5 + c
255 const n = g.you[pos]
256 const marked = g.yourMarks.includes(pos)
257 const calledNum = pos === FREE || g.called.includes(n)
258 const label = pos === FREE ? 'FREE' : String(n)
259 // A completed line: the accent. Marked: green. Called and not yet marked:
260 // yellow, the cell to press. Not called: dim. The Button itself takes no
261 // color, so the Box behind it carries the state.
262 const backgroundColor = litYours.has(pos) ? 'suggestion' : marked ? 'success' : calledNum ? 'warning' : undefined
263 const button = Button({ key: 'cell-' + pos, label, plain: true, dimColor: !calledNum, onPress: async () => {
264 let error: string | undefined
265 await change($, (cur) => {
266 const r = toggleMark(cur, pos)
267 error = r.error
268 return r.game
269 })
270 if (error) $.ui.toast(error)
271 } })
272 return cell([button], backgroundColor)
273 }),
274 }),
275 )
276 const yourCard = Box({ flexDirection: 'column', children: [Text({ bold: true, color: 'success', children: ['Your card'] }), letters, ...yourRows] })
277
278 const claudeRows = [0, 1, 2, 3, 4].map((r) =>
279 Box({
280 flexDirection: 'row',
281 columnGap: 1,
282 children: [0, 1, 2, 3, 4].map((c) => {
283 const pos = r * 5 + c
284 const done = claudeMarked(g, pos)
285 const lit = litClaude.has(pos)
286 return cell([Text({ color: done ? 'inverseText' : undefined, dimColor: !done, bold: lit, children: [pos === FREE ? 'FREE' : String(g.claude[pos])] })], lit ? 'error' : done ? 'claude' : undefined)
287 }),
288 }),
289 )
290 const claudeCard = Box({ flexDirection: 'column', children: [Text({ bold: true, color: 'claude', children: ['Claude\'s card'] }), letters, ...claudeRows] })
291
292 const cards = wide ? Box({ flexDirection: 'row', columnGap: 4, children: [yourCard, claudeCard] }) : Box({ flexDirection: 'column', rowGap: 1, children: [yourCard, claudeCard] })
293
294 const legend = Box({ flexDirection: 'row', columnGap: 2, flexWrap: 'wrap', children: [
295 Text({ backgroundColor: 'warning', children: [' 12 '] }),
296 Text({ dimColor: true, children: [wide ? 'called, mark it' : 'mark it'] }),
297 Text({ backgroundColor: 'success', children: [' 12 '] }),
298 Text({ dimColor: true, children: ['marked'] }),
299 Text({ backgroundColor: 'suggestion', children: [' 12 '] }),
300 Text({ dimColor: true, children: [wide ? 'a full line' : 'line'] }),
301 Text({ dimColor: true, children: ['12'] }),
302 Text({ dimColor: true, children: ['not called'] }),
303 ] })
304
305 const howTo = Box({ flexDirection: 'column', borderStyle: 'round', borderColor: 'suggestion', paddingX: 1, children: [
306 Text({ bold: true, children: ['How to play'] }),
307 Text({ wrap: 'wrap', children: ['1. Press c to call a number, or a to call one every ' + g.speed + ' seconds.'] }),
308 Text({ wrap: 'wrap', children: ['2. A called number on your card lights up yellow: Tab or click it, then Enter to mark it.'] }),
309 Text({ wrap: 'wrap', children: ['3. Five in a row, column, or diagonal (FREE counts): press b. Claude claims one call late, so shout first.'] }),
310 Text({ dimColor: true, wrap: 'wrap', children: ['Keys need the pane focused: click it if nothing happens. h hides this.'] }),
311 ] })
312
313 const footer = Box({
314 flexDirection: 'row',
315 columnGap: 3,
316 flexWrap: 'wrap',
317 children: [
318 Button({ key: 'call', label: 'Call', hotkey: 'c', plain: true, onPress: () => call($) }),
319 Button({ key: 'auto', label: g.auto ? 'Auto on' : 'Auto off', hotkey: 'a', plain: true, onPress: async () => {
320 const updated = await change($, (cur) => (cur.winner ? cur : { ...cur, auto: !cur.auto }))
321 if (updated.auto) await startAuto($)
322 else stopAuto()
323 } }),
324 Button({ key: 'claim', label: 'Bingo!', hotkey: 'b', plain: true, onPress: async () => {
325 let error: string | undefined
326 const updated = await change($, (cur) => {
327 const r = claim(cur)
328 error = r.error
329 return r.error ? cur : { ...r.game, auto: false }
330 })
331 if (error) $.ui.toast(error)
332 else {
333 stopAuto()
334 $.ui.toast('BINGO! You won with ' + updated.winningLine + '.', { timeoutMs: 6000 })
335 }
336 } }),
337 Button({ key: 'new', label: 'New game', hotkey: 'n', plain: true, onPress: () => startNew($) }),
338 Button({ key: 'help', label: showHelp ? 'Hide help' : 'Help', hotkey: 'h', plain: true, dimColor: true, onPress: () => {
339 help = !showHelp
340 $.ui.invalidate('ui.render')
341 } }),
342 Text({ dimColor: true, children: ['Esc closes · you ' + g.stats.wins + ' · Claude ' + g.stats.losses] }),
343 ],
344 })
345
346 return Box({ flexDirection: 'column', rowGap: 1, children: showHelp ? [header, cards, legend, howTo, footer] : [header, cards, legend, footer] })
347 })
348}
349hooks/game.ts 129 lines1// The rules of 75-ball bingo, pure: no `$` here, so the tests and the hooks
2// module share one implementation.
3
4import type { Game, Stats } from '../types'
5
6export const FREE = 12
7export const LETTERS = ['B', 'I', 'N', 'G', 'O']
8export const DEFAULT_SPEED = 8
9
10function hash(s: string): number {
11 let h = 2166136261
12 for (let i = 0; i < s.length; i++) {
13 h ^= s.charCodeAt(i)
14 h = Math.imul(h, 16777619)
15 }
16 return h >>> 0
17}
18
19function rng(seed: number): () => number {
20 let a = seed
21 return () => {
22 a = (a + 0x6d2b79f5) | 0
23 let t = Math.imul(a ^ (a >>> 15), 1 | a)
24 t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
25 return ((t ^ (t >>> 14)) >>> 0) / 4294967296
26 }
27}
28
29// A card: each column draws five of its fifteen numbers; the center is FREE (0).
30export function makeCard(seed: number, salt: string): number[] {
31 const r = rng(hash(seed + ':' + salt))
32 const card = new Array<number>(25).fill(0)
33 for (let col = 0; col < 5; col++) {
34 const pool = Array.from({ length: 15 }, (_, i) => col * 15 + i + 1)
35 for (let row = 0; row < 5; row++) {
36 if (row === 2 && col === 2) continue
37 const i = Math.floor(r() * pool.length)
38 card[row * 5 + col] = pool.splice(i, 1)[0]
39 }
40 }
41 return card
42}
43
44export function newGame(seed: number, stats: Stats, speed: number, auto: boolean): Game {
45 return { seed, you: makeCard(seed, 'you'), claude: makeCard(seed, 'claude'), called: [], yourMarks: [FREE], claudeLine: null, winner: null, winningLine: null, auto, speed, stats }
46}
47
48export function letterOf(n: number): string {
49 return LETTERS[Math.floor((n - 1) / 15)] ?? '?'
50}
51
52export function callLabel(n: number): string {
53 return letterOf(n) + '-' + n
54}
55
56const LINES: [string, number[]][] = [
57 ...[0, 1, 2, 3, 4].map((r): [string, number[]] => ['row ' + (r + 1), [0, 1, 2, 3, 4].map((c) => r * 5 + c)]),
58 ...[0, 1, 2, 3, 4].map((c): [string, number[]] => ['column ' + LETTERS[c], [0, 1, 2, 3, 4].map((r) => r * 5 + c)]),
59 ['diagonal ↘', [0, 6, 12, 18, 24]],
60 ['diagonal ↗', [4, 8, 12, 16, 20]],
61]
62
63export function linesOf(marked: (pos: number) => boolean): string[] {
64 return LINES.filter(([, cells]) => cells.every(marked)).map(([name]) => name)
65}
66
67// The cells of a named line, so the drawing can light it; [] for an unknown name.
68export function lineCells(name: string | null): number[] {
69 return LINES.find(([n]) => n === name)?.[1] ?? []
70}
71
72// Claude's marks are every called number on its card, and FREE.
73export function claudeMarked(g: Game, pos: number): boolean {
74 return pos === FREE || g.called.includes(g.claude[pos])
75}
76
77export function yourLines(g: Game): string[] {
78 return linesOf((pos) => g.yourMarks.includes(pos))
79}
80
81export function claudeLines(g: Game): string[] {
82 return linesOf((pos) => claudeMarked(g, pos))
83}
84
85// The next call. If Claude already has a line, Claude claims it instead:
86// the window to shout first was the time since the previous call.
87export function callNumber(g: Game): Game {
88 if (g.winner) return g
89 if (g.claudeLine) {
90 return { ...g, winner: 'claude', winningLine: g.claudeLine, stats: { ...g.stats, losses: g.stats.losses + 1 } }
91 }
92 const left = Array.from({ length: 75 }, (_, i) => i + 1).filter((n) => !g.called.includes(n))
93 if (!left.length) return g
94 const r = rng(hash(g.seed + ':call:' + g.called.length))
95 const n = left[Math.floor(r() * left.length)]
96 const next = { ...g, called: [...g.called, n] }
97 const lines = claudeLines(next)
98 return { ...next, claudeLine: lines.length ? lines[0] : null }
99}
100
101export type MarkResult = { game: Game; error?: string }
102
103export function toggleMark(g: Game, pos: number): MarkResult {
104 if (g.winner) return { game: g, error: 'The game is over. Press n for a new one.' }
105 if (pos === FREE) return { game: g, error: 'FREE is always marked.' }
106 const n = g.you[pos]
107 if (!g.called.includes(n)) return { game: g, error: callLabel(n) + " hasn't been called." }
108 const yourMarks = g.yourMarks.includes(pos) ? g.yourMarks.filter((p) => p !== pos) : [...g.yourMarks, pos]
109 return { game: { ...g, yourMarks } }
110}
111
112export function claim(g: Game): MarkResult {
113 if (g.winner) return { game: g, error: 'The game is over.' }
114 const lines = yourLines(g)
115 if (!lines.length) return { game: g, error: 'Not a bingo: no full row, column, or diagonal yet.' }
116 return { game: { ...g, winner: 'you', winningLine: lines[0], stats: { ...g.stats, wins: g.stats.wins + 1 } } }
117}
118
119export function isGame(x: unknown): x is Game {
120 if (!x || typeof x !== 'object') return false
121 const g = x as Record<string, unknown>
122 const card = (c: unknown) => Array.isArray(c) && c.length === 25 && c.every((n) => Number.isInteger(n))
123 const numbers = (c: unknown) => Array.isArray(c) && c.every((n) => Number.isInteger(n))
124 const stats = g.stats as Record<string, unknown> | undefined
125 return typeof g.seed === 'number' && card(g.you) && card(g.claude) && numbers(g.called) && numbers(g.yourMarks) && typeof g.speed === 'number' &&
126 (g.winner === null || g.winner === 'you' || g.winner === 'claude') && !!stats && typeof stats === 'object' &&
127 ['games', 'wins', 'losses'].every((k) => Number.isInteger(stats[k]))
128}
129types/index.d.ts 28 lines1export type Stats = { games: number; wins: number; losses: number }
2export type Game = {
3 /** Seed of this game's cards and call order; 0 until a game exists. */
4 seed: number
5 /** Your card, 25 cells in row order; 0 is the FREE center. */
6 you: number[]
7 /** Claude's card, the same shape. */
8 claude: number[]
9 /** Numbers called so far, in order. */
10 called: number[]
11 /** Positions on your card you marked. FREE (12) is always in it. */
12 yourMarks: number[]
13 /** A line Claude completed; Claude claims it at the next call. */
14 claudeLine: string | null
15 winner: 'you' | 'claude' | null
16 winningLine: string | null
17 /** Call a number every `speed` seconds while the pane is open. */
18 auto: boolean
19 speed: number
20 stats: Stats
21}
22
23declare module 'claude-code' {
24 interface PluginState {
25 bingo: { game: Game }
26 }
27}
28