A three-reel slot machine in the band, a pane, a toast and the spinner, paid in Claude Dollars you earn by prompting and Claude earns by calling tools.

A three-reel slot machine inside Claude Code, paid in CLD (Claude Dollars).

The machine lives in the band above the prompt. Beside the reels: the balance and the bet, the session's income by source, the session's spin stats, the lifetime hall of fame, and the last two spins.
Digits work at an empty prompt: 1 spins, 2 cycles the bet (25, 50, 100, 250, 500), 8 mutes and unmutes (kept across sessions), 0 hides the band to one line and brings it back. The buttons are clickable too. A session starts with the band hidden; the Band at start setting makes it expanded.

While a turn runs, the spinner line (Sauteing… 12s) carries a live meter: Claude's tool calls and what they will pay, your spins so far, the balance.
| source | pays |
|---|---|
| every character of a prompt you send | +1 |
| every tool call Claude makes | +10, paid at the end of the turn |
| every tenth tool call in a turn | +100 combo |
the word yeet in a prompt | +100 |
| spinning | whatever the reels say |
The balance lasts the session (it survives hot reloads). A hall of fame (lifetime net, biggest win, jackpots, type errors) persists across sessions. The balance may go negative: that is Claude Credit.
After each turn a toast and the band's readout say what we earned together: Claude's tool calls plus your spins. The turn's closing line (✻ Baked for 3s · done 6:04 PM) carries the tally too.
| symbol | three of a kind | pair |
|---|---|---|
| 7 | 77x (JACKPOT, with fanfare and a voice) | 1.2x |
| $ coin | 25x | 1.2x |
| bun | 20x | 1.2x |
| diamond | 15x | 1.2x |
| BAR | 10x | 1.2x |
| cherry | 6x | 2x; a single cherry returns 0.6x |
any | -4x (TYPE ERROR; your TypeScript license is revoked, audibly) | nothing |
The return to player is generous. The house is Claude and Claude is paid in tokens.
/slop or /slop stats prints session and lifetime stats/slop bet <n>/slop stats/slop reset/slop hide, /slop showThe command runs mid-turn too.
/config lists the mod's rows: Sounds (generated WAV clips, macOS), Announcer voice (macOS say on jackpots and type errors), Starting balance, Band at start (hidden or expanded). 8 on the band mutes both without touching the settings.
/plugin install slop-machine --marketplace wobondar/claude-plugins
Answer y to add the marketplace, then pick a scope. To run from a checkout instead: claude --plugin-dir ./mods/slop-machine.
claude plugin validate ./mods/slop-machine
claude plugin test ./mods/slop-machine
bun ./mods/slop-machine/scripts/make-sounds.ts
The reels are rendered as one Raster (25 by 7 cells) on the terminal and as text on other surfaces. A spin is 30 frames a second through $.ui.blit, no render pass; state is written only when a reel lands, each landing with a one-row bounce. The title is a second Raster, a retro gradient blitted every 140 ms. The pure parts live in hooks/reels.ts (strip, payouts, spin plan) and hooks/raster.ts (tiles, frame, base64 packing).
hooks/register.tsx 507 lines1import { atom, memberOf, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderElement, ResolveInput } from 'claude-code'
3
4import type { SlopHallOfFame, SlopHistoryEntry, SlopTally, SlopTurn, SlopWallet } from '../types'
5import {
6 BETS, CLD, COMBO_BONUS, COMBO_EVERY, SITES, SLOT, TOOL_PAY, YEET_BONUS,
7 FRAME_MS, formatCld, frameAt, idleMachine, isLanding, judge, nextBet, planSpin, signedCld, spinLength, toastFrame, visibleSymbols,
8} from './reels'
9import type { Frame, MachineState, Outcome, Site, SpinPlan } from './reels'
10import {
11 FRAME_COLUMNS, FRAME_ROWS, TITLE, TITLE_COLUMNS, TITLE_CYCLE_MS, TITLE_FRAME_MS, packCells, readoutFor, renderFrame, renderTitle, textReels,
12} from './raster'
13const MARQUEE_MS = 400
14
15const initialWallet = (balance: number): SlopWallet => ({
16 balance,
17 bet: BETS[0] as number,
18 fromPrompts: 0,
19 fromTools: 0,
20 fromSpins: 0,
21 wagered: 0,
22 won: 0,
23 spins: 0,
24 biggestWin: 0,
25})
26
27const idleTurn = (): SlopTurn => ({ isWorking: false, tools: 0, combos: 0, spins: 0, spinsNet: 0, startedAt: 0 })
28
29const emptyHall = (): SlopHallOfFame => ({
30 lifetimeNet: 0,
31 lifetimeSpins: 0,
32 biggestWin: 0,
33 jackpots: 0,
34 typeErrors: 0,
35 sessions: 0,
36})
37
38const wallet = atom({ plugin: 'slop-machine', key: 'wallet' } as const, initialWallet(100))
39const turn = atom({ plugin: 'slop-machine', key: 'turn' } as const, idleTurn())
40const tallies = atom({ plugin: 'slop-machine', key: 'tallies' } as const, {})
41const history = atom({ plugin: 'slop-machine', key: 'history' } as const, [])
42const marquee = atom({ plugin: 'slop-machine', key: 'marquee' } as const, 0)
43const isHidden = atom({ plugin: 'slop-machine', key: 'isHidden' } as const, false)
44const machine = atom({ plugin: 'slop-machine', key: 'machine' } as const, idleMachine())
45
46type Band = 'expanded' | 'hidden'
47
48type Settings = { sounds: boolean; announcer: boolean; startingBalance: number; band: Band }
49
50let settings: Settings = { sounds: true, announcer: true, startingBalance: 100, band: 'hidden' }
51
52const isMuted = atom({ plugin: 'slop-machine', key: 'isMuted' } as const, false)
53
54type LiveSpin = { plan: SpinPlan; tick: number; frame: Frame }
55
56let live: LiveSpin | null = null
57let bandRequestId: string | null = null
58let titleTick = 0
59let titleTimer: { cancel: () => void } | null = null
60let flashUntilTick = 0
61
62const titlePhase = (): number => ((titleTick * TITLE_FRAME_MS) / TITLE_CYCLE_MS) % 1
63
64const titleCells = (): string => packCells(renderTitle(titlePhase(), { chaser: titleTick, isFlashing: titleTick < flashUntilTick }))
65
66const flashTitle = (ms: number): void => {
67 flashUntilTick = titleTick + Math.ceil(ms / TITLE_FRAME_MS)
68}
69
70const liveView = (m: MachineState): MachineState => (live === null ? m : { ...m, phase: 'spinning', reels: live.frame.reels, stopped: live.frame.stopped })
71
72const blitMachine = async ($: EngineInterface, m: MachineState, bet: number, lit: number): Promise<void> => {
73 if (bandRequestId === null) return
74 const { readout, color } = readoutFor(m, bet, '1 to spin')
75 await $.ui.blit({ requestId: bandRequestId, key: 'band', cells: packCells(renderFrame(m, { lit, readout, readoutColor: color })) })
76}
77
78const startTitle = ($: EngineInterface): void => {
79 titleTimer?.cancel()
80 titleTimer = $.clock.every(TITLE_FRAME_MS, () => {
81 titleTick += 1
82 if (bandRequestId === null) return
83 void $.ui.blit({ requestId: bandRequestId, key: 'title', cells: titleCells() })
84 })
85}
86
87const formatDuration = (ms: number): string => {
88 const s = Math.round(ms / 1000)
89 if (s < 60) return `${s}s`
90 const m = Math.floor(s / 60)
91 if (m < 60) return `${m}m ${s - m * 60}s`
92 const h = Math.floor(m / 60)
93 return `${h}h ${m - h * 60}m`
94}
95
96const formatClock = (ms: number): string => {
97 const d = new Date(ms)
98 try {
99 return d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })
100 } catch {
101 const h = d.getHours()
102 const minute = String(d.getMinutes()).padStart(2, '0')
103 return `${h % 12 === 0 ? 12 : h % 12}:${minute} ${h < 12 ? 'AM' : 'PM'}`
104 }
105}
106
107const toggleMute = async ($: EngineInterface): Promise<void> => {
108 const muted = await update($, isMuted, held => !held)
109 await $.store.set('isMuted', muted)
110 $.ui.toast(muted ? `${SLOT} muted` : `${SLOT} unmuted`, { timeoutMs: 2000 })
111}
112const timers: Partial<Record<Site, { cancel: () => void }>> = {}
113let marqueeTimer: { cancel: () => void } | null = null
114
115const play = async ($: EngineInterface, name: 'spin' | 'win' | 'jackpot' | 'lose'): Promise<void> => {
116 if (!settings.sounds || (await read($, isMuted))) return
117 $.audio.play({ asset: `sounds/${name}.wav` }).catch(() => undefined)
118}
119
120const say = async ($: EngineInterface, text: string): Promise<void> => {
121 if (!settings.announcer || (await read($, isMuted))) return
122 $.audio.speak(text).catch(() => undefined)
123}
124
125let hall: SlopHallOfFame = emptyHall()
126
127const loadHall = async ($: EngineInterface): Promise<SlopHallOfFame> => {
128 hall = ((await $.store.get('hall')) as SlopHallOfFame | undefined) ?? emptyHall()
129 return hall
130}
131
132let hallChain: Promise<unknown> = Promise.resolve()
133
134const hallUpdate = ($: EngineInterface, change: (hall: SlopHallOfFame) => SlopHallOfFame): Promise<SlopHallOfFame> => {
135 const step = async (): Promise<SlopHallOfFame> => {
136 hall = change(await loadHall($))
137 await $.store.set('hall', hall)
138 return hall
139 }
140 const run = hallChain.then(step, step)
141 hallChain = run
142 return run
143}
144
145const settle = async ($: EngineInterface, site: Site, m: MachineState, bet: number, rng: () => number): Promise<Outcome> => {
146 const outcome = judge(visibleSymbols(m), bet, rng)
147 await update($, memberOf(machine, { requestId: site }), held => ({ ...held, phase: 'result' as const, outcome, spins: held.spins + 1 }))
148 const w = await update($, wallet, held => ({
149 ...held,
150 balance: held.balance + outcome.payout,
151 won: held.won + Math.max(0, outcome.payout),
152 fromSpins: held.fromSpins + outcome.payout,
153 biggestWin: Math.max(held.biggestWin, outcome.payout),
154 }))
155 await update($, turn, held => ({ ...held, spinsNet: held.spinsNet + outcome.payout }))
156 const at = await $.clock.now()
157 await update($, history, held => [{ site, outcome, bet, at } satisfies SlopHistoryEntry, ...held].slice(0, 30))
158 void hallUpdate($, hall => ({
159 ...hall,
160 lifetimeNet: hall.lifetimeNet + outcome.payout - bet,
161 lifetimeSpins: hall.lifetimeSpins + 1,
162 biggestWin: Math.max(hall.biggestWin, outcome.payout),
163 jackpots: hall.jackpots + (outcome.tier === 'jackpot' ? 1 : 0),
164 typeErrors: hall.typeErrors + (outcome.tier === 'cursed' ? 1 : 0),
165 }))
166
167 const line = `${toastFrame({ ...m, stopped: [true, true, true] })} ${outcome.title} ${outcome.payout === 0 ? '' : signedCld(outcome.payout)} ${CLD} · balance ${formatCld(w.balance)} ${CLD}`
168 if (outcome.tier === 'jackpot' || outcome.tier === 'big') flashTitle(outcome.tier === 'jackpot' ? 8000 : 3000)
169 if (outcome.tier === 'jackpot') {
170 void play($, 'jackpot')
171 void say($, `Jackpot. ${outcome.payout} Claude dollars.`)
172 $.ui.toast(`💥 ${line}`, { timeoutMs: 10000 })
173 } else if (outcome.tier === 'cursed') {
174 void play($, 'lose')
175 void say($, 'any. any. any. Your TypeScript license has been revoked.')
176 $.ui.toast(`☠️ ${line}`, { timeoutMs: 8000 })
177 } else if (outcome.tier === 'big') {
178 void play($, 'win')
179 $.ui.toast(`✨ ${line}`, { timeoutMs: 6000 })
180 } else if (outcome.payout > 0) {
181 void play($, 'win')
182 }
183 return outcome
184}
185
186const spin = async ($: EngineInterface, site: Site, rng: () => number = Math.random): Promise<void> => {
187 if (live !== null) return
188 const m = await read($, memberOf(machine, { requestId: site }))
189 const w = await read($, wallet)
190 const bet = w.bet
191 const plan: SpinPlan = planSpin(rng)
192 const start = [m.reels[0].index, m.reels[1].index, m.reels[2].index] as const
193 live = { plan, tick: 0, frame: frameAt(start, plan, 0) }
194
195 await update($, wallet, held => ({
196 ...held,
197 balance: held.balance - bet,
198 wagered: held.wagered + bet,
199 spins: held.spins + 1,
200 fromSpins: held.fromSpins - bet,
201 }))
202 await update($, turn, held => ({ ...held, spins: held.spins + 1, spinsNet: held.spinsNet - bet }))
203 await update($, memberOf(machine, { requestId: site }), held => ({ ...held, phase: 'spinning' as const, stopped: [false, false, false] as [boolean, boolean, boolean], outcome: null }))
204 void play($, 'spin')
205
206 let chain: Promise<void> = Promise.resolve()
207 const step = async (): Promise<void> => {
208 if (live === null) return
209 live.tick += 1
210 live.frame = frameAt(start, plan, live.tick)
211 const { tick, frame } = live
212 const shown: MachineState = { ...m, phase: 'spinning', reels: frame.reels, stopped: frame.stopped, outcome: null }
213 if (tick >= spinLength(plan)) {
214 timer.cancel()
215 delete timers[site]
216 live = null
217 const landed = await update($, memberOf(machine, { requestId: site }), held => ({ ...held, reels: frame.reels, stopped: frame.stopped }))
218 await settle($, site, landed, bet, rng)
219 return
220 }
221 if (isLanding(plan, tick)) {
222 await update($, memberOf(machine, { requestId: site }), held => ({ ...held, reels: frame.reels, stopped: frame.stopped }))
223 return
224 }
225 await blitMachine($, shown, bet, Math.floor(tick / 4))
226 }
227 const timer = $.clock.every(FRAME_MS, () => {
228 chain = chain.then(step, step)
229 })
230 timers[site] = timer
231}
232
233const cycleBet = async ($: EngineInterface): Promise<void> => {
234 const w = await update($, wallet, held => ({ ...held, bet: nextBet(held.bet) }))
235 $.ui.toast(`${SLOT} bet is now ${w.bet} ${CLD}`, { timeoutMs: 2000 })
236}
237
238const startMarquee = ($: EngineInterface): void => {
239 marqueeTimer?.cancel()
240 marqueeTimer = $.clock.every(MARQUEE_MS, () => {
241 void update($, marquee, n => (n + 1) % 1000)
242 })
243}
244
245const stopMarquee = (): void => {
246 marqueeTimer?.cancel()
247 marqueeTimer = null
248}
249
250type MachineViewArgs = { site: Site; rasterKey: string; hint: string }
251
252const machineView = async ($: EngineInterface, e: ResolveInput, { site, rasterKey, hint }: MachineViewArgs): Promise<RenderElement> => {
253 const m = liveView(await read($, memberOf(machine, { requestId: site })))
254 const w = await read($, wallet)
255 const lit = (await read($, marquee)) + Math.floor((live?.tick ?? 0) / 4)
256 const { readout, color } = readoutFor(m, w.bet, hint)
257 if (e.surface === 'terminal') {
258 const { Raster } = $.ui.resolve(e)
259 return (
260 <Raster
261 key={rasterKey}
262 columns={FRAME_COLUMNS}
263 rows={FRAME_ROWS}
264 cells={packCells(renderFrame(m, { lit, readout, readoutColor: color }))}
265 />
266 )
267 }
268 const { Box, Text } = $.ui.resolve(e)
269 return (
270 <Box flexDirection="column" borderStyle="round" borderColor="claude" paddingX={1}>
271 <Text bold color="warning">{textReels(m).join('\n')}</Text>
272 <Text dimColor>{readout}</Text>
273 </Box>
274 )
275}
276
277const historyLine = (entry: SlopHistoryEntry): string => {
278 const reels = entry.outcome.symbols
279 .map(s => (s === 'cherry' ? '🍒' : s === 'seven' ? '7' : s === 'coin' ? '$' : s === 'diamond' ? '◆' : s))
280 .join(' ')
281 return `${reels} ${entry.outcome.title} ${entry.outcome.payout === 0 ? '' : signedCld(entry.outcome.payout)}`
282}
283
284export const register: Register = (on, options) => {
285 settings = {
286 sounds: options.sounds !== false,
287 announcer: options.announcer !== false,
288 startingBalance: typeof options.startingBalance === 'number' ? options.startingBalance : 100,
289 band: options.band === 'expanded' ? 'expanded' : 'hidden',
290 }
291
292 on('session.start', async ($, e, next) => {
293 const w = await read($, wallet)
294 if (w.balance === 100 && w.spins === 0 && w.fromPrompts === 0 && settings.startingBalance !== 100) {
295 await update($, wallet, held => ({ ...held, balance: settings.startingBalance }))
296 }
297 await update($, isHidden, () => settings.band === 'hidden')
298 for (const site of SITES) {
299 const m = await read($, memberOf(machine, { requestId: site }))
300 if (m.phase === 'spinning') await update($, memberOf(machine, { requestId: site }), held => ({ ...held, phase: 'idle' as const, stopped: [true, true, true] as [boolean, boolean, boolean] }))
301 }
302 await update($, turn, held => ({ ...held, isWorking: false }))
303 const muted = (await $.store.get('isMuted')) === true
304 await update($, isMuted, () => muted)
305 await loadHall($)
306 startTitle($)
307 await $.command.register({
308 name: 'slop',
309 description: 'Slop Machine: stats, bet, reset, hide or show the band',
310 argumentHint: '[stats | bet <n> | reset | hide | show]',
311 immediate: true,
312 })
313 return next(e)
314 })
315
316 on('command.run', { command: 'slop' }, async ($, e) => {
317 const [verb, arg] = e.args.trim().split(/\s+/)
318 if (verb === 'bet') {
319 const n = Number(arg)
320 if (!Number.isInteger(n) || n <= 0) return { text: `Usage: /slop bet <n>. Presets: ${BETS.join(', ')}.` }
321 await update($, wallet, held => ({ ...held, bet: n }))
322 return { text: `Bet set to ${n} ${CLD}.` }
323 }
324 if (verb === 'reset') {
325 await update($, wallet, () => initialWallet(settings.startingBalance))
326 for (const site of SITES) await update($, memberOf(machine, { requestId: site }), () => idleMachine())
327 await update($, history, () => [])
328 return { text: `Wallet reset to ${settings.startingBalance} ${CLD}. The house thanks you for your business.` }
329 }
330 if (verb === 'hide') {
331 await update($, isHidden, () => true)
332 return { text: 'Slop machine band hidden. Press 0 at an empty prompt, or /slop show, to bring it back.' }
333 }
334 if (verb === 'show') {
335 await update($, isHidden, () => false)
336 return { text: 'Slop machine band shown.' }
337 }
338 if (verb === 'stats' || verb === undefined || verb === '') {
339 const w = await read($, wallet)
340 const rtp = w.wagered > 0 ? `${Math.round((w.won / w.wagered) * 100)}%` : 'n/a'
341 return {
342 text: [
343 `**Session** balance ${formatCld(w.balance)} ${CLD} · bet ${w.bet}`,
344 `prompts +${formatCld(w.fromPrompts)} · tools +${formatCld(w.fromTools)} · spins ${signedCld(w.fromSpins)} (${w.spins} spins, RTP ${rtp}, best +${formatCld(w.biggestWin)})`,
345 `**Lifetime** net ${signedCld(hall.lifetimeNet)} over ${hall.lifetimeSpins} spins · best +${formatCld(hall.biggestWin)} · jackpots ${hall.jackpots} · type errors ${hall.typeErrors}`,
346 ].join('\n'),
347 }
348 }
349 return { text: 'Usage: /slop [stats | bet <n> | reset | hide | show]' }
350 })
351
352 on('prompt.submit', async ($, e, next) => {
353 const entered = await next(e)
354 if (entered.drop !== undefined) return entered
355 if (e.origin.kind === 'plugin') return entered
356 const chars = [...e.text].length
357 const yeet = /\byeet\b/i.test(e.text) ? YEET_BONUS : 0
358 await update($, wallet, held => ({
359 ...held,
360 balance: held.balance + chars + yeet,
361 fromPrompts: held.fromPrompts + chars + yeet,
362 }))
363 if (yeet > 0) $.ui.toast(`${SLOT} YEET BONUS +${YEET_BONUS} ${CLD}`, { timeoutMs: 4000 })
364 return entered
365 })
366
367 on('turn.start', async ($, e, next) => {
368 const startedAt = await $.clock.now()
369 await update($, turn, () => ({ isWorking: true, tools: 0, combos: 0, spins: 0, spinsNet: 0, startedAt }))
370 startMarquee($)
371 return next(e)
372 })
373
374 on('tool.call', async ($, e, next) => {
375 await update($, turn, held => {
376 const tools = held.tools + 1
377 return { ...held, tools, combos: Math.floor(tools / COMBO_EVERY) }
378 })
379 return next(e)
380 })
381
382 on('turn.complete', async ($, e, next) => {
383 if (e.agentId !== undefined) return next(e)
384 stopMarquee()
385 const t = await read($, turn)
386 const toolPay = t.tools * TOOL_PAY
387 const comboPay = t.combos * COMBO_BONUS
388 const w = await update($, wallet, held => ({
389 ...held,
390 balance: held.balance + toolPay + comboPay,
391 fromTools: held.fromTools + toolPay + comboPay,
392 }))
393 const tally: SlopTally = {
394 tools: t.tools,
395 toolPay,
396 comboPay,
397 spins: t.spins,
398 spinsNet: t.spinsNet,
399 net: toolPay + comboPay + t.spinsNet,
400 durationMs: e.durationMs,
401 endedAt: await $.clock.now(),
402 balance: w.balance,
403 }
404 await update($, tallies, held => Object.fromEntries([...Object.entries(held).slice(-49), [String(e.durationMs), tally]]))
405 await update($, turn, held => ({ ...held, isWorking: false }))
406 if (t.tools > 0 || t.spins > 0) {
407 $.ui.toast(`${SLOT} we earned ${signedCld(tally.net)} ${CLD} this turn · balance ${formatCld(w.balance)} ${CLD}`, { timeoutMs: 6000 })
408 }
409 return next(e)
410 })
411
412 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
413 if (e.props.hasSurvey) return next(e)
414 const { Box, Text, Button } = $.ui.resolve(e)
415 const w = await read($, wallet)
416 bandRequestId = e.requestId
417 if (await read($, isHidden)) {
418 return (
419 <Box gap={2} paddingLeft={2} paddingTop={1}>
420 <Text dimColor>
421 {SLOT} {CLD} {formatCld(w.balance)}
422 </Text>
423 <Button key="show" hotkey="0" plain dimColor onPress={() => update($, isHidden, () => false)}>
424 show slop machine
425 </Button>
426 </Box>
427 )
428 }
429 const recent = (await read($, history)).slice(0, 2)
430 const muted = await read($, isMuted)
431 const balanceColor = w.balance < 0 ? 'error' : 'success'
432 const rtp = w.wagered > 0 ? `${Math.round((w.won / w.wagered) * 100)}%` : 'n/a'
433
434 return (
435 <Box gap={2} paddingLeft={2} paddingTop={1}>
436 {await machineView($, e, { site: 'band', rasterKey: 'band', hint: '1 to spin' })}
437 <Box flexDirection="column">
438 <Box gap={1}>
439 {e.surface === 'terminal'
440 ? (() => {
441 const { Raster } = $.ui.resolve(e)
442 return <Raster key="title" columns={TITLE_COLUMNS} rows={1} cells={titleCells()} />
443 })()
444 : <Text bold color="claude">{TITLE.trim()}</Text>}
445 <Text bold color={balanceColor}>
446 {CLD} {formatCld(w.balance)}
447 </Text>
448 {w.balance < 0 && <Text color="error" italic>(Claude Credit)</Text>}
449 <Text bold color="white">
450 {' '}< bet {formatCld(w.bet)} >
451 </Text>
452 </Box>
453 <Text dimColor wrap="truncate-end">
454 prompts +{formatCld(w.fromPrompts)} · tools +{formatCld(w.fromTools)} · spins {signedCld(w.fromSpins)}
455 </Text>
456 <Text dimColor wrap="truncate-end">
457 {w.spins} spins · wagered {formatCld(w.wagered)} · won {formatCld(w.won)} · RTP {rtp} · best +{formatCld(w.biggestWin)}
458 </Text>
459 <Text dimColor wrap="truncate-end">
460 lifetime {signedCld(hall.lifetimeNet)} over {hall.lifetimeSpins} spins · jackpots {hall.jackpots} · type errors {hall.typeErrors}
461 </Text>
462 <Box gap={1}>
463 <Button key="spin" hotkey="1" variant="primary" label="1: spin" onPress={() => spin($, 'band')} />
464 <Button key="bet" hotkey="2" label="2: bet" onPress={() => cycleBet($)} />
465 <Button key="mute" hotkey="8" label={muted ? '8: unmute' : '8: mute'} onPress={() => toggleMute($)} />
466 <Button key="hide" hotkey="0" label="0: hide" onPress={() => update($, isHidden, () => true)} />
467 </Box>
468 {recent.length === 0 && <Text dimColor>the reels are cold. 1 to spin at an empty prompt.</Text>}
469 {recent.map(entry => (
470 <Text dimColor wrap="truncate-end">{historyLine(entry)}</Text>
471 ))}
472 </Box>
473 </Box>
474 )
475 })
476
477 on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
478 const { Box, Text } = $.ui.resolve(e)
479 const t = await read($, turn)
480 const w = await read($, wallet)
481 const pending = t.tools * TOOL_PAY + t.combos * COMBO_BONUS
482 return (
483 <Box flexDirection="column">
484 {await next(e)}
485 <Text dimColor>
486 {' '}{SLOT} Claude {t.tools} tool calls → +{formatCld(pending)} {CLD} pending · you {signedCld(t.spinsNet)} · balance {formatCld(w.balance)} {CLD}
487 </Text>
488 </Box>
489 )
490 })
491
492 on('ui.render', { component: 'TurnDuration' }, async ($, e, next) => {
493 const tally = (await read($, tallies))[String(e.props.durationMs)]
494 if (tally === undefined) return next(e)
495 const { Box, Text } = $.ui.resolve(e)
496 const you = tally.spins > 0 ? `, you ${tally.spins} spins ${signedCld(tally.spinsNet)}` : ''
497 const done = tally.endedAt === undefined ? '' : ` · done ${formatClock(tally.endedAt)}`
498 return (
499 <Box marginTop={1}>
500 <Text dimColor>
501 ✻ {e.props.word} for {formatDuration(e.props.durationMs)}{done} · {SLOT} {signedCld(tally.net)} {CLD} (Claude {tally.tools} tools +{formatCld(tally.toolPay + tally.comboPay)}{you})
502 </Text>
503 </Box>
504 )
505 })
506}
507hooks/reels.ts 195 lines1export type SlotSymbol = 'seven' | 'coin' | 'diamond' | 'bar' | 'bun' | 'cherry' | 'any'
2
3export type Site = 'band'
4
5export const SITES: readonly Site[] = ['band']
6
7export type ReelState = { index: number; offset: number }
8
9export type Outcome = {
10 symbols: [SlotSymbol, SlotSymbol, SlotSymbol]
11 multiplier: number
12 payout: number
13 title: string
14 tier: 'jackpot' | 'big' | 'win' | 'push' | 'lose' | 'cursed'
15}
16
17export type MachineState = {
18 phase: 'idle' | 'spinning' | 'result'
19 reels: [ReelState, ReelState, ReelState]
20 stopped: [boolean, boolean, boolean]
21 outcome: Outcome | null
22 spins: number
23}
24
25export const STRIP: readonly SlotSymbol[] = [
26 'cherry', 'bar', 'diamond', 'cherry', 'coin', 'bar', 'seven', 'cherry', 'bun',
27 'bar', 'diamond', 'cherry', 'any', 'coin', 'bar', 'cherry', 'diamond', 'bun',
28 'seven', 'cherry', 'bar', 'coin', 'diamond', 'bun',
29]
30
31export const STRIP_LENGTH = STRIP.length
32
33export const BETS: readonly number[] = [25, 50, 100, 250, 500]
34
35export const TOOL_PAY = 10
36export const COMBO_EVERY = 10
37export const COMBO_BONUS = 100
38export const YEET_BONUS = 100
39
40export const CLD = '\u{F16A6}'
41
42export const SLOT = '\u{F1114}'
43
44export const idleMachine = (): MachineState => ({
45 phase: 'idle',
46 reels: [{ index: 0, offset: 0 }, { index: 1, offset: 0 }, { index: 2, offset: 0 }],
47 stopped: [true, true, true],
48 outcome: null,
49 spins: 0,
50})
51
52export const symbolAt = (index: number): SlotSymbol =>
53 STRIP[((index % STRIP_LENGTH) + STRIP_LENGTH) % STRIP_LENGTH] as SlotSymbol
54
55export const rollIndex = (rng: () => number): number => Math.floor(rng() * STRIP_LENGTH) % STRIP_LENGTH
56
57const LOSS_TITLES = [
58 'slop.',
59 'skill issue',
60 'the house (Claude) wins',
61 'git blame yourself',
62 'needs more tokens',
63 'try `any`... no wait',
64 'compaction ate it',
65 'rate limited',
66 'unslop skill applied',
67]
68
69const TRIPLES: Record<SlotSymbol, { multiplier: number; title: string; tier: Outcome['tier'] }> = {
70 seven: { multiplier: 77, title: 'JACKPOT 777', tier: 'jackpot' },
71 coin: { multiplier: 25, title: 'PAYDAY', tier: 'big' },
72 bun: { multiplier: 20, title: 'bun install', tier: 'big' },
73 diamond: { multiplier: 15, title: 'SHINY', tier: 'big' },
74 bar: { multiplier: 10, title: 'BAR BAR BAR', tier: 'win' },
75 cherry: { multiplier: 6, title: 'CHERRY BOMB', tier: 'win' },
76 any: { multiplier: -4, title: 'TYPE ERROR', tier: 'cursed' },
77}
78
79const PAIR_TITLES: Record<SlotSymbol, string> = {
80 seven: 'two sevens',
81 coin: 'two coins',
82 bun: 'two buns',
83 diamond: 'two diamonds',
84 bar: 'two bars',
85 cherry: 'two cherries',
86 any: 'pair of any',
87}
88
89export const judge = (
90 symbols: [SlotSymbol, SlotSymbol, SlotSymbol],
91 bet: number,
92 rng: () => number = Math.random,
93): Outcome => {
94 const [a, b, c] = symbols
95 const finish = (multiplier: number, title: string, tier: Outcome['tier']): Outcome => ({
96 symbols,
97 multiplier,
98 payout: Math.floor(bet * multiplier),
99 title,
100 tier,
101 })
102
103 if (a === b && b === c) {
104 const triple = TRIPLES[a]
105 return finish(triple.multiplier, triple.title, triple.tier)
106 }
107
108 const counts = new Map<SlotSymbol, number>()
109 for (const s of symbols) counts.set(s, (counts.get(s) ?? 0) + 1)
110 const cherries = counts.get('cherry') ?? 0
111 const paired = [...counts.entries()].find(([s, n]) => n === 2 && s !== 'cherry' && s !== 'any')?.[0]
112
113 if (cherries === 2) return finish(2, 'two cherries', 'win')
114 if (paired !== undefined) return finish(1.2, PAIR_TITLES[paired], 'win')
115 if (cherries === 1) return finish(0.6, 'a cherry', 'push')
116 if ((counts.get('any') ?? 0) === 2) return finish(0, 'pair of any, no payout', 'lose')
117
118 return finish(0, LOSS_TITLES[Math.floor(rng() * LOSS_TITLES.length)] ?? 'slop.', 'lose')
119}
120
121export const SYMBOL_LABEL: Record<SlotSymbol, string> = {
122 seven: ' 7 ',
123 coin: ' $ ',
124 diamond: ' ◆ ',
125 bar: 'BAR',
126 bun: 'bun',
127 cherry: '🍒 ',
128 any: 'any',
129}
130
131export const visibleSymbols = (m: MachineState): [SlotSymbol, SlotSymbol, SlotSymbol] => [
132 symbolAt(m.reels[0].index),
133 symbolAt(m.reels[1].index),
134 symbolAt(m.reels[2].index),
135]
136
137export type SpinPlan = {
138 targets: [number, number, number]
139 stopAt: [number, number, number]
140}
141
142export const FRAME_MS = 33
143export const BOUNCE_TICKS = 3
144
145export const planSpin = (rng: () => number): SpinPlan => ({
146 targets: [rollIndex(rng), rollIndex(rng), rollIndex(rng)],
147 stopAt: [30, 48, 66],
148})
149
150export const spinLength = (plan: SpinPlan): number => plan.stopAt[2] + BOUNCE_TICKS
151
152export type Frame = { reels: [ReelState, ReelState, ReelState]; stopped: [boolean, boolean, boolean] }
153
154const reelAt = (start: number, target: number, landing: number, tick: number): ReelState => {
155 if (tick >= landing + BOUNCE_TICKS) return { index: target, offset: 0 }
156 if (tick > landing) return { index: target, offset: 1 }
157 if (tick === landing) return { index: target, offset: 0 }
158 if (tick === landing - 1) return { index: (target - 1 + STRIP_LENGTH) % STRIP_LENGTH, offset: 2 }
159 if (tick === landing - 2) return { index: (target - 1 + STRIP_LENGTH) % STRIP_LENGTH, offset: 1 }
160 const rows = start * 3 + tick
161 return { index: Math.floor(rows / 3) % STRIP_LENGTH, offset: rows % 3 }
162}
163
164export const frameAt = (start: readonly [number, number, number], plan: SpinPlan, tick: number): Frame => ({
165 reels: [
166 reelAt(start[0], plan.targets[0], plan.stopAt[0], tick),
167 reelAt(start[1], plan.targets[1], plan.stopAt[1], tick),
168 reelAt(start[2], plan.targets[2], plan.stopAt[2], tick),
169 ],
170 stopped: [tick >= plan.stopAt[0], tick >= plan.stopAt[1], tick >= plan.stopAt[2]],
171})
172
173export const isLanding = (plan: SpinPlan, tick: number): boolean => plan.stopAt.includes(tick)
174
175export const formatCld = (n: number): string => {
176 const sign = n < 0 ? '-' : ''
177 const digits = Math.abs(Math.trunc(n)).toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',')
178 return `${sign}${digits}`
179}
180
181export const signedCld = (n: number): string => (n >= 0 ? `+${formatCld(n)}` : formatCld(n))
182
183export const nextBet = (bet: number): number => {
184 const at = BETS.indexOf(bet)
185 return BETS[(at + 1) % BETS.length] as number
186}
187
188export const toastFrame = (m: MachineState): string => {
189 const cells = m.reels.map((reel, r) => {
190 const label = SYMBOL_LABEL[symbolAt(reel.index)]
191 return m.stopped[r] ? `[${label}]` : `~${label}~`
192 })
193 return `${SLOT} ${cells.join(' ')}`
194}
195hooks/raster.ts 200 lines1import type { MachineState, SlotSymbol } from './reels'
2import { formatCld, symbolAt } from './reels'
3
4export const DEFAULT = 0x01000000
5
6const GOLD = 0xffd700
7const RED = 0xff3b30
8const GREEN = 0x34c759
9const CYAN = 0x5ac8fa
10const WHITE = 0xffffff
11const BLACK = 0x101010
12const BEIGE = 0xfbf0df
13const CABINET = 0x3b1f5e
14const CABINET_DIM = 0x2a1544
15const WINDOW = 0x0b0b14
16const LIGHT_OFF = 0x6b4f8a
17const SILVER = 0xc0c0c0
18
19type Rgb = readonly [number, number, number]
20
21const RETRO: readonly Rgb[] = [
22 [255, 0, 170], [255, 60, 60], [255, 140, 0], [255, 230, 0], [120, 255, 60],
23 [0, 240, 200], [0, 170, 255], [150, 80, 255], [230, 0, 230],
24]
25
26const sampleGradient = (t: number): number => {
27 const wrapped = ((t % 1) + 1) % 1
28 const scaled = wrapped * RETRO.length
29 const at = Math.floor(scaled) % RETRO.length
30 const mix = scaled - Math.floor(scaled)
31 const left = RETRO[at] as Rgb
32 const right = RETRO[(at + 1) % RETRO.length] as Rgb
33 const r = Math.round(left[0] + (right[0] - left[0]) * mix)
34 const g = Math.round(left[1] + (right[1] - left[1]) * mix)
35 const b = Math.round(left[2] + (right[2] - left[2]) * mix)
36 return (r << 16) | (g << 8) | b
37}
38
39export const TITLE = ' SLOP MACHINE '
40export const TITLE_COLUMNS = TITLE.length
41export const TITLE_CYCLE_MS = 1600
42export const TITLE_FRAME_MS = 140
43const SIGN_BG = 0x2a0a4a
44const BULB = 0xffffff
45
46export type TitleOptions = { chaser: number; isFlashing: boolean }
47
48export const renderTitle = (phase: number, { chaser, isFlashing }: TitleOptions = { chaser: -1, isFlashing: false }): Cell[] =>
49 [...TITLE].map((ch, i) => {
50 const cp = ch.codePointAt(0) ?? 32
51 if (isFlashing) return [cp, chaser % 2 === 0 ? GOLD : BULB, chaser % 2 === 0 ? SIGN_BG : 0x5a1a8a] as const
52 const lit = i === chaser % TITLE_COLUMNS || i === (chaser + 1) % TITLE_COLUMNS
53 return [cp, lit ? BULB : sampleGradient(i / (TITLE_COLUMNS - 1) - phase), SIGN_BG] as const
54 })
55
56export type Cell = readonly [codePoint: number, fg: number, bg: number]
57
58type Tile = readonly [readonly Cell[], readonly Cell[], readonly Cell[]]
59
60export const TILE_COLUMNS = 7
61export const TILE_ROWS = 3
62export const FRAME_COLUMNS = 1 + TILE_COLUMNS * 3 + 2 + 1
63export const FRAME_ROWS = 7
64
65const row = (text: string, fg: number, bg: number = WINDOW, overrides: Record<number, Cell> = {}): Cell[] =>
66 [...text].map((ch, x) => overrides[x] ?? ([ch.codePointAt(0) ?? 32, fg, bg] as const))
67
68const plate = (text: string, fg: number, bg: number): Cell[] => [...text].map(ch => [ch.codePointAt(0) ?? 32, fg, bg] as const)
69
70const TILES: Record<SlotSymbol, Tile> = {
71 seven: [row('▀▀▀▀▀██', GOLD), row(' ▄█▀', GOLD), row(' ██ ', GOLD)],
72 coin: [
73 row('▗█████▖', GOLD),
74 row('██ ██', GOLD, WINDOW, { 2: [32, BLACK, GOLD], 3: [36, BLACK, GOLD], 4: [32, BLACK, GOLD] }),
75 row('▝█████▘', GOLD),
76 ],
77 diamond: [row(' ▄███▄ ', CYAN), row('███████', CYAN), row(' ▀███▀ ', CYAN)],
78 bar: [row('▄▄▄▄▄▄▄', WHITE), plate(' BAR ', BLACK, WHITE), row('▀▀▀▀▀▀▀', WHITE)],
79 bun: [row('╭─────╮', BEIGE), row('│ bun │', BEIGE), row('╰─────╯', BEIGE)],
80 cherry: [row(' ╱╲ ', GREEN), row(' ╱ ╲ ', GREEN), row(' ██ ██', RED)],
81 any: [row('▚▞▚▞▚▞▚', RED), row('▐ any ▌', RED), row('▞▚▞▚▞▚▞', RED)],
82}
83
84const dim = (color: number): number => {
85 if (color === DEFAULT) return color
86 const r = Math.floor(((color >> 16) & 0xff) * 0.55)
87 const g = Math.floor(((color >> 8) & 0xff) * 0.55)
88 const b = Math.floor((color & 0xff) * 0.55)
89 return (r << 16) | (g << 8) | b
90}
91
92const reelRows = (m: MachineState, r: number): Cell[][] => {
93 const reel = m.reels[r] as { index: number; offset: number }
94 const here = TILES[symbolAt(reel.index)]
95 const next = TILES[symbolAt(reel.index + 1)]
96 const spinning = !m.stopped[r]
97 const rows: Cell[][] = []
98 for (let k = 0; k < TILE_ROWS; k++) {
99 const src = k + reel.offset
100 const line = src < TILE_ROWS ? here[src as 0 | 1 | 2] : next[(src - TILE_ROWS) as 0 | 1 | 2]
101 rows.push(spinning ? line.map(([cp, fg, bg]) => [cp, dim(fg), bg === WINDOW ? bg : dim(bg)] as const) : [...line])
102 }
103 return rows
104}
105
106const text = (s: string, width: number, fg: number, bg: number, align: 'left' | 'center' = 'center'): Cell[] => {
107 const chars = [...s].slice(0, width)
108 const pad = width - chars.length
109 const left = align === 'center' ? Math.floor(pad / 2) : 0
110 const cells: Cell[] = []
111 for (let i = 0; i < width; i++) {
112 const ch = chars[i - left]
113 cells.push([ch === undefined ? 32 : (ch.codePointAt(0) ?? 32), fg, bg])
114 }
115 return cells
116}
117
118export type FrameOptions = {
119 lit: number
120 readout: string
121 readoutColor?: number
122}
123
124export const renderFrame = (m: MachineState, { lit, readout, readoutColor }: FrameOptions): Cell[] => {
125 const cells: Cell[] = []
126 const pushRow = (r: Cell[]): void => {
127 if (r.length !== FRAME_COLUMNS) throw new Error(`row is ${r.length} wide, want ${FRAME_COLUMNS}`)
128 cells.push(...r)
129 }
130
131 const lights: Cell[] = []
132 const hot = m.phase === 'spinning' || m.outcome?.tier === 'jackpot' || m.outcome?.tier === 'big'
133 for (let x = 0; x < FRAME_COLUMNS; x++) {
134 const on = hot ? (x + lit) % 2 === 0 : (x + lit) % 4 === 0
135 lights.push([on ? 0x28f6 : 0x2840, on ? GOLD : LIGHT_OFF, CABINET])
136 }
137 pushRow(lights)
138
139 pushRow(row('╔═══════╤═══════╤═══════╗', GOLD, CABINET))
140
141 const reels = [0, 1, 2].map(r => reelRows(m, r))
142 for (let k = 0; k < TILE_ROWS; k++) {
143 const line: Cell[] = [[0x2551, GOLD, CABINET]]
144 for (let r = 0; r < 3; r++) {
145 line.push(...(reels[r]?.[k] ?? []))
146 line.push(r < 2 ? [0x2502, SILVER, WINDOW] : [0x2551, GOLD, CABINET])
147 }
148 pushRow(line)
149 }
150
151 pushRow(row('╚═══════╧═══════╧═══════╝', GOLD, CABINET))
152 pushRow(text(readout, FRAME_COLUMNS, readoutColor ?? WHITE, CABINET_DIM))
153
154 return cells
155}
156
157const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
158
159export const packCells = (cells: readonly Cell[]): string => {
160 const bytes = new Uint8Array(cells.length * 12)
161 const view = new DataView(bytes.buffer)
162 cells.forEach(([cp, fg, bg], i) => {
163 view.setUint32(i * 12, cp, true)
164 view.setUint32(i * 12 + 4, fg, true)
165 view.setUint32(i * 12 + 8, bg, true)
166 })
167 let out = ''
168 for (let i = 0; i < bytes.length; i += 3) {
169 const a = bytes[i] as number
170 const b = bytes[i + 1]
171 const c = bytes[i + 2]
172 const n = (a << 16) | ((b ?? 0) << 8) | (c ?? 0)
173 out += B64[(n >> 18) & 63]
174 out += B64[(n >> 12) & 63]
175 out += b === undefined ? '=' : B64[(n >> 6) & 63]
176 out += c === undefined ? '=' : B64[n & 63]
177 }
178 return out
179}
180
181export const readoutFor = (m: MachineState, bet: number, hint: string): { readout: string; color: number } => {
182 if (m.phase === 'spinning') return { readout: (m.reels[0]?.offset ?? 0) === 1 ? '▸▸▸ spinning ▸▸▸' : ' ▸▸ spinning ▸▸ ', color: GOLD }
183 if (m.phase === 'result' && m.outcome) {
184 const o = m.outcome
185 const sign = o.payout > 0 ? '+' : ''
186 const color = o.tier === 'jackpot' ? GOLD : o.tier === 'cursed' ? RED : o.payout > 0 ? GREEN : SILVER
187 return { readout: `${o.title} ${sign}${o.payout === 0 ? '' : formatCld(o.payout)}`.trim(), color }
188 }
189 return { readout: `BET ${bet} · ${hint}`, color: WHITE }
190}
191
192export const textReels = (m: MachineState): string[] => {
193 const labels = m.reels.map((reel, r) => {
194 const sym = symbolAt(reel.index)
195 const label = sym === 'cherry' ? ' * ' : sym === 'diamond' ? ' <> ' : sym === 'seven' ? ' 7 ' : sym === 'coin' ? ' $ ' : sym
196 return m.stopped[r] ? `[${label.padEnd(3)}]` : `~${label.padEnd(3)}~`
197 })
198 return [labels.join(' ')]
199}
200types/index.d.ts 86 lines1export type SlopSymbol = 'seven' | 'coin' | 'diamond' | 'bar' | 'bun' | 'cherry' | 'any'
2
3export type SlopSite = 'band'
4
5export type SlopReel = { index: number; offset: number }
6
7export type SlopOutcome = {
8 symbols: [SlopSymbol, SlopSymbol, SlopSymbol]
9 multiplier: number
10 payout: number
11 title: string
12 tier: 'jackpot' | 'big' | 'win' | 'push' | 'lose' | 'cursed'
13}
14
15export type SlopMachine = {
16 phase: 'idle' | 'spinning' | 'result'
17 reels: [SlopReel, SlopReel, SlopReel]
18 stopped: [boolean, boolean, boolean]
19 outcome: SlopOutcome | null
20 spins: number
21}
22
23export type SlopWallet = {
24 balance: number
25 bet: number
26 fromPrompts: number
27 fromTools: number
28 fromSpins: number
29 wagered: number
30 won: number
31 spins: number
32 biggestWin: number
33}
34
35export type SlopTurn = {
36 isWorking: boolean
37 tools: number
38 combos: number
39 spins: number
40 spinsNet: number
41 startedAt: number
42}
43
44export type SlopTally = {
45 tools: number
46 toolPay: number
47 comboPay: number
48 spins: number
49 spinsNet: number
50 net: number
51 durationMs: number
52 endedAt?: number
53 balance: number
54}
55
56export type SlopHistoryEntry = {
57 site: SlopSite
58 outcome: SlopOutcome
59 bet: number
60 at: number
61}
62
63export type SlopHallOfFame = {
64 lifetimeNet: number
65 lifetimeSpins: number
66 biggestWin: number
67 jackpots: number
68 typeErrors: number
69 sessions: number
70}
71
72declare module 'claude-code' {
73 interface PluginState {
74 'slop-machine': {
75 wallet: SlopWallet
76 turn: SlopTurn
77 tallies: Record<string, SlopTally>
78 history: SlopHistoryEntry[]
79 marquee: number
80 isHidden: boolean
81 isMuted: boolean
82 machine: StateFamily<SlopMachine>
83 }
84 }
85}
86