SLOPSHOPPER

flappy-claude

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

bandcommandtimer
v0.1.0MITupdated 2026-10-16Anerco/claude-code-flappy-claude
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · flappy-claude
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /flappy ⎿ flappy-claude: Flappy Claude is off. /flappy turns it back on. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

flappy-claude

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.

  • Shows up by itself while a turn runs, and goes away when Claude is done. A run still in the air when the turn ends keeps going until the bird lands.
  • Click the game to flap and give it the keyboard, then Space (or Up, W, K) flaps. Esc goes back to the prompt; a second Esc interrupts Claude as usual.
  • Best score is kept across sessions.
  • /flappy play brings the game up while Claude is idle. /flappy off hides it, /flappy on brings it back.

Install

/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.

Privacy

It reads and writes nothing outside its own store (the best score and the on/off switch) and makes no network calls.

Development

claude plugin validate .
claude plugin test .

License

MIT

Source 3 files
hooks/register.tsx 129 lines
1// 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}
129
hooks/game.tsx 332 lines
1// 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
332
types/index.d.ts 19 lines
1/** 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