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…

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.
/pacman opens the maze above the prompt (/pacman stop or the close button closes it).p pauses, r restarts.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.
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.
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.
claude -p, the desktop app or mobile.A clone of the genre, not affiliated with or endorsed by the original publisher.
hooks/register.tsx 89 lines1/* @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}
89hooks/boards/pacman.tsx 73 lines1/* @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}
73hooks/boards/common.tsx 53 lines1/* @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'
53hooks/games/pacman.ts 230 lines1// 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