SLOPSHOPPER

pong

Pong above the Claude Code prompt: rally against the AI paddle while Claude works. Plays while Claude works, pauses when the turn ends, keeps your best winning…

newbandcommandtoast
v0.1.1MITupdated 2026-09-25ambareeshav/claude-pong-mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · pong
› 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 › /pong ⎿ pong: $Pong · click the board to play · Esc returns to the prompt · /pong stop closes [ close ] ▣ client module ./boards/pong.tsx ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
[ close ] ▣ client module ./boards/pong.tsx ⟨Claude Code's own drawing⟩
README

Claude Code Pong Mod

A pong game for Claude Code, built as a function-hooks mod. While Claude thinks, you can be losing at pong. Rally against an AI paddle right above the prompt, first to 7 — no context switch, no tokens spent, no excuse. The moment Claude's done, the ball freezes mid-air and the status line taps you on the shoulder.

Install

Written to .claude/skills/pong/, which Claude Code auto-loads as pong@skills-dir. For one session with hot reload: CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude --plugin-dir .claude/skills/pong.

Play

  1. /pong opens the board above the prompt (/pong stop or the close button closes it).
  2. Click the board to give it the keyboard; Esc gives the keyboard back to the prompt.
  3. ↑ ↓ (or w/s) move your paddle (the left, cyan one). p pauses, r restarts. Hit the ball near the edge of your paddle to steepen its return.

Your best winning margin (points you won by) is kept in the plugin's store across sessions — a loss never overwrites it.

Requirements

  • Claude Code 2.1.269 or later (the first build whose function hooks draw above the prompt) with CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1. The $ API is early access and may change between releases.
  • An interactive terminal that reports the mouse. Nothing draws in claude -p, the desktop app or mobile.
  • A terminal font with box-drawing and block characters.

How it works

Built the same way as the tetris mod — the AI paddle just tracks the ball a cell at a time, so it's beatable, not psychic.

