SLOPSHOPPER

blackjack

Claude offers a hand of blackjack while it works on a long task

newpanebandguardcommandstatus
v0.2.0MITupdated 2026-10-10gszecsenyi/blackjack-claude-mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · blackjack
│ ┃ Blackjack ✕ › fix the failing auth test and add an audit log call │ ┃ Get closer to 21 than the dealer without │ ┃ going over. Face cards are 10, aces 1 or 11; ⏺ Read(src/auth.ts) │ ┃ the dealer draws to 17, blackjack pays 3:2. ⎿ Read 6 lines │ ┃ Press Deal (n) to start. Bet: 50. ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ Chips: 1000 ⏺ Bash(bun test) │ ┃ [ Deal ] [ Close ] ⎿ 3 pass, 1 fail │ ┃ n Deal · ←→/Tab move · Enter press · Esc │ ┃ close ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /blackjack │ ⎿ blackjack: Blackjack table open (Esc returns to the prompt). │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Blackjack
Get closer to 21 than the dealer without going over. Face cards are 10, aces 1 or 11; the dealer draws to 17, blackjack pays 3:2. Press Deal (n) to start. Bet: 50. Chips: 1000 [ Deal ] [ Close ] n Deal · ←→/Tab move · Enter press · Esc close
README

Blackjack mod for Claude Code

When Claude starts a task it expects to take a while, it offers you a hand of blackjack in a side pane. You play while it works.

