SLOPSHOPPER

pong

Pong above the Claude Code prompt: first to 5 against the computer. Plays while Claude works, pauses when the turn ends, keeps your best score; zero tokens. A…

newbandcommandtoast
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

pong

Pong above the Claude Code prompt, for the minutes Claude spends working: first to 5 against the computer. The game pauses and its status line says so when Claude finishes the turn, so you never miss a reply. Playing costs no tokens: the mod answers every key and click itself.

A single game split out of sezaakgun/cc-arcade (MIT, by Seza Akgün). Want all nine games, a picker and the pet in one plugin? Install games/cc-arcade instead.

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 move the mouse over the board, to place your paddle on the left. Space pauses, r restarts. First to 5 wins.

Your best score is kept in the plugin's store across sessions.

Install

npx claude-code-templates@latest --mod games/pong
claude

It is written to .claude/skills/pong/, which Claude Code auto-loads as pong@skills-dir. For one session with hot reload: claude --plugin-dir .claude/skills/pong.

Requirements

  • Claude Code 2.1.287 or later (mods are on by default from that version).
  • 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.

Clones of the genre, not affiliated with or endorsed by the original publishers.

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, split out of cc-arcade (https://github.com/sezaakgun/cc-arcade,
6// MIT, by Seza Akgün) as a single-game mod. `/pong` opens it, `/pong stop` closes it. The board in
7// ./boards is a surface module that runs on the drawing thread; this module mounts it, keeps the best
8// score in $.store and pauses the game 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 score, 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 ${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 round, posted by the board: keep the best score 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 ${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(18)} props={{ best, done: turnsDone, safe: false }} />
90        {await next(e)}
91      </Box>
92    )
93  })
94}
95
hooks/games/best.ts 10 lines
1// Which score counts as a new best. Minesweeper's best is its fastest clear, so lower wins there;
2// everywhere else higher wins. A score of zero or less never counts: a Minesweeper board whose
3// first click clears it takes 0 s and would otherwise lock the best forever, and a Pong match you
4// lost posts a negative margin.
5export function isBetter(score: number, prev: number | undefined, lowerIsBetter = false): boolean {
6  if (!Number.isFinite(score) || score <= 0) return false
7  if (prev === undefined || prev <= 0) return true
8  return lowerIsBetter ? score < prev : score > prev
9}
10
hooks/boards/pong.tsx 83 lines
1/* @jsx h */
2import type { ClientSurface } from 'claude-code'
3import { base, claudeDone, grid, statusLine, type Base, type Props } from './common.tsx'
4import { newPong, nudge, place, tick, WIN, 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 size = () => ({
11    bw: Math.max(16, Math.min(40, Math.floor((surface.columns - 2) / 2))),
12    bh: Math.max(8, Math.min(15, surface.rows - 3)),
13  })
14  // the first key or click of a round starts it
15  const ensure = (s: State): State => {
16    if (s.game && !s.game.over) return s
17    const { bw, bh } = size()
18    return { ...s, game: newPong(bw, bh) }
19  }
20
21  if (surface.state === undefined) {
22    surface.setState(base(props))
23    surface.every(100, () => {
24      const s = surface.state
25      if (!s?.game || !s.playing || s.game.over) return
26      const game = tick(s.game)
27      // best is the winning margin
28      if (game.over) surface.post({ game: 'pong', score: game.scoreYou - game.scoreCpu })
29      surface.setState({ ...s, game })
30    })
31    surface.onKey(({ key }) => {
32      const s = surface.state
33      if (!s) return
34      // the space bar may arrive as the character or by name
35      const k = key === 'space' ? ' ' : key.toLowerCase()
36      if (k === 'p' || k === ' ') {
37        if (s.game && !s.game.over) surface.setState({ ...s, banner: false, playing: !s.playing })
38        return
39      }
40      if (k === 'r') {
41        const { bw, bh } = size()
42        surface.setState({ ...s, banner: false, playing: true, game: newPong(bw, bh) })
43        return
44      }
45      const rows = k === 'up' || k === 'w' ? -2 : k === 'down' || k === 's' ? 2 : 0
46      if (!rows) return
47      const next = ensure(s)
48      surface.setState({ ...next, banner: false, playing: true, game: nudge(next.game!, rows) })
49    })
50    // the paddle follows the pointer over the board (the border takes the first row)
51    surface.onPointer(ev => {
52      const s = surface.state
53      if (!s || (ev.type !== 'move' && ev.type !== 'down')) return
54      if (ev.type === 'move' && (!s.game || s.game.over || !s.playing)) return
55      const next = ensure(s)
56      surface.setState({ ...next, banner: false, playing: true, game: place(next.game!, ev.y - 1) })
57    })
58  }
59  claudeDone(props, surface)
60
61  const s = surface.state
62  const g = s?.game
63  const { bw, bh } = g ? { bw: g.w, bh: g.h } : size()
64  const rows = grid(Text, bw, bh, (x, y) => {
65    if (!g) return x === Math.floor(bw / 2) && y % 2 === 0 ? [' ·', 'gray'] : ['  ']
66    if (x === 0 && y >= g.you && y < g.you + g.pad) return ['██', 'cyan']
67    const cpu = Math.round(g.cpu)
68    if (x === bw - 1 && y >= cpu && y < cpu + g.pad) return ['██', 'magenta']
69    if (x === g.ball.x && y === Math.round(g.ball.y)) return ['██', 'whiteBright']
70    return x === Math.floor(bw / 2) && y % 2 === 0 ? [' ·', 'gray'] : ['  ']
71  })
72  const text = !g ? `pong · click here, then ↑↓ or move the mouse · first to ${WIN}`
73    : g.over ? `pong · ${g.scoreYou > g.scoreCpu ? 'you win' : 'cpu wins'} ${g.scoreYou}:${g.scoreCpu} · r plays again`
74    : !s?.playing ? `pong · paused · you ${g.scoreYou} : ${g.scoreCpu} cpu · space resumes`
75    : `pong · you ${g.scoreYou} : ${g.scoreCpu} cpu · first to ${WIN} · ↑↓ or the mouse`
76  return (
77    <Box flexDirection="column">
78      <Box flexDirection="column" borderStyle="round" borderColor={g?.over ? (g.scoreYou > g.scoreCpu ? 'green' : 'red') : 'white'} width={bw * 2 + 2}>{rows}</Box>
79      {statusLine(Text, !!s?.banner, `${text} · best margin ${props?.best ?? 0}`)}
80    </Box>
81  )
82}
83
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 55 lines
1// Pong against the computer: pure game logic, drawn by the Pong module in ../surface.tsx.
2// Your paddle is the left column, the computer's the right; first to WIN points wins.
3
4export type Ball = { x: number; y: number; dx: number; dy: number }
5export type PongGame = { w: number; h: number; ball: Ball; you: number; cpu: number; pad: number; scoreYou: number; scoreCpu: number; over: boolean }
6
7export const WIN = 5
8// the computer tracks the ball at under a row per tick, so a steep ball gets past it
9const CPU_SPEED = 0.6
10
11const serve = (w: number, h: number, towardYou: boolean, rand: () => number): Ball =>
12  ({ x: Math.floor(w / 2), y: h / 2, dx: towardYou ? -1 : 1, dy: rand() < 0.5 ? -0.5 : 0.5 })
13
14export function newPong(w: number, h: number, rand = Math.random): PongGame {
15  const pad = Math.max(3, Math.floor(h / 4))
16  const mid = Math.floor((h - pad) / 2)
17  return { w, h, ball: serve(w, h, false, rand), you: mid, cpu: mid, pad, scoreYou: 0, scoreCpu: 0, over: false }
18}
19
20const clampPad = (g: PongGame, top: number) => Math.max(0, Math.min(g.h - g.pad, top))
21
22// move your paddle by rows, or to a row (a pointer over the board), keeping it on the board
23export const nudge = (g: PongGame, rows: number): PongGame => (g.over ? g : { ...g, you: clampPad(g, g.you + rows) })
24export const place = (g: PongGame, row: number): PongGame => (g.over ? g : { ...g, you: clampPad(g, Math.round(row - g.pad / 2)) })
25
26// a paddle at `top` covers the ball's row: send it back, steeper the further from the centre it hits
27function returned(g: PongGame, top: number, ball: Ball, dx: number): Ball | undefined {
28  const row = Math.round(ball.y)
29  if (row < top || row >= top + g.pad) return undefined
30  const offset = (row - (top + (g.pad - 1) / 2)) / g.pad
31  return { ...ball, dx, dy: Math.max(-1, Math.min(1, ball.dy + offset)) }
32}
33
34export function tick(g: PongGame, rand = Math.random): PongGame {
35  if (g.over) return g
36  let ball: Ball = { ...g.ball, x: g.ball.x + g.ball.dx, y: g.ball.y + g.ball.dy }
37  // top and bottom walls
38  if (ball.y < 0) ball = { ...ball, y: -ball.y, dy: -ball.dy }
39  if (ball.y > g.h - 1) ball = { ...ball, y: 2 * (g.h - 1) - ball.y, dy: -ball.dy }
40  // the computer follows the ball while it comes toward it
41  const target = ball.y - (g.pad - 1) / 2
42  const cpu = ball.dx > 0 ? clampPad(g, g.cpu + Math.max(-CPU_SPEED, Math.min(CPU_SPEED, target - g.cpu))) : g.cpu
43  let { scoreYou, scoreCpu } = g
44  if (ball.x <= 0) {
45    const back = returned(g, g.you, ball, 1)
46    if (back) ball = { ...back, x: 1 }
47    else { scoreCpu++; ball = serve(g.w, g.h, false, rand) }
48  } else if (ball.x >= g.w - 1) {
49    const back = returned(g, Math.round(cpu), ball, -1)
50    if (back) ball = { ...back, x: g.w - 2 }
51    else { scoreYou++; ball = serve(g.w, g.h, true, rand) }
52  }
53  return { ...g, ball, cpu, scoreYou, scoreCpu, over: scoreYou >= WIN || scoreCpu >= WIN }
54}
55