Source 5 files
hooks/register.tsx 95 lines
1/* @jsx h */
2import type { Register } from 'claude-code'
3import { isBetter } from './games/best.ts'
4
5// Pong above the Claude Code prompt, built the same way as the tetris mod: `/pong` opens it,
6// `/pong stop` closes it. The board in ./boards is a surface module that runs on the drawing
7// thread; this module mounts it, keeps the best winning margin in $.store and pauses the game
8// when Claude finishes a turn. Playing costs no tokens.
9
10const GAME = 'pong' // what the board posts its score under
11const TITLE = 'Pong'
12const LOWER_IS_BETTER = false
13const UNIT = ''
14
15let open = false
16// bumped at each finished turn; the open game pauses when it sees a new value
17let turnsDone = 0
18let best = 0
19
20export const register: Register = on => {
21  on('session.start', async ($, e, next) => {
22    const r = await next(e)
23    // a store that cannot be read costs the best margin, never the game
24    const score = Number(await $.store.get('best').catch(() => undefined))
25    if (score) best = score
26    await $.command.register({
27      name: 'pong',
28      description: `$Pong above the prompt, while Claude works (stop closes)`,
29      argumentHint: '[stop]',
30      immediate: true,
31    }).catch(err => $.ui.log(`pong: /pong not registered: ${err}`))
32    return r
33  })
34
35  on('command.run', { command: 'pong' }, async ($, e) => {
36    const arg = e.args.trim().toLowerCase()
37    if (arg === 'stop' || arg === 'close') {
38      open = false
39      $.ui.invalidate('ui.render')
40      return { text: `$Pong closed` }
41    }
42    open = true
43    $.ui.invalidate('ui.render')
44    const score = best ? ` · best margin ${best}$` : ''
45    return { text: `$Pong · click the board to play · Esc returns to the prompt · /pong stop closes${score}` }
46  })
47
48  on('turn.complete', async ($, e, next) => {
49    const r = await next(e)
50    // a subagent's turn is not the one the person is waiting for
51    if (e.agentId) return r
52    if (open) {
53      turnsDone++
54      $.ui.invalidate('ui.render')
55    }
56    return r
57  })
58
59  // a finished match, posted by the board: keep the best margin and hand it back with the next props
60  on('ui.message', async ($, e, next) => {
61    const data = e.data as { game?: unknown; score?: unknown } | null
62    if (data?.game !== GAME || typeof data.score !== 'number') return next(e)
63    if (isBetter(data.score, best || undefined, LOWER_IS_BETTER)) {
64      best = data.score
65      await $.store.set('best', best).catch(err => $.ui.log(`pong: store write failed: ${err}`))
66      $.ui.toast(`$Pong: new best margin ${best}$`)
67    }
68    return { props: { best, done: turnsDone, safe: false } }
69  })
70
71  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
72    // the board needs a terminal's keys and mouse; the desktop and mobile surfaces draw their own band
73    if (!open || e.props.hasSurvey || e.surface !== 'terminal') return next(e)
74    const { Box, Button, Client } = $.ui.resolve(e)
75    // the band's own width: the transcript column's while a Pane is docked beside it
76    const cols = e.props.bodyColumns || (e.viewport?.columns ?? 80)
77    // one row for the close button, one for whatever else draws in the band
78    const rows = Math.max(8, e.props.maxRows - 2)
79    const tall = (max: number) => Math.min(rows, max)
80    const close = () => {
81      open = false
82      $.ui.invalidate('ui.render')
83    }
84    return (
85      <Box flexDirection="column">
86        <Box flexDirection="row" columnGap={1}>
87          <Button key="pong:close" label="close" onPress={close} />
88        </Box>
89        <Client key="board:pong" module="./boards/pong.tsx" width={cols} height={tall(17)} props={{ best, done: turnsDone, safe: false }} />
90        {await next(e)}
91      </Box>
92    )
93  })
94}
95
hooks/games/best.ts 9 lines
1// Which score counts as a new best. Pong's best is its widest winning margin, so higher wins;
2// a score of zero or less never counts: a match you lost posts a margin of zero or less and
3// should never overwrite a real win.
4export function isBetter(score: number, prev: number | undefined, lowerIsBetter = false): boolean {
5  if (!Number.isFinite(score) || score <= 0) return false
6  if (prev === undefined || prev <= 0) return true
7  return lowerIsBetter ? score < prev : score > prev
8}
9
hooks/boards/pong.tsx 68 lines
1/* @jsx h */
2import type { ClientSurface } from 'claude-code'
3import { base, claudeDone, grid, statusLine, type Base, type Props } from './common.tsx'
4import { margin, movePlayer, newPong, tick, TICK_MS, WIN_SCORE, type PongGame } from '../games/pong.ts'
5
6type State = Base & { game?: PongGame }
7
8export default function Pong(props: Props, surface: ClientSurface<State>) {
9  const { Box, Text } = surface.elements
10  const boardRows = () => Math.min(14, surface.rows - 3)
11  // a wide court reads better than tetris's square one; still two chars per cell
12  const boardCols = () => Math.max(16, Math.min(40, Math.floor((surface.columns - 2) / 2)))
13
14  if (surface.state === undefined) {
15    surface.setState(base(props))
16    surface.every(TICK_MS, () => {
17      const s = surface.state
18      if (!s?.game || !s.playing || s.game.over) return
19      const game = tick(s.game)
20      if (game.over) surface.post({ game: 'pong', score: margin(game) })
21      surface.setState({ ...s, game })
22    })
23    surface.onKey(({ key }) => {
24      const s = surface.state
25      if (!s) return
26      const k = key.toLowerCase()
27      if (!s.game || s.game.over || k === 'r') {
28        if (boardRows() < 6) return
29        surface.setState({ ...s, banner: false, playing: true, game: newPong(boardCols(), boardRows()) })
30        return
31      }
32      if (k === 'p') {
33        surface.setState({ ...s, banner: false, playing: !s.playing })
34        return
35      }
36      const g = s.game
37      const game = k === 'up' || k === 'w' ? movePlayer(g, -1)
38        : k === 'down' || k === 's' ? movePlayer(g, 1)
39        : undefined
40      if (!game) return
41      surface.setState({ ...s, banner: false, playing: true, game })
42    })
43  }
44  claudeDone(props, surface)
45
46  const s = surface.state
47  const g = s?.game
48  const bw = g?.w ?? boardCols()
49  const bh = g?.h ?? boardRows()
50  const rows = grid(Text, bw, bh, (x, y) => {
51    if (g && !g.over && Math.round(g.ballY) === y && Math.round(g.ballX) === x) return ['██', 'yellow']
52    if (g && x === 0 && y >= g.playerY && y < g.playerY + g.paddleH) return ['██', 'cyan']
53    if (g && x === g.w - 1 && y >= g.aiY && y < g.aiY + g.paddleH) return ['██', 'red']
54    if (x === Math.floor(bw / 2) && y % 2 === 0) return [' ·', 'gray']
55    return ['  ', 'gray']
56  })
57  const text = !g ? `pong · click here, then any key · ↑↓ move · p pause · first to ${WIN_SCORE} wins`
58    : g.over ? (g.won ? `pong · you win ${g.playerScore}–${g.aiScore} · r plays again` : `pong · you lose ${g.playerScore}–${g.aiScore} · r plays again`)
59    : !s?.playing ? `pong · paused · ${g.playerScore}–${g.aiScore} · p resumes`
60    : `pong · ${g.playerScore}–${g.aiScore} · p pause`
61  return (
62    <Box flexDirection="column">
63      <Box flexDirection="column" borderStyle="round" borderColor={g?.over ? (g.won ? 'green' : 'red') : 'cyan'} width={bw * 2 + 2}>{rows}</Box>
64      {statusLine(Text, !!s?.banner, `${text} · best margin ${props?.best ?? 0}`)}
65    </Box>
66  )
67}
68
hooks/boards/common.tsx 61 lines
1/* @jsx h */
2import type { ClientElements, ClientSurface } from 'claude-code'
3
4// Shared by every board. A board is a surface module: it runs on the drawing thread with its own
5// frame clock, keys (after a click gives it focus; Esc gives the prompt back) and mouse.
6//
7// Never name a local `h` in a board file: every JSX tag compiles to a call of `h`.
8
9export type Props = { best?: number; done?: number; safe?: boolean } | undefined
10export type Base = { playing: boolean; banner: boolean; seenDone: number }
11export type Paint = readonly [glyph: string, color?: string, bg?: string]
12type TextTag = ClientElements['Text']
13
14export const base = (props: Props): Base => ({ playing: false, banner: false, seenDone: props?.done ?? 0 })
15
16// the hooks module bumps props.done when Claude finishes a turn: pause and say so, once per turn
17export function claudeDone<S extends Base>(props: Props, surface: ClientSurface<S>) {
18  const s = surface.state
19  const done = props?.done ?? 0
20  if (s && done !== s.seenDone) surface.setState({ ...s, seenDone: done, banner: true, playing: false })
21}
22
23export function statusLine(Text: TextTag, banner: boolean, text: string) {
24  return banner
25    ? <Text color="yellow" bold wrap="truncate-end">{`● Claude is done · ${text}`}</Text>
26    : <Text dimColor wrap="truncate-end">{text}</Text>
27}
28
29export type Run = [text: string, color?: string, bg?: string]
30
31// one run per stretch of cells sharing both colors. A drawing the engine cannot fit in its node
32// budget is worse than a coarser one, so maxRuns folds neighbours together, pair by pair, until
33// the row fits: a merged pair keeps the left one's colors and every glyph.
34export function runs(cols: number, y: number, at: (x: number, y: number) => Paint, maxRuns = Infinity): Run[] {
35  let out: Run[] = []
36  for (let x = 0; x < cols; x++) {
37    const [glyph, color, bg] = at(x, y)
38    const last = out[out.length - 1]
39    if (last && last[1] === color && last[2] === bg) last[0] += glyph
40    else out.push([glyph, color, bg])
41  }
42  while (out.length > Math.max(1, maxRuns)) {
43    const folded: Run[] = []
44    for (let i = 0; i < out.length; i += 2) {
45      const [t, c, b] = out[i]
46      const next = out[i + 1]
47      folded.push(next ? [t + next[0], c, b] : [t, c, b])
48    }
49    out = folded
50  }
51  return out
52}
53
54// a board as rows of two-character cells (a terminal cell is about twice as tall as wide, so two
55// make a square), one styled span per run of the same colors
56export function grid(Text: TextTag, cols: number, rowCount: number, at: (x: number, y: number) => Paint, maxRuns = Infinity) {
57  return Array.from({ length: Math.max(0, rowCount) }, (_, y) => (
58    <Text>{runs(cols, y, at, maxRuns).map(([t, c, b]) => (b ? <Text color={c} backgroundColor={b}>{t}</Text> : <Text color={c}>{t}</Text>))}</Text>
59  ))
60}
61
hooks/games/pong.ts 122 lines
1// Pong: pure game logic, drawn by the Pong module in ../boards/pong.tsx.
2// The player's paddle is column 0 (left); the AI's paddle is the last column (right).
3// Coordinates and speeds are whole board cells — the ball moves exactly one cell per tick,
4// so a paddle plane is only ever crossed on a single tick, never skipped over.
5
6export type PongGame = {
7  w: number
8  h: number
9  paddleH: number
10  ballX: number
11  ballY: number
12  vx: -1 | 1
13  vy: -1 | 1
14  playerY: number
15  aiY: number
16  playerScore: number
17  aiScore: number
18  over: boolean
19  won: boolean // true when the player reached WIN_SCORE first
20}
21
22export const WIN_SCORE = 7
23
24const clampPaddle = (y: number, paddleH: number, h: number) => Math.max(0, Math.min(h - paddleH, y))
25const randomSign = (rand: () => number): -1 | 1 => (rand() < 0.5 ? -1 : 1)
26
27export function newPong(w: number, h: number, rand = Math.random): PongGame {
28  const paddleH = Math.max(3, Math.floor(h / 4))
29  const midY = Math.floor((h - paddleH) / 2)
30  return {
31    w,
32    h,
33    paddleH,
34    ballX: Math.floor(w / 2),
35    ballY: Math.floor(h / 2),
36    vx: randomSign(rand),
37    vy: randomSign(rand),
38    playerY: midY,
39    aiY: midY,
40    playerScore: 0,
41    aiScore: 0,
42    over: false,
43    won: false,
44  }
45}
46
47export function movePlayer(g: PongGame, dy: number): PongGame {
48  if (g.over) return g
49  return { ...g, playerY: clampPaddle(g.playerY + dy, g.paddleH, g.h) }
50}
51
52// re-center and send the ball toward whoever just conceded, so the other side gets a beat to react
53function serve(g: PongGame, towardPlayer: boolean, rand: () => number): PongGame {
54  return {
55    ...g,
56    ballX: Math.floor(g.w / 2),
57    ballY: Math.floor(g.h / 2),
58    vx: towardPlayer ? -1 : 1,
59    vy: randomSign(rand),
60  }
61}
62
63// steepens (changes vy) when the ball caught the paddle's outer third, so a shot placed at the
64// edge answers back; a hit near the center keeps the ball's existing vertical direction
65function deflect(vy: -1 | 1, hitY: number, paddleY: number, paddleH: number): -1 | 1 {
66  const offset = (hitY - (paddleY + paddleH / 2)) / (paddleH / 2)
67  return offset < -0.3 ? -1 : offset > 0.3 ? 1 : vy
68}
69
70export function tick(g: PongGame, rand = Math.random): PongGame {
71  if (g.over) return g
72
73  // the AI tracks the ball's row, one cell per tick — beatable, not psychic
74  const aiCenter = g.aiY + g.paddleH / 2
75  const aiY = clampPaddle(g.aiY + (aiCenter < g.ballY ? 1 : aiCenter > g.ballY ? -1 : 0), g.paddleH, g.h)
76
77  let { ballX, ballY, vx, vy } = g
78  ballX += vx
79  ballY += vy
80
81  if (ballY <= 0) {
82    ballY = 0
83    vy = 1
84  } else if (ballY >= g.h - 1) {
85    ballY = g.h - 1
86    vy = -1
87  }
88
89  if (ballX === 1 && vx < 0 && ballY >= g.playerY && ballY < g.playerY + g.paddleH) {
90    vx = 1
91    vy = deflect(vy, ballY, g.playerY, g.paddleH)
92  } else if (ballX === g.w - 2 && vx > 0 && ballY >= aiY && ballY < aiY + g.paddleH) {
93    vx = -1
94    vy = deflect(vy, ballY, aiY, g.paddleH)
95  }
96
97  if (ballX < 0) {
98    const aiScore = g.aiScore + 1
99    return aiScore >= WIN_SCORE
100      ? { ...g, aiY, aiScore, over: true, won: false }
101      : serve({ ...g, aiY, aiScore }, true, rand)
102  }
103  if (ballX > g.w - 1) {
104    const playerScore = g.playerScore + 1
105    return playerScore >= WIN_SCORE
106      ? { ...g, aiY, playerScore, over: true, won: true }
107      : serve({ ...g, aiY, playerScore }, false, rand)
108  }
109
110  return { ...g, aiY, ballX, ballY, vx, vy }
111}
112
113// the score posted when a round ends: the player's winning margin, negative when they lost —
114// isBetter in ./best.ts already refuses anything <= 0, so a loss never overwrites a real best
115export const margin = (g: PongGame) => g.playerScore - g.aiScore
116
117// ticks (140 ms each) between ball steps: one cell per tick throughout, speed is constant.
118// Slower than the first pass (60 ms) — arrow-key auto-repeat can't move the paddle fast enough
119// to keep up with a ball crossing the court in ~1s; giving it ~2.5x longer per crossing makes
120// the AI's perfect one-cell-per-tick tracking a lot less punishing to play against.
121export const TICK_MS = 140
122