🃏 Big refactor – this will take a while. Fancy a hand of blackjack?  [ Play ] [ No thanks ] [ Don't ask again ]

The blackjack table in a Claude Code side pane

How it works

  • The mod registers a tool, mcp__blackjack__offer_blackjack. Claude itself decides when to call it: at the start of each long task. There is no timer or heuristic.
  • The offer appears as a band above the prompt: at most once per task, never while you're already playing. Don't ask again turns it off for good.
  • The game runs locally in a pane: it uses no tokens, and the model never sees it.
  • Keys: h Hit, s Stand, d Double, n new deal; arrows/Tab move between buttons, Enter presses; Esc closes the table.
  • Rules: a 4-deck shoe, the dealer draws to 17, blackjack pays 3:2, the bet is 50.
  • Chips persist across sessions. You start with 1000, and when you run out you get a fresh 1000.
  • When Claude finishes, the status line tells you, and the game stays open.
  • /blackjack opens the table at any time, even while a turn is running.

Install

Inside Claude Code:

/plugin marketplace add gszecsenyi/blackjack-claude-mod
/plugin install blackjack@blackjack-claude-mod
/reload-plugins
/blackjack

Or from a local clone: claude --plugin-dir /path/to/blackjack-claude-mod.

Develop

claude plugin validate .
claude plugin test .

License

MIT – see LICENSE.

Source 3 files
hooks/register.tsx 204 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Card, Game } from '../types'
5import {
6  START_CHIPS,
7  canDouble,
8  cryptoRandom,
9  deal,
10  double,
11  hit,
12  label,
13  newGame,
14  stand,
15  value,
16} from './game'
17
18const PANE = 'blackjack'
19const TOOL = 'offer_blackjack'
20
21const game = atom({ plugin: 'blackjack', key: 'game' } as const, null)
22const isOfferShown = atom({ plugin: 'blackjack', key: 'isOfferShown' } as const, false)
23const wasOffered = atom({ plugin: 'blackjack', key: 'wasOffered' } as const, false)
24const offerReason = atom({ plugin: 'blackjack', key: 'offerReason' } as const, '')
25
26const RULES =
27  'Get closer to 21 than the dealer without going over. Face cards are 10, aces 1 or 11; the dealer draws to 17, blackjack pays 3:2.'
28
29const DESCRIPTION = [
30  'Offers the user a hand of blackjack in a side pane while you keep working.',
31  'Call it once per task, right as you start a task you expect to take several minutes',
32  '(many files to change, a long build or test run, a broad refactor or investigation).',
33  'Do not call it for short tasks or quick questions. It returns immediately; carry on with the task.',
34].join(' ')
35
36async function loadChips($: EngineInterface): Promise<number> {
37  const stored = await $.store.get('chips')
38
39  return typeof stored === 'number' && stored > 0 ? stored : START_CHIPS
40}
41
42async function play($: EngineInterface, move: (g: Game) => Game) {
43  const chips = await loadChips($)
44  const after = await update($, game, g => move(g ?? newGame(chips)))
45  await $.store.set('chips', after.chips)
46}
47
48const openTable = ($: EngineInterface) =>
49  $.ui.open({ id: PANE, title: 'Blackjack', focus: true, closeOnEscape: true, holdToasts: true, rows: 12 })
50
51const hand = (cards: readonly Card[], isHidden = false) =>
52  cards.map((card, i) => (isHidden && i === 1 ? '🂠' : label(card))).join('  ')
53
54export const register: Register = on => {
55  on('session.start', async ($, e, next) => {
56    await $.tool.register({
57      name: TOOL,
58      description: DESCRIPTION,
59      inputSchema: {
60        type: 'object',
61        properties: {
62          reason: { type: 'string', description: 'A few words on why this will take a while' },
63        },
64      },
65    })
66    await $.command.register({
67      name: 'blackjack',
68      description: 'Open the blackjack table',
69      immediate: true,
70    })
71
72    return next(e)
73  })
74
75  on('tool.call', { tool: 'mcp__blackjack__offer_blackjack' }, async ($, e) => {
76    if ((await $.store.get('optOut')) === true) {
77      return { result: 'The user turned blackjack offers off. Carry on with the task.' }
78    }
79    if (await read($, wasOffered)) {
80      return { result: 'Already offered for this task. Carry on with the task.' }
81    }
82    if ((await $.ui.panes()).some(pane => pane.id === PANE)) {
83      return { result: 'The user is already playing. Carry on with the task.' }
84    }
85    const reason = (e as { reason?: unknown }).reason
86    await update($, offerReason, () => (typeof reason === 'string' ? reason.slice(0, 80) : ''))
87    await update($, wasOffered, () => true)
88    await update($, isOfferShown, () => true)
89
90    return { result: 'Offered. Carry on with the task; do not mention the game.' }
91  })
92
93  on('command.run', { command: 'blackjack' }, async $ => {
94    await update($, isOfferShown, () => false)
95    await openTable($)
96
97    return { text: 'Blackjack table open (Esc returns to the prompt).' }
98  })
99
100  on('turn.complete', async ($, e, next) => {
101    await update($, isOfferShown, () => false)
102    await update($, wasOffered, () => false)
103    const isPlaying = (await $.ui.panes()).some(pane => pane.id === PANE)
104    if (isPlaying) {
105      $.ui.status('🃏 Claude is done – your game will wait.')
106    }
107
108    return next(e)
109  })
110
111  on('ui.close', ($, e, next) => {
112    if (e.id === PANE) {
113      $.ui.status(undefined)
114    }
115
116    return next(e)
117  })
118
119  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
120    if (e.props.hasSurvey || !(await read($, isOfferShown))) {
121      return next(e)
122    }
123    const { Box, Button, Text } = $.ui.resolve(e)
124    const reason = await read($, offerReason)
125    const hide = () => update($, isOfferShown, () => false)
126
127    return (
128      <Box gap={1}>
129        <Text>
130          🃏 {reason ? `${reason} – this` : 'This'} will take a while. Fancy a hand of blackjack?
131        </Text>
132        <Button
133          key="play"
134          label="Play"
135          variant="primary"
136          onPress={async () => {
137            await hide()
138            await openTable($)
139          }}
140        />
141        <Button key="no" label="No thanks" onPress={hide} />
142        <Button
143          key="never"
144          label="Don't ask again"
145          dimColor
146          onPress={async () => {
147            await $.store.set('optOut', true)
148            await hide()
149          }}
150        />
151      </Box>
152    )
153  })
154
155  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
156    const { Box, Button, Text } = $.ui.resolve(e)
157    const g = (await read($, game)) ?? newGame(await loadChips($))
158    const isLive = g.phase === 'player'
159    const random = cryptoRandom
160    const keys = isLive ? `h Hit · s Stand${canDouble(g) ? ' · d Double' : ''}` : 'n Deal'
161    const help = `${keys} · ←→/Tab move · Enter press · Esc close`
162
163    return (
164      <Box flexDirection="column">
165        <Text dimColor>{RULES}</Text>
166        {g.phase === 'idle' ? (
167          <Text dimColor>Press Deal (n) to start. Bet: 50.</Text>
168        ) : (
169          <Box flexDirection="column">
170            <Text>
171              Dealer: {hand(g.dealer, isLive)}
172              <Text dimColor> {isLive ? '' : `(${value(g.dealer)})`}</Text>
173            </Text>
174            <Text>
175              You:    {hand(g.player)}
176              <Text dimColor> ({value(g.player)})</Text>
177            </Text>
178          </Box>
179        )}
180        <Text bold={g.phase === 'done'}>{g.message || ' '}</Text>
181        <Text dimColor>
182          Chips: {g.chips}
183          {isLive ? `   Bet: ${g.bet}` : ''}
184        </Text>
185        <Box gap={1}>
186          {isLive ? (
187            <Box gap={1}>
188              <Button key="hit" label="Hit" hotkey="h" variant="primary" autoFocus onPress={() => play($, s => hit(s, random))} />
189              <Button key="stand" label="Stand" hotkey="s" onPress={() => play($, s => stand(s, random))} />
190              {canDouble(g) && (
191                <Button key="double" label="Double" hotkey="d" onPress={() => play($, s => double(s, random))} />
192              )}
193            </Box>
194          ) : (
195            <Button key="deal" label="Deal" hotkey="n" variant="primary" autoFocus onPress={() => play($, s => deal(s, random))} />
196          )}
197          <Button key="close" label="Close" role="dismiss" dimColor onPress={() => $.ui.close({ id: PANE })} />
198        </Box>
199        <Text dimColor>{help}</Text>
200      </Box>
201    )
202  })
203}
204
hooks/game.ts 165 lines
1import type { Card, Game, Suit } from '../types'
2
3export const START_CHIPS = 1000
4export const BET = 50
5
6const RANKS = ['A', '2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K']
7const SUITS: Suit[] = ['♠', '♥', '♦', '♣']
8const DECKS = 4
9const RESHUFFLE_BELOW = 20
10
11export type Random = () => number
12
13export const cryptoRandom: Random = () => {
14  const one = new Uint32Array(1)
15  crypto.getRandomValues(one)
16
17  return (one[0] ?? 0) / 2 ** 32
18}
19
20export function shoe(random: Random): Card[] {
21  const cards: Card[] = []
22  for (let d = 0; d < DECKS; d++) {
23    for (const suit of SUITS) {
24      for (const rank of RANKS) {
25        cards.push({ rank, suit })
26      }
27    }
28  }
29  for (let i = cards.length - 1; i > 0; i--) {
30    const j = Math.floor(random() * (i + 1))
31    const a = cards[i] as Card
32    cards[i] = cards[j] as Card
33    cards[j] = a
34  }
35
36  return cards
37}
38
39export function value(hand: readonly Card[]): number {
40  let total = 0
41  let aces = 0
42  for (const card of hand) {
43    if (card.rank === 'A') {
44      aces += 1
45      total += 11
46    } else if (card.rank === 'J' || card.rank === 'Q' || card.rank === 'K') {
47      total += 10
48    } else {
49      total += Number(card.rank)
50    }
51  }
52  while (total > 21 && aces > 0) {
53    total -= 10
54    aces -= 1
55  }
56
57  return total
58}
59
60export const isBlackjack = (hand: readonly Card[]) => hand.length === 2 && value(hand) === 21
61
62export const label = (card: Card) => `${card.rank}${card.suit}`
63
64export function newGame(chips: number): Game {
65  return { deck: [], player: [], dealer: [], phase: 'idle', bet: 0, chips, message: '' }
66}
67
68function draw(deck: Card[], random: Random): { card: Card; deck: Card[] } {
69  const from = deck.length > 0 ? deck : shoe(random)
70  const [card, ...rest] = from
71
72  return { card: card as Card, deck: rest }
73}
74
75export function deal(game: Game, random: Random): Game {
76  const chips = game.chips < BET ? START_CHIPS : game.chips
77  let deck = game.deck.length < RESHUFFLE_BELOW ? shoe(random) : game.deck
78  const take = () => {
79    const drawn = draw(deck, random)
80    deck = drawn.deck
81
82    return drawn.card
83  }
84  const player = [take()]
85  const dealer = [take()]
86  player.push(take())
87  dealer.push(take())
88
89  const next: Game = {
90    deck,
91    player,
92    dealer,
93    phase: 'player',
94    bet: BET,
95    chips: chips - BET,
96    message: game.chips < BET ? 'Out of chips – here are 1000 more.' : '',
97  }
98
99  return isBlackjack(player) || isBlackjack(dealer) ? settle(next) : next
100}
101
102export function hit(game: Game, random: Random): Game {
103  if (game.phase !== 'player') {
104    return game
105  }
106  const { card, deck } = draw(game.deck, random)
107  const next = { ...game, deck, player: [...game.player, card] }
108
109  return value(next.player) >= 21 ? stand(next, random) : next
110}
111
112export const canDouble = (game: Game) =>
113  game.phase === 'player' && game.player.length === 2 && game.chips >= game.bet
114
115export function double(game: Game, random: Random): Game {
116  if (!canDouble(game)) {
117    return game
118  }
119  const { card, deck } = draw(game.deck, random)
120
121  return stand(
122    { ...game, deck, player: [...game.player, card], chips: game.chips - game.bet, bet: game.bet * 2 },
123    random,
124  )
125}
126
127export function stand(game: Game, random: Random): Game {
128  if (game.phase !== 'player') {
129    return game
130  }
131  if (value(game.player) > 21) {
132    return settle(game)
133  }
134  let deck = game.deck
135  const dealer = [...game.dealer]
136  while (value(dealer) < 17) {
137    const drawn = draw(deck, random)
138    deck = drawn.deck
139    dealer.push(drawn.card)
140  }
141
142  return settle({ ...game, deck, dealer })
143}
144
145export function settle(game: Game): Game {
146  const you = value(game.player)
147  const house = value(game.dealer)
148  const done = (won: number, message: string): Game => ({
149    ...game,
150    phase: 'done',
151    chips: game.chips + won,
152    message,
153  })
154
155  if (isBlackjack(game.player) && isBlackjack(game.dealer)) return done(game.bet, 'Both have blackjack – push.')
156  if (isBlackjack(game.player)) return done(game.bet + Math.floor(game.bet * 1.5), `Blackjack! +${Math.floor(game.bet * 1.5)}`)
157  if (isBlackjack(game.dealer)) return done(0, `Dealer has blackjack. −${game.bet}`)
158  if (you > 21) return done(0, `Bust (${you}). −${game.bet}`)
159  if (house > 21) return done(game.bet * 2, `Dealer busts (${house}). +${game.bet}`)
160  if (you > house) return done(game.bet * 2, `You win, ${you} vs ${house}. +${game.bet}`)
161  if (you < house) return done(0, `Dealer wins, ${house} vs ${you}. −${game.bet}`)
162
163  return done(game.bet, `Push, ${you}.`)
164}
165
types/index.d.ts 25 lines
1export type Suit = '♠' | '♥' | '♦' | '♣'
2export type Card = { rank: string; suit: Suit }
3export type Phase = 'idle' | 'player' | 'done'
4
5export type Game = {
6  deck: Card[]
7  player: Card[]
8  dealer: Card[]
9  phase: Phase
10  bet: number
11  chips: number
12  message: string
13}
14
15declare module 'claude-code' {
16  interface PluginState {
17    blackjack: {
18      game: Game | null
19      isOfferShown: boolean
20      wasOffered: boolean
21      offerReason: string
22    }
23  }
24}
25