Flappy Bird with Claude's spark, above the prompt while Claude works

Flappy Bird for Claude Code, played while Claude works. The bird is Claude's spark, spinning through the spinner's glyphs; the pipes scroll past above the prompt.
/flappy play brings the game up while Claude is idle. /flappy off hides it, /flappy on brings it back./plugin marketplace add Anerco/plugins
/plugin install flappy-claude@anerco
Clicks reach the game in Claude Code's fullscreen layout ("tui": "fullscreen" in settings). The game needs 8 free rows above the prompt; it stays hidden in a shorter terminal.
Updates: in /plugin, choose Marketplaces → anerco → Enable auto-update.
It reads and writes nothing outside its own store (the best score and the on/off switch) and makes no network calls.
claude plugin validate .
claude plugin test .
MIT
hooks/register.tsx 129 lines1// Flappy Claude: a game in the band above the prompt while a turn runs.
2// The band draws a `Client` (game.tsx) that plays on its own; this module
3// decides when it shows, keeps the best score and serves `/flappy`.
4//
5// A run that is in the air when the turn ends keeps the band up until the
6// bird lands, then a moment more for the score.
7
8import { atom, read, update } from 'claude-code'
9import type { Register } from 'claude-code'
10
11import type { FlappyPost, FlappyProps } from '../types'
12
13const isOn = atom({ plugin: 'flappy-claude', key: 'isOn' } as const, true)
14const best = atom({ plugin: 'flappy-claude', key: 'best' } as const, 0)
15const isPlaying = atom({ plugin: 'flappy-claude', key: 'isPlaying' } as const, false)
16
17const MAX_COLUMNS = 96
18const MAX_SKY = 10
19const MIN_SKY = 6
20const LINGER_MS = 2500
21
22function asPost(data: unknown): FlappyPost | undefined {
23 if (typeof data !== 'object' || data === null) return undefined
24 const post = data as Record<string, unknown>
25 if (post.kind === 'start') return { kind: 'start' }
26 const isOver = post.kind === 'over' && typeof post.score === 'number' && Number.isFinite(post.score)
27
28 return isOver ? { kind: 'over', score: Math.max(0, Math.floor(post.score as number)) } : undefined
29}
30
31export const register: Register = on => {
32 let run = 0
33 let fault: string | undefined
34
35 on('session.start', async ($, e, next) => {
36 await $.command.register({
37 name: 'flappy',
38 description: 'Turn Flappy Claude on or off: it flies above the prompt while Claude works',
39 argumentHint: '[on|off|play]',
40 immediate: true,
41 })
42 const stored = await $.store.get('best')
43 const isStoredOn = await $.store.get('isOn')
44 await update($, best, n => Math.max(n, typeof stored === 'number' ? stored : 0))
45 await update($, isOn, () => isStoredOn !== false)
46
47 return next(e)
48 })
49
50 on('command.run', { command: 'flappy' }, async ($, e) => {
51 const arg = e.args.trim().toLowerCase()
52 if (arg === 'play') {
53 // Practice while idle: the band stays up until a run ends, as after a turn.
54 await update($, isOn, () => true)
55 await $.store.set('isOn', true)
56 await update($, isPlaying, () => true)
57 fault = undefined
58 return { text: 'Flappy Claude is up above the prompt: click it, then space to flap.' }
59 }
60 const isNowOn = arg === 'on' ? true : arg === 'off' ? false : !(await read($, isOn))
61 await update($, isOn, () => isNowOn)
62 await $.store.set('isOn', isNowOn)
63 if (!isNowOn) await update($, isPlaying, () => false)
64 fault = undefined
65
66 return {
67 text: isNowOn
68 ? 'Flappy Claude is on: it flies above the prompt while Claude works.'
69 : 'Flappy Claude is off. /flappy turns it back on.',
70 }
71 })
72
73 on('ui.message', async ($, e, next) => {
74 const post = e.element === 'flappy' ? asPost(e.data) : undefined
75 if (post?.kind === 'start') {
76 run += 1
77 await update($, isPlaying, () => true)
78 }
79 if (post?.kind === 'over') {
80 const ended = run
81 const score = post.score
82 const record = await update($, best, n => Math.max(n, score))
83 await $.store.set('best', record)
84 $.clock.after(LINGER_MS, () => {
85 if (run === ended) void update($, isPlaying, () => false)
86 })
87 }
88
89 return next(e)
90 })
91
92 on('ui.fault', ($, e, next) => {
93 if (e.element === 'flappy') {
94 fault = e.reason
95 $.ui.log(`flappy-claude: the game stopped (${e.reason}); /flappy tries again`)
96 }
97
98 return next(e)
99 })
100
101 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
102 const shown = await read($, isOn)
103 const playing = await read($, isPlaying)
104 const record = await read($, best)
105 const sky = Math.min(MAX_SKY, e.props.maxRows - 2)
106 const isQuiet =
107 !shown ||
108 fault !== undefined ||
109 e.props.hasSurvey ||
110 (!e.props.isWorking && !playing) ||
111 sky < MIN_SKY ||
112 (e.surface !== 'terminal' && e.surface !== 'desktop')
113 if (isQuiet) return next(e)
114
115 const { Box, Client } = $.ui.resolve(e)
116 const below = await next(e)
117 const columns = Math.min(MAX_COLUMNS, e.props.bodyColumns)
118 const rows = sky + 2
119 const props: FlappyProps = { best: record, isWorking: e.props.isWorking, columns, rows }
120
121 return (
122 <Box flexDirection="column">
123 <Client key="flappy" module="./game.tsx" width={columns} height={rows} props={props} />
124 {below}
125 </Box>
126 )
127 })
128}
129hooks/game.tsx 332 lines1// The game itself: a surface module drawn by the `Client` the band holds.
2// It runs on the drawing thread with its own frame clock, keys and pointer,
3// so play never waits on the hooks module; it posts a run's start and its
4// score, which the hooks module keeps as the best.
5//
6// The field is cells: a HUD row, the sky, a ground row. The bird is Claude's
7// spark, spinning through the spinner's glyphs; physics is in rows per tick.
8
9import type {
10 ClientKeyEvent,
11 ClientModule,
12 ClientPointerEvent,
13 ClientSurface,
14 RenderElement,
15 RenderNode,
16} from 'claude-code'
17
18import type { FlappyPost, FlappyProps } from '../types'
19
20const TICK_MS = 50
21const GRAVITY = 0.11
22const FLAP = -0.68
23const MAX_FALL = 0.85
24const PIPE_WIDTH = 4
25const PIPE_SPACING = 22
26const BIRD_X = 8
27const FIRST_PIPE = 30
28const COOLDOWN_TICKS = 12
29const SPARKS = ['✢', '✳', '✶', '✻', '✽', '✻', '✶', '✳']
30const FLAP_KEYS = new Set([' ', 'space', 'up', 'return', 'w', 'k'])
31
32const PIPE_SHADES = ['#9be26a', '#5fb236', '#4c9a2a', '#3a7a1f']
33const CAP = '#7ccc4a'
34const SAND = '#d6b26b'
35const STRIPE = '#a8823f'
36
37type Phase = 'ready' | 'playing' | 'over'
38type Pipe = { x: number; gapTop: number; isScored: boolean }
39
40type Game = {
41 phase: Phase
42 tick: number
43 y: number
44 vy: number
45 spin: number
46 pipes: readonly Pipe[]
47 score: number
48 best: number
49 seed: number
50 overAt: number
51 scrolled: number
52}
53
54type State = { game: Game }
55type Field = { width: number; sky: number; gap: number }
56type Cell = { ch: string; fg?: string; bg?: string; bold?: boolean; dim?: boolean }
57
58function field(surface: ClientSurface<State>, props: FlappyProps): Field {
59 const width = Math.max(20, surface.columns || props.columns)
60 const sky = Math.max(4, (surface.rows || props.rows) - 2)
61
62 return { width, sky, gap: Math.max(3, Math.round(sky * 0.42)) }
63}
64
65// mulberry32: the next value in [0, 1) and the next seed.
66function random(seed: number): [number, number] {
67 const next = (seed + 0x6d2b79f5) | 0
68 let r = Math.imul(next ^ (next >>> 15), 1 | next)
69 r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r
70
71 return [((r ^ (r >>> 14)) >>> 0) / 4294967296, next]
72}
73
74function fresh(best: number, f: Field, seed: number): Game {
75 return {
76 phase: 'ready',
77 tick: 0,
78 y: f.sky / 2 - 0.5,
79 vy: 0,
80 spin: 0,
81 pipes: [],
82 score: 0,
83 best,
84 seed,
85 overAt: 0,
86 scrolled: 0,
87 }
88}
89
90function withPipe(g: Game, x: number, f: Field): Game {
91 const [r, seed] = random(g.seed)
92 const low = 1
93 const high = Math.max(low, f.sky - f.gap - 1)
94 const previous = g.pipes.at(-1)?.gapTop
95 const from = previous === undefined ? low : Math.max(low, previous - 3)
96 const to = previous === undefined ? high : Math.min(high, previous + 3)
97 const gapTop = from + Math.floor(r * (to - from + 1))
98
99 return { ...g, seed, pipes: [...g.pipes, { x, gapTop, isScored: false }] }
100}
101
102function launch(g: Game, f: Field): Game {
103 const started = { ...fresh(g.best, f, g.seed), phase: 'playing' as const, vy: FLAP, y: g.y }
104
105 return withPipe(started, Math.min(f.width + 1, BIRD_X + FIRST_PIPE), f)
106}
107
108type Hit = 'cap' | number | undefined
109
110// What pipe covers a cell: a cap, a body column's shade index, or nothing.
111function pipeAt(pipes: readonly Pipe[], col: number, row: number, f: Field): Hit {
112 for (const pipe of pipes) {
113 const left = Math.round(pipe.x)
114 const isGap = row >= pipe.gapTop && row < pipe.gapTop + f.gap
115 if (isGap) continue
116 const isCapRow = row === pipe.gapTop - 1 || row === pipe.gapTop + f.gap
117 if (isCapRow && col >= left - 1 && col <= left + PIPE_WIDTH) return 'cap'
118 if (col >= left && col < left + PIPE_WIDTH) return col - left
119 }
120
121 return undefined
122}
123
124function step(g: Game, f: Field): Game {
125 const tick = g.tick + 1
126
127 if (g.phase === 'ready') {
128 // The wait before the first flap stirs the seed, so no two runs match.
129 const y = f.sky / 2 - 0.5 + Math.sin(tick / 4) * 0.6
130 return { ...g, tick, y, seed: random(g.seed)[1], spin: g.spin + 0.25, scrolled: g.scrolled + 0.8 }
131 }
132
133 if (g.phase === 'over') {
134 const floor = f.sky - 1
135 const isSettled = g.y >= floor && tick - g.overAt > COOLDOWN_TICKS
136 if (isSettled) return g
137 const vy = Math.min(MAX_FALL, g.vy + GRAVITY)
138 return { ...g, tick, vy, y: Math.min(floor, g.y + vy) }
139 }
140
141 const speed = 0.8 + Math.min(0.4, g.score * 0.02)
142 const vy = Math.min(MAX_FALL, g.vy + GRAVITY)
143 const rose = g.y + vy
144 const y = Math.max(0, rose)
145 let score = g.score
146 const pipes = g.pipes
147 .map(pipe => ({ ...pipe, x: pipe.x - speed }))
148 .filter(pipe => pipe.x + PIPE_WIDTH + 1 >= 0)
149 .map(pipe => {
150 const isPast = !pipe.isScored && Math.round(pipe.x) + PIPE_WIDTH < BIRD_X
151 if (isPast) score += 1
152 return isPast ? { ...pipe, isScored: true } : pipe
153 })
154 let next: Game = {
155 ...g,
156 tick,
157 y,
158 vy: rose < 0 ? 0 : vy,
159 spin: g.spin + (vy < 0 ? 1 : 0.34),
160 pipes,
161 score,
162 scrolled: g.scrolled + speed,
163 }
164 const last = next.pipes.at(-1)
165 if (last === undefined || last.x <= f.width - PIPE_SPACING) {
166 next = withPipe(next, (last?.x ?? f.width) + PIPE_SPACING, f)
167 }
168
169 const isGrounded = y > f.sky - 1
170 const isHit = pipeAt(next.pipes, BIRD_X, Math.round(Math.min(y, f.sky - 1)), f) !== undefined
171 if (isGrounded || isHit) {
172 return { ...next, phase: 'over', overAt: tick, best: Math.max(g.best, score), y: Math.min(y, f.sky - 1) }
173 }
174
175 return next
176}
177
178function flapped(g: Game, f: Field): Game {
179 if (g.phase === 'ready') return launch(g, f)
180 if (g.phase === 'playing') return { ...g, vy: FLAP }
181 if (g.tick - g.overAt < COOLDOWN_TICKS) return g
182
183 return launch({ ...g, y: f.sky / 2 - 0.5 }, f)
184}
185
186function sky(g: Game, f: Field, row: number, col: number): Cell {
187 const hit = pipeAt(g.pipes, col, row, f)
188 if (hit === 'cap') return { ch: ' ', bg: CAP }
189 if (hit !== undefined) return { ch: ' ', bg: PIPE_SHADES[hit] }
190 const far = col + Math.floor(g.scrolled * 0.25)
191 let mix = Math.imul(far, 374761393) ^ Math.imul(row + 1, 668265263)
192 mix = Math.imul(mix ^ (mix >>> 13), 1274126177)
193 const isStar = ((mix ^ (mix >>> 16)) >>> 0) % 47 === 0
194
195 return isStar ? { ch: '·', fg: 'subtle' } : { ch: ' ' }
196}
197
198function bird(g: Game, behind: Cell): Cell {
199 const ch = g.phase === 'over' ? '✶' : (SPARKS[Math.floor(g.spin) % SPARKS.length] ?? '✻')
200
201 return { ch, fg: g.phase === 'over' ? 'error' : 'claude', bg: behind.bg, bold: true }
202}
203
204function ground(g: Game, col: number): Cell {
205 const isStripe = (col + Math.floor(g.scrolled)) % 3 === 0
206
207 return { ch: isStripe ? '╱' : ' ', fg: STRIPE, bg: SAND }
208}
209
210function sameLook(a: Cell, b: Cell): boolean {
211 return a.fg === b.fg && a.bg === b.bg && a.bold === b.bold && a.dim === b.dim
212}
213
214// One row of cells as one Text, a nested Text per run of one look.
215function line(cells: readonly Cell[], surface: ClientSurface<State>, key: string): RenderNode {
216 const { Text } = surface.elements
217 const runs: { look: Cell; text: string }[] = []
218 for (const cell of cells) {
219 const run = runs.at(-1)
220 if (run !== undefined && sameLook(run.look, cell)) run.text += cell.ch
221 else runs.push({ look: cell, text: cell.ch })
222 }
223
224 return (
225 <Text key={key} wrap="truncate">
226 {runs.map(({ look, text }) =>
227 look.fg === undefined && look.bg === undefined ? (
228 text
229 ) : (
230 <Text color={look.fg} backgroundColor={look.bg} bold={look.bold} dimColor={look.dim}>
231 {text}
232 </Text>
233 ),
234 )}
235 </Text>
236 )
237}
238
239function hud(g: Game, f: Field, props: FlappyProps, surface: ClientSurface<State>): RenderNode {
240 const { Box, Text } = surface.elements
241 const best = Math.max(g.best, props.best)
242 const hint = f.width >= 64 ? ' click to play · space flaps · esc to type' : ' click, then space'
243 const left =
244 g.phase === 'ready' ? (
245 <Text>
246 <Text color="claude" bold>
247 ✻ Flappy Claude
248 </Text>
249 <Text dimColor>{hint}</Text>
250 </Text>
251 ) : g.phase === 'playing' ? (
252 <Text bold>score {g.score}</Text>
253 ) : (
254 <Text>
255 <Text color="error" bold>
256 ✕ score {g.score}
257 </Text>
258 {g.score > 0 && g.score >= best ? <Text color="success"> new best!</Text> : ''}
259 <Text dimColor>{g.tick - g.overAt >= COOLDOWN_TICKS ? ' space to fly again' : ''}</Text>
260 </Text>
261 )
262 const right = (
263 <Text>
264 <Text dimColor>best {best}</Text>
265 {props.isWorking ? '' : <Text color="success"> ✓ Claude is done</Text>}
266 </Text>
267 )
268
269 return (
270 <Box key="hud" flexDirection="row" justifyContent="space-between">
271 {left}
272 {right}
273 </Box>
274 )
275}
276
277function draw(g: Game, props: FlappyProps, surface: ClientSurface<State>): RenderElement {
278 const { Box } = surface.elements
279 const f = field(surface, props)
280 const birdRow = Math.round(Math.min(g.y, f.sky - 1))
281 const rows: RenderNode[] = []
282 for (let row = 0; row < f.sky; row += 1) {
283 const cells: Cell[] = []
284 for (let col = 0; col < f.width; col += 1) {
285 const cell = sky(g, f, row, col)
286 cells.push(row === birdRow && col === BIRD_X ? bird(g, cell) : cell)
287 }
288 rows.push(line(cells, surface, `sky${row}`))
289 }
290 const soil: Cell[] = []
291 for (let col = 0; col < f.width; col += 1) soil.push(ground(g, col))
292
293 return (
294 <Box flexDirection="column">
295 {hud(g, f, props, surface)}
296 {rows}
297 {line(soil, surface, 'ground')}
298 </Box>
299 )
300}
301
302const FlappyClaude: ClientModule<FlappyProps, State> = (props, surface) => {
303 if (surface.state === undefined) {
304 const post = (data: FlappyPost) => surface.post(data)
305 // The size props only stand in until the surface lays the region out.
306 const act = (fn: (g: Game, f: Field) => Game) => {
307 const before = surface.state?.game
308 if (before === undefined) return
309 const after = fn(before, field(surface, props))
310 if (after === before) return
311 if (after.phase === 'playing' && before.phase !== 'playing') post({ kind: 'start' })
312 if (after.phase === 'over' && before.phase !== 'over') post({ kind: 'over', score: after.score })
313 surface.setState({ game: after })
314 }
315 surface.every(TICK_MS, () => act(step))
316 surface.onKey((event: ClientKeyEvent) => {
317 if (FLAP_KEYS.has(event.key)) act(flapped)
318 })
319 surface.onPointer((event: ClientPointerEvent) => {
320 if (event.type === 'down' && event.button !== 'right') act(flapped)
321 })
322 const seed = Math.floor(Math.random() * 2 ** 31)
323 const game = fresh(props.best, field(surface, props), seed)
324 surface.setState({ game })
325 return draw(game, props, surface)
326 }
327
328 return draw(surface.state.game, props, surface)
329}
330
331export default FlappyClaude
332types/index.d.ts 19 lines1/** What the game posts to the hooks module (`surface.post`). */
2export type FlappyPost = { kind: 'start' } | { kind: 'over'; score: number }
3
4/** What the hooks module hands the game (`Client` props): the region's size until the surface measures it. */
5export type FlappyProps = { best: number; isWorking: boolean; columns: number; rows: number }
6
7declare module 'claude-code' {
8 interface PluginState {
9 'flappy-claude': {
10 /** The `/flappy` switch, kept in `$.store` across sessions. */
11 isOn: boolean
12 /** Best score, kept in `$.store` across sessions. */
13 best: number
14 /** A run is in the air: the band stays up after the turn until it ends. */
15 isPlaying: boolean
16 }
17 }
18}
19