SLOPSHOPPER

pacman

Pac-Man above the Claude Code prompt: a 19×15 maze, four ghosts that chase, scatter and flee, power pellets, three lives and faster levels. Plays while Claude…

newbandcommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · pacman
› 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 › /pacman ⎿ pacman: Pac-Man · click the maze, then an arrow key · Esc returns to the prompt · /pacman stop closes [ close ] ▣ client module ./boards/pacman.tsx ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

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

pacman

Pac-Man above the Claude Code prompt: a 19×15 maze, four ghosts that chase, scatter and flee, power pellets, three lives and faster levels. Plays while Claude works, pauses when the turn ends, keeps your best score; zero tokens. Needs an interactive terminal. Zero tokens: the mod answers every key and click itself and never asks the model anything.

Play

  1. /pacman opens the maze above the prompt (/pacman stop or the close button closes it).
  2. Click the maze to give it the keyboard; Esc gives the keyboard back to the prompt.
  3. Arrows or WASD steer: the turn is taken at the next opening. Space or p pauses, r restarts.
  4. Pellets are 10 points, a power pellet 50 and makes the ghosts edible for a few seconds: 200, 400, 800, 1600 in a row. The row in the middle is a tunnel. Clear the maze and the next level is faster.

The four ghosts behave like the originals: red chases you, pink aims four cells ahead of you, cyan mirrors red through you, green chases while far and runs to its corner while near. They switch to a scatter pattern now and then.

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

Install

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

It is written to .claude/skills/pacman/, which Claude Code auto-loads as pacman@skills-dir. For one session with hot reload: claude --plugin-dir .claude/skills/pacman. claude plugin validate .claude/skills/pacman prints every event it hooks and every $ call it makes.

How it is built

