SLOPSHOPPER

bingo

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…

newpanecommandtoasttimer
v0.1.1MITupdated 2026-10-15MDmubarak786/claude-mods/mods/bingo
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · bingo
│ ┃ Bingo ✕ › fix the failing auth test and add an audit log call │ ┃ BINGO you vs Claude called 0/75 no call │ ┃ recent: none ⏺ Read(src/auth.ts) │ ┃ Press c to call the first number. ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ Your card ⎿ Added 2 lines, removed 1 line │ ┃ B I N G O ⏺ Bash(bun test) │ ┃ 9 17 35 46 71 ⎿ 3 pass, 1 fail │ ┃ 6 24 32 55 74 │ ┃ 12 20 FREE 47 66 ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ 11 29 41 52 62 │ ┃ 1 16 36 50 73 ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ Claude's card › /bingo │ ┃ B I N G O │ ┃ 6 30 37 46 71 │ ┃ 3 21 38 53 65 │ ┃ 7 16 FREE 47 63 │ ┃ 15 18 33 54 64 │ ┃ 1 29 39 58 75 │ ┃ │ ┃ 12 mark it 12 marked 12 line 12 │ ┃ │ ┃ c: Call a: Auto off b: Bingo! n: New g │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Bingo
BINGO you vs Claude called 0/75 no call yet recent: none Press c to call the first number. Your card B I N G O 9 17 35 46 71 6 24 32 55 74 12 20 FREE 47 66 11 29 41 52 62 1 16 36 50 73 Claude's card B I N G O 6 30 37 46 71 3 21 38 53 65 7 16 FREE 47 63 15 18 33 54 64 1 29 39 58 75 12 mark it 12 marked 12 line 12 not called c: Call a: Auto off b: Bingo! n: New game h: Help
README

bingo

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.

Bingo against Claude in a pane: one call completes your row 1 and Claude's row 2, Claude's line waits a call, and you shout first.

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.

Install

/plugin marketplace add MDmubarak786/claude-mods
/plugin install bingo@modhub

Try it for one session without installing:

claude --plugin-dir ./mods/bingo

How to play

  1. Run /bingo. The pane opens with your card on the left and Claude's on the right.
  2. Press c to call a number, or a to turn on Auto, which calls one every 8 seconds while the pane is open.
  3. When a called number is on your card, the cell lights up yellow and the status line names it. Tab or click to that cell and press Enter to mark it: it turns green. Press again to unmark. Uncalled numbers stay dim.
  4. When you have a full row, column, or diagonal, the line takes the accent color and the status line says so: press b. A false shout just gets a toast.
  5. Claude marks its card by itself. When it completes a line, a toast says so, the line is drawn in red on Claude's card, and the status line turns yellow unless you have a line of your own to shout. Claude claims at the next call, so shout first and you win.
  6. n starts a new game, h shows or hides the how-to. Esc closes the pane; the game and the score are kept.

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.

CommandWhat it does
/bingoOpen the pane. Where no pane can be drawn, print the game's state.
/bingo newStart a new game. The win-loss record is kept.
/bingo speed 5Auto calls a number every 5 seconds (1 to 120; default 8).
/bingo statsGames 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.

What it touches

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.
  • 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, 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.

Limitations

  • One game at a time per machine. A session loads the saved game when it starts and saves on every change, so two sessions playing at once overwrite each other: the last write wins.
  • Auto runs only while the pane is open, and turns itself off when a game ends. Closing the pane pauses the game.
  • The cards are laid out as a grid of fixed-width cells rather than padded text, so they line up on the Desktop app as well as in a terminal. A theme with no color for warning, success, or suggestion shows the cells uncolored; the status line still names what to mark.
  • Claude's card is drawn from the same pool as yours, so the two cards can share numbers; that's how real bingo works too.
  • The caller is pseudo-random from the game's seed, which makes a game reproducible from its seed rather than truly random.

License

MIT, see the repository root.

Source 3 files
hooks/register.ts 349 lines
1// 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}
349
hooks/game.ts 129 lines
1// 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}
129
types/index.d.ts 28 lines
1export 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