Clawd-themed games in a side pane while Claude works. Pauses when Claude needs you and tells you when Claude is done.

Clawd-themed games in a side pane, to play while Claude works.
/arcade opens the pane next to your chat, with a menu of games. ← Games goes back to it.Works in the Claude desktop app's Code tab and in the terminal (not in VS Code or on mobile, which can't host interactive panes).
It only watches; it never blocks, rewrites or answers anything.
| Event | What the arcade does with it |
|---|---|
session.start | Registers /arcade and loads your best scores |
command.run (/arcade) | Opens the pane |
turn.start / turn.complete | Notes that Claude is working, or done (for the banner) |
tool.call | Notes when Claude is waiting on you: a question, or a step that will stop for your OK (asked read-only of Claude Code's permission rules; it never makes or changes that decision) |
ui.message | Receives a finished round's score from the game and keeps your best |
ui.render (Pane) | Draws the pane and the game |
Your best score per game is kept on your machine. Nothing is sent anywhere.
claude plugin validate plugins/clawd-arcade
claude plugin test plugins/clawd-arcadehooks/register.tsx 175 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { GameId, Screen, Status } from '../types'
5
6// The arcade pane: a menu of games, the game being played, what Claude is up
7// to, and each game's best score.
8const PANE = 'clawd-arcade'
9const status = atom({ plugin: 'clawd-arcade', key: 'status' } as const, { claude: 'idle' } as Status)
10const best = atom({ plugin: 'clawd-arcade', key: 'best' } as const, {} as Record<string, number>)
11const now = atom({ plugin: 'clawd-arcade', key: 'now' } as const, 0)
12const screen = atom({ plugin: 'clawd-arcade', key: 'screen' } as const, 'menu' as Screen)
13
14type Listing = { id: GameId; name: string; blurb: string; score: string; isReady: boolean }
15const GAMES: Listing[] = [
16 { id: 'conga', name: 'Clawd Conga', blurb: 'Lead a conga line of mini Clawds and squash bugs.', score: 'bugs', isReady: true },
17 { id: 'hats', name: 'Hat Trick', blurb: 'Clawd hides under a hat. Watch the shuffle, then find him.', score: 'rounds', isReady: true },
18 { id: 'stacks', name: 'Clawd Stacks', blurb: 'Stack falling blocks into lines.', score: 'lines', isReady: false },
19 { id: 'flappy', name: 'Flappy Clawd', blurb: 'Hop through the gaps.', score: 'gaps', isReady: false },
20]
21const PLAYABLE = GAMES.filter(g => g.isReady).map(g => g.id as string)
22
23const str = (v: unknown): string => (typeof v === 'string' ? v : '')
24const clip = (s: string, max: number): string => (s.length > max ? s.slice(0, max - 1) + '…' : s)
25const toolName = (tool: string): string => clip(tool.replace(/^mcp__/, '').split('__').pop() || tool, 24)
26const clamp = (v: number, lo: number, hi: number): number => Math.max(lo, Math.min(hi, v))
27
28// A tool call's own arguments: the event minus the keys the engine adds.
29const argsOf = (e: Record<string, unknown>): Record<string, unknown> => {
30 const { tool: _t, tool_use_id: _id, agentId: _a, ...args } = e
31 return args
32}
33
34const CLAUDE_LINE: Record<Status['claude'], string> = {
35 idle: 'Claude is free. Play away.',
36 working: 'Claude is cooking. Play away.',
37 needs: 'Claude needs you',
38 done: 'Claude is done!',
39}
40
41export const register: Register = on => {
42 on('session.start', async ($, e, next) => {
43 const started = await next(e)
44 await $.command.register({ name: 'arcade', description: 'Open Clawd Arcade: games to play while Claude works' })
45 const saved = await $.store.get('best')
46 if (saved && typeof saved === 'object') await update($, best, () => saved as Record<string, number>)
47 return started
48 })
49
50 on('command.run', { command: 'arcade' }, async $ => {
51 await update($, screen, () => 'menu' as Screen)
52 await $.ui.open({ id: PANE, title: 'Clawd Arcade' })
53 return { text: 'Clawd Arcade is open. Pick a game, click it, then play with the keyboard. Esc gives the keyboard back.' }
54 })
55
56 // What Claude is doing, for the games' banners: working, done, or waiting on you.
57 on('turn.start', async ($, e, next) => {
58 if (!(e as Record<string, unknown>).agentId) await update($, status, () => ({ claude: 'working' }))
59 return next(e)
60 })
61
62 on('turn.complete', async ($, e, next) => {
63 const ev = e as unknown as Record<string, unknown>
64 if (!ev.agentId) {
65 const at = await $.clock.now()
66 const isStopped = str(ev.reason) !== '' && str(ev.reason) !== 'answer'
67 await update($, status, () => (isStopped ? { claude: 'idle' } : { claude: 'done', doneAt: at }))
68 await update($, now, () => at)
69 }
70 return next(e)
71 })
72
73 // A question, or a step that will stop for your OK (asked read-only of the
74 // permission rules; the decision stays with Claude Code and you).
75 on('tool.call', async ($, e, next) => {
76 const ev = e as unknown as Record<string, unknown>
77 const tool = str(ev.tool)
78 if (ev.agentId) return next(e)
79 const verdict = tool === 'AskUserQuestion' ? null : await $.tool.check({ tool, input: argsOf(ev) })
80 const needs = tool === 'AskUserQuestion' ? 'your answer' : verdict?.decision === 'ask' ? `your OK on ${toolName(tool)}` : ''
81 if (needs) await update($, status, () => ({ claude: 'needs', needs }))
82 const ran = await next(e)
83 if (needs) await update($, status, () => ({ claude: 'working' }))
84 return ran
85 })
86
87 // A game posts its score when a round ends; keep each game's best.
88 on('ui.message', { requestId: PANE }, async ($, e) => {
89 const data = e.data as { type?: unknown; game?: unknown; score?: unknown } | null
90 const game = str(data?.game)
91 const score = typeof data?.score === 'number' && Number.isFinite(data.score) ? Math.max(0, Math.floor(data.score)) : -1
92 if (data?.type !== 'score' || !PLAYABLE.includes(game) || score < 0) return {}
93 const all = await update($, best, b => ((b[game] ?? 0) >= score ? b : { ...b, [game]: score }))
94 await $.store.set('best', all)
95 return {}
96 })
97
98 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
99 const ui = $.ui.resolve(e)
100 const { Box, Text, Button } = ui
101 const st = await read($, status)
102 const all = await read($, best)
103 const shown = await read($, screen)
104 await read($, now)
105 const at = await $.clock.now()
106
107 if (!('Client' in ui) || e.surface === 'vscode' || e.surface === 'mobile') {
108 return (
109 <Box flexDirection="column">
110 <Text bold>Clawd Arcade</Text>
111 <Text dimColor>The games need the Claude desktop app or the terminal.</Text>
112 </Box>
113 )
114 }
115 const { Client } = ui
116 const tone = st.claude === 'needs' ? 'yellow' : st.claude === 'done' ? 'green' : 'gray'
117 const claudeLine = (
118 <Text color={tone} bold={st.claude === 'needs' || st.claude === 'done'}>
119 {st.claude === 'needs' ? `${CLAUDE_LINE.needs}: ${st.needs ?? 'you'}. Press Esc to answer.` : CLAUDE_LINE[st.claude]}
120 </Text>
121 )
122
123 if (shown === 'menu') {
124 return (
125 <Box flexDirection="column" gap={1}>
126 <Text bold color="#D97757">
127 Clawd Arcade
128 </Text>
129 {claudeLine}
130 {GAMES.map(game => (
131 <Box flexDirection="column">
132 <Box flexDirection="row" gap={1}>
133 <Button
134 key={`play-${game.id}`}
135 label={game.isReady ? `Play ${game.name}` : `${game.name} (coming soon)`}
136 onPress={() => (game.isReady ? update($, screen, () => game.id as Screen) : undefined)}
137 />
138 {game.isReady && <Text dimColor>Best {all[game.id] ?? 0}</Text>}
139 </Box>
140 <Text dimColor>{game.blurb}</Text>
141 </Box>
142 ))}
143 </Box>
144 )
145 }
146
147 // Square cells: a desktop column is about a third as wide as a row is
148 // tall, a terminal one about half.
149 const cellW = e.surface === 'terminal' ? 2 : 3
150 const cols = clamp(Math.floor(((e.props as { bodyColumns?: number }).bodyColumns ?? 60) / cellW) - 1, 12, 28)
151 const rows = clamp((e.viewport?.rows ?? 30) - 12, 10, 20)
152 const game = GAMES.find(g => g.id === shown) ?? GAMES[0]
153 const props = { status: st, best: all[game.id] ?? 0, now: at, cellW, cols, rows }
154 return (
155 <Box flexDirection="column" gap={1}>
156 <Box flexDirection="row" gap={2} alignItems="center">
157 <Button key="back" label="← Games" onPress={() => update($, screen, () => 'menu' as Screen)} />
158 <Text>
159 <Text bold color="#D97757">
160 {game.name}
161 </Text>
162 <Text dimColor> · Best {all[game.id] ?? 0}</Text>
163 </Text>
164 </Box>
165 {claudeLine}
166 {game.id === 'hats' ? (
167 <Client key="hats" module="./hats.tsx" props={props} />
168 ) : (
169 <Client key="conga" module="./conga.tsx" props={props} />
170 )}
171 </Box>
172 )
173 })
174}
175hooks/hats.tsx 324 lines1import type { ClientElements, ClientKeyEvent, ClientModule, ClientPointerEvent, RenderElement } from 'claude-code'
2
3import type { GameProps } from '../types'
4
5// Hat Trick: Clawd hides under one of his chef hats, the hats shuffle, and you
6// pick the one he's under. Each round shuffles more and faster; every second
7// round adds a hat, up to six.
8
9type Phase = 'ready' | 'show' | 'drop' | 'shuffle' | 'pick' | 'reveal' | 'over'
10type Swap = { a: number; b: number }
11type Game = {
12 round: number
13 hats: number
14 /** Which slot each hat stands in. */
15 slotOf: number[]
16 /** The hat Clawd is under. */
17 clawd: number
18 swaps: Swap[]
19 swapMs: number
20 phase: Phase
21 /** Milliseconds into the current phase. */
22 t: number
23 sel: number
24 picked: number
25 isRight: boolean
26 isPaused: boolean
27 props: GameProps
28 doneSeen?: number
29 bannerMs: number
30 isNewBest: boolean
31}
32type State = { game: Game; frame: number }
33
34const TICK_MS = 30
35const SHOW_MS = 1400
36const DROP_MS = 350
37const REVEAL_MS = 1300
38const BANNER_MS = 6000
39const SLOT = 6 // cells per hat slot: a 4-cell hat and a 2-cell gap
40const ROWS = 8
41
42const CLAY = '#D97757'
43const INK = '#1F1E1D'
44const STAGE = '#262624'
45const FLOOR = '#33302C'
46const HAT = '#F2F0EA'
47const HAT_SHADE = '#D8D4CA'
48const BAND = '#C9C2B4'
49const PICK = '#E0A33B'
50const RIGHT = '#3B9C5F'
51
52const hatsFor = (round: number): number => Math.min(6, 3 + Math.floor((round - 1) / 2))
53const swapsFor = (round: number): number => 2 + round
54const swapMsFor = (round: number): number => Math.max(190, 650 - round * 45)
55const ease = (t: number): number => (t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2)
56
57const newRound = (g: Game, round: number): void => {
58 const hats = hatsFor(round)
59 g.round = round
60 g.hats = hats
61 g.slotOf = Array.from({ length: hats }, (_, i) => i)
62 g.clawd = Math.floor(Math.random() * hats)
63 g.swaps = Array.from({ length: swapsFor(round) }, () => {
64 const a = Math.floor(Math.random() * hats)
65 const b = (a + 1 + Math.floor(Math.random() * (hats - 1))) % hats
66 return { a, b }
67 })
68 g.swapMs = swapMsFor(round)
69 g.phase = 'show'
70 g.t = 0
71 g.sel = Math.floor(hats / 2)
72 g.picked = -1
73}
74
75const fresh = (props: GameProps): Game => {
76 const g = {
77 round: 1, hats: 3, slotOf: [0, 1, 2], clawd: 1, swaps: [], swapMs: 600, phase: 'ready', t: 0,
78 sel: 1, picked: -1, isRight: false, isPaused: false, props, bannerMs: 0, isNewBest: false,
79 } as Game
80 return g
81}
82
83// The slots after a run of swaps: each swap trades the hats in two slots.
84const swapped = (slotOf: number[], swaps: Swap[]): number[] =>
85 swaps.reduce((slots, { a, b }) => slots.map(slot => (slot === a ? b : slot === b ? a : slot)), slotOf)
86
87// Where each hat stands, in cells from the stage's left, and how high it rides.
88const hatPlaces = (g: Game): { x: number; lift: number }[] => {
89 const base = (slot: number) => 1 + slot * SLOT
90 // Mid-shuffle, the hats stand where the swaps so far have put them.
91 const done = g.phase === 'shuffle' ? Math.min(g.swaps.length, Math.floor(g.t / g.swapMs)) : 0
92 const slots = swapped(g.slotOf, g.swaps.slice(0, done))
93 const places = slots.map(slot => ({ x: base(slot), lift: 0 }))
94 if (g.phase === 'shuffle') {
95 const swap = g.swaps[done]
96 if (swap) {
97 const p = ease((g.t % g.swapMs) / g.swapMs)
98 slots.forEach((slot, hat) => {
99 if (slot === swap.a) places[hat] = { x: base(swap.a) + (base(swap.b) - base(swap.a)) * p, lift: Math.round(Math.sin(p * Math.PI) * 2) }
100 if (slot === swap.b) places[hat] = { x: base(swap.b) + (base(swap.a) - base(swap.b)) * p, lift: 0 }
101 })
102 }
103 }
104 if (g.phase === 'show') places[g.clawd].lift = 3
105 if (g.phase === 'drop') places[g.clawd].lift = Math.round(3 * (1 - g.t / DROP_MS))
106 if (g.phase === 'reveal' || g.phase === 'over') {
107 if (g.picked >= 0) places[g.picked].lift = 3
108 places[g.clawd].lift = 3
109 }
110 return places
111}
112
113const settleSwaps = (g: Game): void => {
114 g.slotOf = swapped(g.slotOf, g.swaps)
115}
116
117const advance = (g: Game, ms: number, post: (data: { type: 'score'; game: 'hats'; score: number }) => void): void => {
118 if (g.isPaused || g.phase === 'ready' || g.phase === 'pick' || g.phase === 'over') return
119 g.t += ms
120 if (g.phase === 'show' && g.t >= SHOW_MS) {
121 g.phase = 'drop'
122 g.t = 0
123 } else if (g.phase === 'drop' && g.t >= DROP_MS) {
124 g.phase = 'shuffle'
125 g.t = 0
126 } else if (g.phase === 'shuffle' && g.t >= g.swaps.length * g.swapMs) {
127 settleSwaps(g)
128 g.phase = 'pick'
129 g.t = 0
130 } else if (g.phase === 'reveal' && g.t >= REVEAL_MS) {
131 if (g.isRight) newRound(g, g.round + 1)
132 else {
133 g.phase = 'over'
134 g.isNewBest = g.round - 1 > g.props.best
135 post({ type: 'score', game: 'hats', score: g.round - 1 })
136 }
137 }
138}
139
140const pickSlot = (g: Game, slot: number): void => {
141 if (g.phase !== 'pick' || slot < 0 || slot >= g.hats) return
142 g.picked = g.slotOf.indexOf(slot)
143 g.isRight = g.picked === g.clawd
144 g.phase = 'reveal'
145 g.t = 0
146}
147
148const onKeyFor = (g: Game, start: () => void) => (ev: ClientKeyEvent): void => {
149 const key = ev.key.toLowerCase()
150 if (g.phase === 'ready' || g.phase === 'over') {
151 if (key === ' ' || key === 'space' || key === 'return') start()
152 return
153 }
154 if (key === ' ' || key === 'space') {
155 if (g.phase === 'pick') return pickSlot(g, g.sel)
156 g.isPaused = !g.isPaused
157 return
158 }
159 if (g.phase !== 'pick') return
160 if (key === 'left' || key === 'a') g.sel = (g.sel + g.hats - 1) % g.hats
161 else if (key === 'right' || key === 'd') g.sel = (g.sel + 1) % g.hats
162 else if (key === 'return') pickSlot(g, g.sel)
163 else if (/^[1-6]$/.test(key)) pickSlot(g, Number(key) - 1)
164}
165
166// ---------------------------------------------------------------------------
167// The stage: a grid of square cells, each a fixed-width box.
168
169type Px = { bg: string; glyph?: string; fg?: string }
170
171const paint = (el: ClientElements, grid: Px[][], cellW: number): RenderElement => {
172 const { Box, Text } = el
173 return Box({
174 flexDirection: 'column',
175 children: grid.map(row => {
176 const runs: { px: Px; n: number }[] = []
177 for (const px of row) {
178 const last = runs[runs.length - 1]
179 if (last && !px.glyph && !last.px.glyph && last.px.bg === px.bg) last.n += 1
180 else runs.push({ px, n: 1 })
181 }
182 return Box({
183 flexDirection: 'row',
184 children: runs.map(({ px, n }) =>
185 Box({
186 width: cellW * n,
187 height: 1,
188 backgroundColor: px.bg,
189 justifyContent: 'center',
190 overflow: 'hidden',
191 children: px.glyph ? Text({ color: px.fg, bold: true, children: px.glyph }) : undefined,
192 }),
193 ),
194 })
195 }),
196 })
197}
198
199const CHEF_HAT: string[] = ['.WW.', 'WWWW', 'BBBB']
200const CLAWD: string[] = ['CCCC', 'CeeC', 'C..C']
201
202const stage = (g: Game): Px[][] => {
203 const cols = g.hats * SLOT + 1
204 const grid: Px[][] = Array.from({ length: ROWS }, (_, y) => Array.from({ length: cols }, (): Px => ({ bg: y === 6 ? FLOOR : STAGE })))
205 const put = (x: number, y: number, px: Px) => {
206 if (y >= 0 && y < ROWS && x >= 0 && x < cols) grid[y][x] = px
207 }
208 const places = hatPlaces(g)
209 // Clawd stands under his hat, wherever it goes.
210 const home = places[g.clawd]
211 const cx = Math.round(home.x)
212 CLAWD.forEach((line, dy) =>
213 [...line].forEach((ch, dx) => {
214 if (ch === 'C') put(cx + dx, 3 + dy, { bg: CLAY })
215 if (ch === 'e') put(cx + dx, 3 + dy, { bg: CLAY, glyph: '•', fg: INK })
216 }),
217 )
218 // The hats, lowest first so a rising hat draws over a resting one.
219 const order = places.map((p, hat) => ({ ...p, hat })).sort((a, b) => a.lift - b.lift)
220 for (const { x, lift, hat } of order) {
221 const hx = Math.round(x)
222 const isPicked = (g.phase === 'reveal' || g.phase === 'over') && hat === g.picked
223 CHEF_HAT.forEach((line, dy) =>
224 [...line].forEach((ch, dx) => {
225 const y = 3 - lift + dy
226 if (ch === 'W') put(hx + dx, y, { bg: dx % 3 ? HAT : HAT_SHADE })
227 if (ch === 'B') put(hx + dx, y, { bg: isPicked ? (g.isRight ? RIGHT : PICK) : BAND })
228 }),
229 )
230 }
231 // Under the floor: the hat numbers, and an arrow at the one you're choosing.
232 for (let slot = 0; slot < g.hats; slot++) {
233 const x = 1 + slot * SLOT
234 const isSel = g.phase === 'pick' && slot === g.sel
235 put(x + 1, 7, { bg: STAGE, glyph: isSel ? '▲' : String(slot + 1), fg: isSel ? PICK : '#8F8F8A' })
236 }
237 return grid
238}
239
240const HatTrick: ClientModule<GameProps, State> = (props, surface) => {
241 const { Box, Text } = surface.elements
242
243 if (!surface.state) {
244 const game = fresh(props)
245 let frame = 0
246 const start = () => {
247 Object.assign(game, fresh(game.props))
248 newRound(game, 1)
249 }
250 // Redraw on the key itself, not the next tick, so input feels instant.
251 const onKey = onKeyFor(game, start)
252 surface.onKey(ev => {
253 onKey(ev)
254 surface.setState({ game, frame: (frame += 1) })
255 })
256 surface.onPointer((ev: ClientPointerEvent) => {
257 if (ev.type !== 'down') return
258 if (game.phase === 'ready' || game.phase === 'over') start()
259 const cell = Math.floor(ev.x / game.props.cellW) - 1
260 if (cell >= 0 && cell % SLOT < 4) pickSlot(game, Math.floor(cell / SLOT))
261 surface.setState({ game, frame: (frame += 1) })
262 })
263 surface.every(TICK_MS, () => {
264 if (game.bannerMs > 0) game.bannerMs -= TICK_MS
265 advance(game, TICK_MS, d => surface.post(d))
266 surface.setState({ game, frame: (frame += 1) })
267 })
268 surface.setState({ game, frame })
269 }
270
271 const g = surface.state?.game ?? fresh(props)
272 g.props = props
273 // Claude needs you: freeze the shuffle so you don't lose track.
274 if (props.status.claude === 'needs' && ['show', 'drop', 'shuffle', 'reveal'].includes(g.phase)) g.isPaused = true
275 if (props.status.doneAt !== undefined && props.status.doneAt !== g.doneSeen) {
276 g.doneSeen = props.status.doneAt
277 g.bannerMs = BANNER_MS
278 }
279
280 const banner =
281 g.isPaused && props.status.claude === 'needs'
282 ? { text: '✋ Claude needs you: Esc to answer, then click back and press Space', color: '#E0A33B' }
283 : g.bannerMs > 0
284 ? { text: '🎉 Claude is done! Hop back whenever you like.', color: RIGHT }
285 : null
286
287 const line =
288 g.phase === 'ready'
289 ? 'Click the stage, then press Space to start'
290 : g.isPaused
291 ? 'Paused · Space to carry on'
292 : g.phase === 'show'
293 ? `Round ${g.round} · Clawd is under this one. Watch closely…`
294 : g.phase === 'drop' || g.phase === 'shuffle'
295 ? `Round ${g.round} · Shuffling ${g.swaps.length} times…`
296 : g.phase === 'pick'
297 ? 'Where is Clawd? ← → and Enter, a number key, or click a hat'
298 : g.phase === 'reveal'
299 ? g.isRight
300 ? 'Found him! 🎉 Next round…'
301 : 'Not that one! There he is.'
302 : `Game over · ${g.round - 1} round${g.round === 2 ? '' : 's'} cleared${g.isNewBest ? ' · New best! 🎉' : ''} · Space to play again`
303
304 return (
305 <Box flexDirection="column" gap={1}>
306 {banner ? (
307 <Text bold color={banner.color}>
308 {banner.text}
309 </Text>
310 ) : (
311 <Text dimColor>
312 Round {g.round} · {g.hats} hats · Best {Math.max(props.best, g.phase === 'over' ? g.round - 1 : 0)}
313 </Text>
314 )}
315 {paint(surface.elements, stage(g), props.cellW)}
316 <Text bold={g.phase !== 'shuffle'} color={g.phase === 'over' ? CLAY : g.phase === 'reveal' && g.isRight ? RIGHT : undefined}>
317 {line}
318 </Text>
319 </Box>
320 )
321}
322
323export default HatTrick
324hooks/conga.tsx 238 lines1import type { ClientElements, ClientKeyEvent, ClientModule, RenderElement } from 'claude-code'
2
3import type { GameProps } from '../types'
4
5// Clawd Conga: Clawd leads a conga line of mini Clawds around the board,
6// squashing bugs. Each bug adds a dancer and speeds the beat up a little.
7// Hit a wall or the line and the conga trips.
8
9type Cell = [number, number]
10type Phase = 'ready' | 'playing' | 'paused' | 'over'
11type Game = {
12 cols: number
13 rows: number
14 line: Cell[]
15 dir: Cell
16 queued: Cell[]
17 bug: Cell
18 score: number
19 phase: Phase
20 pausedFor: string
21 tick: number
22 every: number
23 props: GameProps
24 doneSeen?: number
25 bannerTicks: number
26 isNewBest: boolean
27}
28type State = { game: Game; frame: number }
29
30const TICK_MS = 30
31const START_EVERY = 5 // ticks per step: 150ms
32const FASTEST = 2 // 60ms
33const BANNER_TICKS = Math.round(6000 / TICK_MS)
34
35const CLAY = '#D97757'
36const CLAY_DARK = '#B45F43'
37const INK = '#1F1E1D'
38const BOARD = '#262624'
39const BOARD_ALT = '#2B2B29'
40const BUG = '#5FA35A'
41
42const DIRS: Record<string, Cell> = {
43 up: [0, -1], w: [0, -1], k: [0, -1],
44 down: [0, 1], s: [0, 1], j: [0, 1],
45 left: [-1, 0], a: [-1, 0],
46 right: [1, 0], d: [1, 0], l: [1, 0],
47}
48
49const same = (a: Cell, b: Cell): boolean => a[0] === b[0] && a[1] === b[1]
50
51// ---------------------------------------------------------------------------
52// The board: a grid of square cells, each a fixed-width box, so every row is
53// the same width whatever the font.
54
55type Px = { bg: string; glyph?: string; fg?: string }
56
57const paint = (el: ClientElements, grid: Px[][], cellW: number): RenderElement => {
58 const { Box, Text } = el
59 return Box({
60 flexDirection: 'column',
61 children: grid.map(row => {
62 const runs: { px: Px; n: number }[] = []
63 for (const px of row) {
64 const last = runs[runs.length - 1]
65 if (last && !px.glyph && !last.px.glyph && last.px.bg === px.bg) last.n += 1
66 else runs.push({ px, n: 1 })
67 }
68 return Box({
69 flexDirection: 'row',
70 children: runs.map(({ px, n }) =>
71 Box({
72 width: cellW * n,
73 height: 1,
74 backgroundColor: px.bg,
75 justifyContent: 'center',
76 overflow: 'hidden',
77 children: px.glyph ? Text({ color: px.fg, bold: true, children: px.glyph }) : undefined,
78 }),
79 ),
80 })
81 }),
82 })
83}
84
85// ---------------------------------------------------------------------------
86
87const placeBug = (g: Game): Cell => {
88 const free: Cell[] = []
89 for (let y = 0; y < g.rows; y++) for (let x = 0; x < g.cols; x++) if (!g.line.some(c => c[0] === x && c[1] === y)) free.push([x, y])
90 return free[Math.floor(Math.random() * free.length)] ?? [0, 0]
91}
92
93const fresh = (props: GameProps): Game => {
94 const { cols, rows } = props
95 const y = Math.floor(rows / 2)
96 const x = Math.floor(cols / 3)
97 const g: Game = {
98 cols, rows,
99 line: [[x, y], [x - 1, y], [x - 2, y]],
100 dir: [1, 0],
101 queued: [],
102 bug: [0, 0],
103 score: 0,
104 phase: 'ready',
105 pausedFor: '',
106 tick: 0,
107 every: START_EVERY,
108 props,
109 bannerTicks: 0,
110 isNewBest: false,
111 }
112 g.bug = placeBug(g)
113 return g
114}
115
116// One beat of the conga: move, squash a bug, or trip.
117const step = (g: Game, post: (data: { type: 'score'; game: 'conga'; score: number }) => void): void => {
118 const next = g.queued.shift()
119 if (next && !(next[0] === -g.dir[0] && next[1] === -g.dir[1])) g.dir = next
120 const head = g.line[0]
121 const to: Cell = [head[0] + g.dir[0], head[1] + g.dir[1]]
122 const eats = same(to, g.bug)
123 const body = eats ? g.line : g.line.slice(0, -1)
124 if (to[0] < 0 || to[1] < 0 || to[0] >= g.cols || to[1] >= g.rows || body.some(c => same(c, to))) {
125 g.phase = 'over'
126 g.isNewBest = g.score > g.props.best
127 post({ type: 'score', game: 'conga', score: g.score })
128 return
129 }
130 g.line = [to, ...body]
131 if (eats) {
132 g.score += 1
133 g.every = Math.max(FASTEST, START_EVERY - Math.floor(g.score / 4))
134 g.bug = placeBug(g)
135 }
136}
137
138const onKeyFor = (g: Game, restart: () => void) => (ev: ClientKeyEvent): void => {
139 const key = ev.key.toLowerCase()
140 if (key === ' ' || key === 'space' || key === 'return') {
141 if (g.phase === 'over') return restart()
142 g.phase = g.phase === 'playing' ? 'paused' : 'playing'
143 g.pausedFor = ''
144 return
145 }
146 if (key === 'r') return restart()
147 const dir = DIRS[key]
148 if (!dir) return
149 if (g.phase === 'ready' || g.phase === 'paused') g.phase = 'playing'
150 const last = g.queued[g.queued.length - 1] ?? g.dir
151 if (!same(dir, last) && !(dir[0] === -last[0] && dir[1] === -last[1]) && g.queued.length < 3) g.queued.push(dir)
152}
153
154const board = (g: Game): Px[][] =>
155 Array.from({ length: g.rows }, (_, y) =>
156 Array.from({ length: g.cols }, (_, x): Px => {
157 const at = g.line.findIndex(c => c[0] === x && c[1] === y)
158 if (at === 0) return { bg: CLAY, glyph: '••', fg: INK } // Clawd, eyes forward
159 if (at > 0) return { bg: at % 2 ? CLAY_DARK : CLAY } // the conga line of mini Clawds
160 if (same([x, y], g.bug)) return { bg: (x + y) % 2 ? BOARD : BOARD_ALT, glyph: '🐛', fg: BUG }
161 return { bg: (x + y) % 2 ? BOARD : BOARD_ALT }
162 }),
163 )
164
165const Conga: ClientModule<GameProps, State> = (props, surface) => {
166 const { Box, Text } = surface.elements
167
168 if (!surface.state) {
169 const game = fresh(props)
170 let frame = 0
171 const restart = () => {
172 Object.assign(game, fresh(game.props), { phase: 'playing' as Phase })
173 }
174 // Redraw on the key itself, not the next tick, so input feels instant.
175 const onKey = onKeyFor(game, restart)
176 surface.onKey(ev => {
177 onKey(ev)
178 surface.setState({ game, frame: (frame += 1) })
179 })
180 surface.every(TICK_MS, () => {
181 game.tick += 1
182 if (game.bannerTicks > 0) game.bannerTicks -= 1
183 if (game.phase === 'playing' && game.tick % game.every === 0) step(game, d => surface.post(d))
184 surface.setState({ game, frame: (frame += 1) })
185 })
186 surface.setState({ game, frame })
187 }
188
189 const g = surface.state?.game ?? fresh(props)
190 g.props = props
191 // The pane changed size before the first move: fit the board to it.
192 if (g.phase === 'ready' && (g.cols !== props.cols || g.rows !== props.rows)) Object.assign(g, fresh(props))
193 // Claude needs you: pause the round the moment that news arrives.
194 if (props.status.claude === 'needs' && g.phase === 'playing') {
195 g.phase = 'paused'
196 g.pausedFor = 'Claude needs you'
197 }
198 // Claude finishing shows the "Claude is done" banner for a few seconds.
199 if (props.status.doneAt !== undefined && props.status.doneAt !== g.doneSeen) {
200 g.doneSeen = props.status.doneAt
201 g.bannerTicks = BANNER_TICKS
202 }
203
204 const banner =
205 g.phase === 'paused' && g.pausedFor
206 ? { text: `✋ ${g.pausedFor}: Esc to answer, then click back and press Space`, color: '#E0A33B' }
207 : g.bannerTicks > 0
208 ? { text: '🎉 Claude is done! Hop back whenever you like.', color: '#3B9C5F' }
209 : null
210
211 const status =
212 g.phase === 'ready'
213 ? 'Click the board, then press an arrow key to start'
214 : g.phase === 'paused'
215 ? 'Paused · Space to dance on'
216 : g.phase === 'over'
217 ? `The conga tripped! ${g.score} bug${g.score === 1 ? '' : 's'} squashed${g.isNewBest ? ' · New best! 🎉' : ''} · Space to go again`
218 : `🐛 ${g.score} squashed · Line of ${g.line.length} · Best ${Math.max(props.best, g.score)}`
219
220 return (
221 <Box flexDirection="column" gap={1}>
222 {banner ? (
223 <Text bold color={banner.color}>
224 {banner.text}
225 </Text>
226 ) : (
227 <Text dimColor>Arrows or WASD to steer · Space pauses · R restarts</Text>
228 )}
229 {paint(surface.elements, board(g), props.cellW)}
230 <Text bold={g.phase !== 'playing'} color={g.phase === 'over' ? CLAY : undefined} dimColor={g.phase === 'playing'}>
231 {status}
232 </Text>
233 </Box>
234 )
235}
236
237export default Conga
238types/index.d.ts 40 lines1/** What Claude is up to, as the games see it. */
2export type ClaudeStatus = 'idle' | 'working' | 'needs' | 'done'
3
4export type Status = {
5 claude: ClaudeStatus
6 /** What Claude needs from you, when `claude` is `needs`. */
7 needs?: string
8 /** When Claude last finished, for the "Claude is done" banner. */
9 doneAt?: number
10}
11
12export type GameId = 'conga' | 'hats' | 'stacks' | 'flappy'
13
14/** What the pane shows: the menu, or a game. */
15export type Screen = 'menu' | GameId
16
17/** The props the arcade hands each game. */
18export type GameProps = {
19 status: Status
20 best: number
21 /** The time the props were drawn, so a game can age its banners. */
22 now: number
23 /** How many columns one square cell takes on this surface. */
24 cellW: number
25 /** The board's size in cells, from the pane's size. */
26 cols: number
27 rows: number
28}
29
30declare module 'claude-code' {
31 interface PluginState {
32 'clawd-arcade': {
33 status: Status
34 best: Record<string, number>
35 now: number
36 screen: Screen
37 }
38 }
39}
40