hooks/register.tsx is the hooks module: it registers /pacman, mounts the board on the AbovePrompt band and keeps the score in $.store. hooks/games/*.ts is the game's logic, pure functions with no drawing, and hooks/boards/*.tsx is the surface module that draws it in two-character cells on the drawing thread and takes the keys and the mouse. Written for aitmpl.com against Anthropic's published function-hooks declarations.

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.

A clone of the genre, not affiliated with or endorsed by the original publisher.

Source 4 files
hooks/register.tsx 89 lines
1/* @jsx h */
2import type { Register } from 'claude-code'
3
4// Pac-Man above the Claude Code prompt. `/pacman` opens it, `/pacman stop` closes it. The board in
5// ./boards/pacman.tsx is a surface module that runs on the drawing thread; this module mounts it,
6// keeps the best score in $.store and pauses the game when Claude finishes a turn. Zero tokens: the
7// mod answers every key itself and never asks the model anything.
8
9const TITLE = 'Pac-Man'
10
11let open = false
12// bumped at each finished turn; the open game pauses when it sees a new value
13let turnsDone = 0
14let best = 0
15
16export const register: Register = on => {
17  on('session.start', async ($, e, next) => {
18    const r = await next(e)
19    // a store that cannot be read costs the best score, never the game
20    const score = Number(await $.store.get('best').catch(() => undefined))
21    if (score) best = score
22    await $.command.register({
23      name: 'pacman',
24      description: 'Pac-Man above the prompt, while Claude works (stop closes)',
25      argumentHint: '[stop]',
26      immediate: true,
27    }).catch(err => $.ui.log(`pacman: /pacman not registered: ${err}`))
28    return r
29  })
30
31  on('command.run', { command: 'pacman' }, async ($, e) => {
32    const arg = e.args.trim().toLowerCase()
33    if (arg === 'stop' || arg === 'close') {
34      open = false
35      $.ui.invalidate('ui.render')
36      return { text: `${TITLE} closed` }
37    }
38    open = true
39    $.ui.invalidate('ui.render')
40    return { text: `${TITLE} · click the maze, then an arrow key · Esc returns to the prompt · /pacman stop closes${best ? ` · best ${best}` : ''}` }
41  })
42
43  on('turn.complete', async ($, e, next) => {
44    const r = await next(e)
45    // a subagent's turn is not the one the person is waiting for
46    if (e.agentId) return r
47    if (open) {
48      turnsDone++
49      $.ui.invalidate('ui.render')
50    }
51    return r
52  })
53
54  // a finished round (or a cleared maze), posted by the board: keep the best score
55  on('ui.message', async ($, e, next) => {
56    const data = e.data as { game?: unknown; score?: unknown } | null
57    if (data?.game !== 'pacman' || typeof data.score !== 'number') return next(e)
58    if (Number.isFinite(data.score) && data.score > best) {
59      best = data.score
60      await $.store.set('best', best).catch(err => $.ui.log(`pacman: store write failed: ${err}`))
61      $.ui.toast(`${TITLE}: new best ${best}`)
62    }
63    return { props: { best, done: turnsDone } }
64  })
65
66  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
67    // the board needs a terminal's keys; the desktop and mobile surfaces draw their own band
68    if (!open || e.props.hasSurvey || e.surface !== 'terminal') return next(e)
69    const { Box, Button, Client } = $.ui.resolve(e)
70    // the band's own width: the transcript column's while a Pane is docked beside it
71    const cols = e.props.bodyColumns || (e.viewport?.columns ?? 80)
72    // one row for the close button, one for whatever else draws in the band; the maze is 15 rows + border
73    const rows = Math.max(8, Math.min(e.props.maxRows - 2, 18))
74    const close = () => {
75      open = false
76      $.ui.invalidate('ui.render')
77    }
78    return (
79      <Box flexDirection="column">
80        <Box flexDirection="row" columnGap={1}>
81          <Button key="pacman:close" label="close" onPress={close} />
82        </Box>
83        <Client key="board:pacman" module="./boards/pacman.tsx" width={cols} height={rows} props={{ best, done: turnsDone }} />
84        {await next(e)}
85      </Box>
86    )
87  })
88}
89
hooks/boards/pacman.tsx 73 lines
1/* @jsx h */
2import type { ClientSurface } from 'claude-code'
3import { base, claudeDone, grid, isSpace, statusLine, type Base, type Props } from './common.tsx'
4import { MAZE, newPacman, nextLevel, step, turn, type PacmanGame } from '../games/pacman.ts'
5
6type State = Base & { game?: PacmanGame }
7
8const TICK_MS = 160
9
10export default function Pacman(props: Props, surface: ClientSurface<State>) {
11  const { Box, Text } = surface.elements
12
13  if (surface.state === undefined) {
14    surface.setState(base(props))
15    surface.every(TICK_MS, () => {
16      const s = surface.state
17      if (!s?.game || !s.playing || s.game.over) return
18      let game = step(s.game)
19      if (game.won) {
20        surface.post({ game: 'pacman', score: game.score })
21        game = nextLevel(game)
22      }
23      if (game.over) surface.post({ game: 'pacman', score: game.score })
24      surface.setState({ ...s, game })
25    })
26    surface.onKey(({ key }) => {
27      const s = surface.state
28      if (!s) return
29      const k = key.toLowerCase()
30      if (isSpace(k) || k === 'p') {
31        if (s.game && !s.game.over) surface.setState({ ...s, banner: false, playing: !s.playing })
32        return
33      }
34      // no round yet, or the last one ended: an arrow (or r) starts a new one
35      if (!s.game || s.game.over || k === 'r') {
36        surface.setState({ ...s, banner: false, playing: true, game: turn(newPacman(), k) })
37        return
38      }
39      surface.setState({ ...s, banner: false, playing: true, game: turn(s.game, k) })
40    })
41  }
42  claudeDone(props, surface)
43
44  const s = surface.state
45  const g = s?.game
46  const w = MAZE[0]!.length
47  // never `h`: that is the JSX factory in a surface module
48  const mazeH = MAZE.length
49  const ghostAt = new Map<number, { color: string; eaten: boolean }>()
50  for (const ghost of g?.ghosts ?? []) ghostAt.set(ghost.pos[1] * w + ghost.pos[0], { color: ghost.color, eaten: ghost.eaten })
51  const rows = grid(Text, w, mazeH, (x, y) => {
52    const k = y * w + x
53    if (g?.walls.has(k) ?? MAZE[y]![x] === '#') return ['██', 'blue']
54    if (g && g.pac[0] === x && g.pac[1] === y) return [g.over ? '××' : g.dir === 'left' ? '◖█' : g.dir === 'right' ? '█◗' : '██', 'yellow']
55    const ghost = ghostAt.get(k)
56    if (ghost) return ghost.eaten ? ['ᵒᵒ', 'white'] : g && g.fright > 0 ? [g.fright < 12 && g.tick % 2 === 0 ? '▒▒' : '▓▓', 'blueBright'] : ['▛▜', ghost.color]
57    if (g ? g.power.has(k) : MAZE[y]![x] === 'o') return [' ●', 'yellowBright']
58    if (g ? g.pellets.has(k) : MAZE[y]![x] === '.') return [' ·', 'yellow']
59    return ['  ']
60  })
61  const lives = g ? '♥'.repeat(Math.max(0, g.lives)) : '♥♥♥'
62  const text = !g ? 'pac-man · click here, then an arrow key · wasd works too'
63    : g.over ? `pac-man · game over · score ${g.score} · an arrow or r plays again`
64    : !s?.playing ? `pac-man · paused · score ${g.score} · space resumes`
65    : `pac-man · score ${g.score} · level ${g.level} · ${lives}${g.fright > 0 ? ' · eat them!' : ''} · space pauses`
66  return (
67    <Box flexDirection="column">
68      <Box flexDirection="column" borderStyle="round" borderColor={g?.over ? 'red' : g && g.fright > 0 ? 'blueBright' : 'yellow'} width={w * 2 + 2}>{rows}</Box>
69      {statusLine(Text, !!s?.banner, `${text} · best ${props?.best ?? 0}`)}
70    </Box>
71  )
72}
73
hooks/boards/common.tsx 53 lines
1/* @jsx h */
2import type { ClientElements, ClientSurface } from 'claude-code'
3
4// Shared by the aitmpl game boards. A board is a surface module: it runs on the drawing thread
5// with its own frame clock (surface.every), keys once a click gives it focus (Esc gives the
6// prompt back) and the mouse. Never name a local `h` here: every JSX tag compiles to a call of `h`.
7
8/** What the hooks module hands every board: the best score, and a counter bumped per finished turn. */
9export type Props = { best?: number; done?: number } | undefined
10export type Base = { playing: boolean; banner: boolean; seenDone: number }
11/** A cell as drawn: two characters, a colour, an optional background. */
12export type Paint = readonly [glyph: string, color?: string, bg?: string]
13type TextTag = ClientElements['Text']
14
15export const base = (props: Props): Base => ({ playing: false, banner: false, seenDone: props?.done ?? 0 })
16
17/** Claude finished a turn (props.done moved): show the banner and pause, once per turn. */
18export function claudeDone<S extends Base>(props: Props, surface: ClientSurface<S>, pause = true) {
19  const s = surface.state
20  const done = props?.done ?? 0
21  if (s && done !== s.seenDone) surface.setState({ ...s, seenDone: done, banner: true, playing: pause ? false : s.playing })
22}
23
24export function statusLine(Text: TextTag, banner: boolean, text: string) {
25  return banner
26    ? <Text color="yellow" bold wrap="truncate-end">{`● Claude is done · ${text}`}</Text>
27    : <Text dimColor wrap="truncate-end">{text}</Text>
28}
29
30type Run = [text: string, color?: string, bg?: string]
31
32/** One row of cells as runs of equal colour, so a row costs a handful of nodes rather than one per cell. */
33function runs(cols: number, y: number, at: (x: number, y: number) => Paint): Run[] {
34  const out: Run[] = []
35  for (let x = 0; x < cols; x++) {
36    const [glyph, color, bg] = at(x, y)
37    const last = out[out.length - 1]
38    if (last && last[1] === color && last[2] === bg) last[0] += glyph
39    else out.push([glyph, color, bg])
40  }
41  return out
42}
43
44/** A board of two-character cells (a terminal cell is about twice as tall as wide, so two make a square). */
45export function grid(Text: TextTag, cols: number, rowCount: number, at: (x: number, y: number) => Paint) {
46  return Array.from({ length: Math.max(0, rowCount) }, (_, y) => (
47    <Text>{runs(cols, y, at).map(([t, c, b]) => (b ? <Text color={c} backgroundColor={b}>{t}</Text> : <Text color={c}>{t}</Text>))}</Text>
48  ))
49}
50
51/** The space bar arrives as the character or by name, depending on the terminal. */
52export const isSpace = (key: string) => key === ' ' || key === 'space'
53
hooks/games/pacman.ts 230 lines
1// Pac-Man's rules, pure and testable: a maze of pellets, a player who turns at the next opening,
2// four ghosts that chase, scatter or flee, power pellets, lives and levels. No drawing here; the
3// board in ../boards/pacman.tsx renders a PacmanGame and feeds it keys.
4
5export type Dir = 'up' | 'down' | 'left' | 'right'
6export type Pt = readonly [x: number, y: number]
7export type Ghost = { pos: Pt; dir: Dir; color: string; home: Pt; eaten: boolean }
8export type PacmanGame = {
9  readonly w: number
10  readonly h: number
11  /** walls, as `y*w+x` keys */
12  readonly walls: ReadonlySet<number>
13  pellets: Set<number>
14  power: Set<number>
15  pac: Pt
16  dir: Dir
17  wanted: Dir
18  ghosts: Ghost[]
19  /** ticks of fright left; ghosts are edible while > 0 */
20  fright: number
21  /** ticks until the ghosts switch between chase and scatter */
22  modeClock: number
23  chasing: boolean
24  score: number
25  lives: number
26  level: number
27  /** ghosts eaten during one power pellet: 200, 400, 800, 1600 */
28  combo: number
29  over: boolean
30  won: boolean
31  tick: number
32}
33
34// `#` wall, `.` pellet, `o` power pellet, ` ` open floor, `P` the player's start, `G` a ghost's
35// start, `-` the pen door (floor the player never enters). Row 7 is open at both ends: a tunnel.
36export const MAZE: readonly string[] = [
37  '###################',
38  '#........#........#',
39  '#o##.###.#.###.##o#',
40  '#.................#',
41  '#.##.#.#####.#.##.#',
42  '#....#.......#....#',
43  '####.#.##-##.#.####',
44  '    ...#GGG#...    ',
45  '####.#.#####.#.####',
46  '#........#........#',
47  '#.##.###.#.###.##.#',
48  '#o.#.....P.....#.o#',
49  '##.#.#.#####.#.#.##',
50  '#....#...#...#....#',
51  '###################',
52]
53
54export const GHOST_COLORS = ['red', 'magenta', 'cyan', 'green'] as const
55const DIRS: readonly Dir[] = ['up', 'down', 'left', 'right']
56const DELTA: Record<Dir, Pt> = { up: [0, -1], down: [0, 1], left: [-1, 0], right: [1, 0] }
57const OPPOSITE: Record<Dir, Dir> = { up: 'down', down: 'up', left: 'right', right: 'left' }
58const FRIGHT_TICKS = 45
59const CHASE_TICKS = 140
60const SCATTER_TICKS = 50
61
62const key = (w: number, [x, y]: Pt) => y * w + x
63const same = (a: Pt, b: Pt) => a[0] === b[0] && a[1] === b[1]
64const dist = (a: Pt, b: Pt) => Math.abs(a[0] - b[0]) + Math.abs(a[1] - b[1])
65
66export function newPacman(level = 1, score = 0, lives = 3): PacmanGame {
67  const h = MAZE.length
68  const w = MAZE[0]!.length
69  const walls = new Set<number>()
70  const pellets = new Set<number>()
71  const power = new Set<number>()
72  const ghosts: Ghost[] = []
73  let pac: Pt = [1, 1]
74  let door: Pt = [9, 6]
75  for (let y = 0; y < h; y++) {
76    for (let x = 0; x < w; x++) {
77      const c = MAZE[y]![x]
78      const k = y * w + x
79      if (c === '#') walls.add(k)
80      else if (c === '.') pellets.add(k)
81      else if (c === 'o') power.add(k)
82      else if (c === 'P') pac = [x, y]
83      else if (c === '-') door = [x, y]
84      else if (c === 'G') ghosts.push({ pos: [x, y], dir: 'up', color: GHOST_COLORS[ghosts.length % GHOST_COLORS.length]!, home: [x, y], eaten: false })
85    }
86  }
87  // the fourth ghost starts on the door, already out
88  ghosts.push({ pos: door, dir: 'left', color: GHOST_COLORS[3]!, home: door, eaten: false })
89  return {
90    w, h, walls, pellets, power, pac, dir: 'left', wanted: 'left', ghosts,
91    fright: 0, modeClock: CHASE_TICKS, chasing: true, score, lives, level, combo: 0, over: false, won: false, tick: 0,
92  }
93}
94
95/** The cell one step from `p` in `d`, wrapping through the tunnel row. */
96function ahead(g: PacmanGame, p: Pt, d: Dir): Pt {
97  const [dx, dy] = DELTA[d]
98  return [(p[0] + dx + g.w) % g.w, (p[1] + dy + g.h) % g.h]
99}
100
101function open(g: PacmanGame, p: Pt): boolean {
102  return !g.walls.has(key(g.w, p))
103}
104
105// the player cannot walk into the pen: its door is floor only for ghosts
106function openForPac(g: PacmanGame, p: Pt): boolean {
107  return open(g, p) && MAZE[p[1]]![p[0]] !== '-' && MAZE[p[1]]![p[0]] !== 'G'
108}
109
110export function turn(g: PacmanGame, k: string): PacmanGame {
111  const d: Dir | undefined =
112    k === 'up' || k === 'w' ? 'up' : k === 'down' || k === 's' ? 'down' : k === 'left' || k === 'a' ? 'left' : k === 'right' || k === 'd' ? 'right' : undefined
113  return d ? { ...g, wanted: d } : g
114}
115
116function ghostTarget(g: PacmanGame, i: number): Pt {
117  if (!g.chasing) {
118    // scatter: each ghost heads for its own corner
119    return ([[g.w - 2, 1], [1, 1], [g.w - 2, g.h - 2], [1, g.h - 2]] as const)[i % 4] as Pt
120  }
121  const [px, py] = g.pac
122  const [dx, dy] = DELTA[g.dir]
123  switch (i % 4) {
124    case 0: return g.pac // Blinky: straight at the player
125    case 1: return [px + dx * 4, py + dy * 4] // Pinky: four cells ahead
126    case 2: { // Inky: the mirror of Blinky's position through a point two cells ahead
127      const b = g.ghosts[0]?.pos ?? g.pac
128      return [2 * (px + dx * 2) - b[0], 2 * (py + dy * 2) - b[1]]
129    }
130    default: { // Clyde: chases while far, runs to its corner while near
131      const c = g.ghosts[3]?.pos ?? g.pac
132      return dist(c, g.pac) > 6 ? g.pac : [1, g.h - 2]
133    }
134  }
135}
136
137function moveGhost(g: PacmanGame, ghost: Ghost, i: number, rnd: () => number): Ghost {
138  if (ghost.eaten) {
139    // eyes only: fly home, then come back out
140    if (same(ghost.pos, ghost.home)) return { ...ghost, eaten: false, dir: 'up' }
141    const options = DIRS.filter(d => open(g, ahead(g, ghost.pos, d)))
142    const best = options.sort((a, b) => dist(ahead(g, ghost.pos, a), ghost.home) - dist(ahead(g, ghost.pos, b), ghost.home))[0] ?? ghost.dir
143    return { ...ghost, dir: best, pos: ahead(g, ghost.pos, best) }
144  }
145  const frightened = g.fright > 0
146  // a ghost never reverses on its own, unless the corridor is a dead end
147  let options = DIRS.filter(d => d !== OPPOSITE[ghost.dir] && open(g, ahead(g, ghost.pos, d)))
148  if (options.length === 0) options = [OPPOSITE[ghost.dir]]
149  let dir: Dir
150  if (frightened) dir = options[Math.floor(rnd() * options.length)]!
151  else {
152    const target = ghostTarget(g, i)
153    // a little noise keeps four ghosts from stacking on one path
154    dir = options.sort((a, b) => dist(ahead(g, ghost.pos, a), target) - dist(ahead(g, ghost.pos, b), target))[rnd() < 0.15 && options.length > 1 ? 1 : 0]!
155  }
156  return { ...ghost, dir, pos: ahead(g, ghost.pos, dir) }
157}
158
159function collide(g: PacmanGame): PacmanGame {
160  let out = g
161  for (let i = 0; i < out.ghosts.length; i++) {
162    const ghost = out.ghosts[i]!
163    if (ghost.eaten || !same(ghost.pos, out.pac)) continue
164    if (out.fright > 0) {
165      const points = 200 * 2 ** out.combo
166      const ghosts = out.ghosts.map((x, j) => (j === i ? { ...x, eaten: true } : x))
167      out = { ...out, ghosts, score: out.score + points, combo: out.combo + 1 }
168    } else {
169      const lives = out.lives - 1
170      const fresh = newPacman(out.level, out.score, lives)
171      return { ...fresh, pellets: out.pellets, power: out.power, over: lives <= 0, tick: out.tick }
172    }
173  }
174  return out
175}
176
177/** One step of the clock. Ghosts move every tick; on early levels they skip one tick in four. */
178export function step(g: PacmanGame, rnd: () => number = Math.random): PacmanGame {
179  if (g.over || g.won) return g
180  let out: PacmanGame = { ...g, tick: g.tick + 1 }
181
182  // the player: take the wanted turn when its cell is open, else keep going, else stop at the wall
183  const turnCell = ahead(out, out.pac, out.wanted)
184  if (openForPac(out, turnCell)) out = { ...out, dir: out.wanted, pac: turnCell }
185  else {
186    const straight = ahead(out, out.pac, out.dir)
187    if (openForPac(out, straight)) out = { ...out, pac: straight }
188  }
189
190  // eat
191  const k = key(out.w, out.pac)
192  if (out.pellets.has(k)) {
193    const pellets = new Set(out.pellets); pellets.delete(k)
194    out = { ...out, pellets, score: out.score + 10 }
195  } else if (out.power.has(k)) {
196    const power = new Set(out.power); power.delete(k)
197    out = { ...out, power, score: out.score + 50, fright: FRIGHT_TICKS, combo: 0 }
198  }
199  if (out.pellets.size === 0 && out.power.size === 0) return { ...out, won: true }
200
201  out = collide(out)
202  if (out.over) return out
203
204  // ghosts: slower than the player on low levels, as slow as fear makes them
205  const ghostsMove = out.fright > 0 ? out.tick % 2 === 0 : out.level < 3 ? out.tick % 4 !== 0 : true
206  if (ghostsMove) {
207    const ghosts = out.ghosts.map((ghost, i) => moveGhost(out, ghost, i, rnd))
208    out = { ...out, ghosts }
209    out = collide(out)
210    if (out.over) return out
211  }
212
213  // clocks
214  const fright = Math.max(0, out.fright - 1)
215  let { modeClock, chasing } = out
216  if (fright === 0) {
217    modeClock--
218    if (modeClock <= 0) {
219      chasing = !chasing
220      modeClock = chasing ? CHASE_TICKS : SCATTER_TICKS
221    }
222  }
223  return { ...out, fright, modeClock, chasing }
224}
225
226/** The next level: the same maze, full again, faster ghosts. */
227export function nextLevel(g: PacmanGame): PacmanGame {
228  return newPacman(g.level + 1, g.score, g.lives)
229}
230