SLOPSHOPPER

pacman

/pacman: Pac-Man played over the conversation on screen, eating it character by character while Claude works; Esc puts it back. Needs function hooks…

newpanebandspinnerrowscommand
★ 1v0.1.0no licenseupdated 2026-10-05lukecameron/dotfiles/claude-mods/pacman
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · pacman
│ ┃ pacman ✕ › fix the failing auth test and add an audit log call │ ┃ ᗧ ready │ ┃ w: ↑ a: ← s: ↓ d: → ⏺ Read(src/auth.ts) │ ┃ k: ↑ h: ← j: ↓ l: → ⎿ Read 6 lines │ ┃ click here to steer ⏺ 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: WASD or hjkl to move · Esc puts the conversation back │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · pacman
ᗧ ready w: ↑ a: ← s: ↓ d: → k: ↑ h: ← j: ↓ l: → click here to steer
README

Dotfiles

Personal macOS development environment, managed with chezmoi.

The configuration currently targets Apple Silicon Macs. Linux support can be added when it is needed. The pre-Chezmoi configuration is retained in Git under the legacy-2026-08-05 tag.

New machine

Run:

/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
eval "$(/opt/homebrew/bin/brew shellenv)"
brew install chezmoi
chezmoi init --apply lukecameron

Chezmoi asks whether the machine is personal or work and which short name (m1-air, mitti-air, or slate-pro) to show in the prompt. The short name defaults to m1-air for personal machines and mitti-air for work machines. Packages are shared by default, with additive role-specific sections in Brewfile.tmpl.

Authentication and secrets remain machine-local. Sign in to 1Password, the coding harnesses, and any other services after setup.

Everyday use

chezmoi edit ~/.zshrc
chezmoi diff
chezmoi apply
chezmoi update

Homebrew setup installs missing packages but deliberately does not remove anything else. Run brew upgrade when you want to update Homebrew-managed tools.

Node's latest LTS release, Pi, Treehouse, and the AXI command-line tools are installed by mise. They track release channels rather than fixed versions, so any change to dot_config/mise/config.toml upgrades all of them to the newest release mise considers eligible; bump its Pi marker to trigger that. No Mistakes uses its official user-local layout so its self-updater and launchd daemon share a stable executable path. Python runtimes and environments are installed on demand by uv.

Bun is installed by Homebrew for Herdr plugins so long-lived Herdr servers have a stable runtime path. The managed rjyo/herdr-window-title-sync plugin publishes the focused Herdr workspace, tab, and agent session through the outer terminal title so Moshi can identify resumed sessions; its local-session fallback can include recent Codex or Claude prompt text in that title.

The Herdr Auto Title plugin names tabs and panes after the Claude Code session title in each one. It is pinned to a known commit, built from source with the Go toolchain mise installs, and installed automatically after Herdr and mise tools are available, together with Herdr's Claude Code integration hook. Herdr only starts plugins when its server starts, so run herdr server stop and reopen Herdr after a fresh install.

Herdr's mobile view keeps split panes side by side, which leaves two unreadable half-width panes on a phone. The herdr-narrow-tabs launchd agent watches the width of the client in front. While it is 80 columns or narrower, the agent moves the right-hand pane of every two-pane tab into a tab of its own, which the mobile switcher lists under tabs. At 100 columns or wider it moves each pane back at its original split ratio. It logs to ~/Library/Logs/herdr-narrow-tabs.log.

Pi's global settings are merged into its existing file so authentication, sessions, trust decisions, and caches stay machine-local. Existing provider and model selections are preserved; defaults are supplied only when missing. The managed settings keep startup quiet, deliver queued steering and follow-ups together, and pin pi-web-access for web research. Pi invokes npm through mise, and its terminal title shows the machine short name plus the current session or directory while it works.

Shared agent guidance is rendered for each supported harness. Reusable cross-agent workflows live in ~/.agents/skills; this setup includes evidence-backed change review and preregistered evaluation skills.

LazyVim installs its plugins the first time nvim starts. Commit ~/.config/nvim/lazy-lock.json through chezmoi whenever plugin versions change.

Claude Code plugins

Work machines get the Datadog plugin from the official marketplace, set to Slate's Datadog site (AU, ap2). Sign in once per machine: /mcp, select plugin:datadog:mcp, then Authenticate.

Claude Code mods

claude-mods/ is a local plugin marketplace of Claude Code mods: plugins built on function hooks, which Claude Code loads only with the early-access CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 that ~/.zshenv sets. chezmoi apply installs them, and reinstalls them after any change, because Claude Code runs a cached copy.

  • pacman: /pacman plays Pac-Man over the conversation on screen, even while Claude is working. WASD or hjkl steer, and Escape closes the game and draws the conversation as it was, without interrupting Claude.

Run a mod's tests and type check from its folder:

CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude plugin test .
bunx -p typescript tsc -p .

The type check reads .claude-plugin/types/, which Claude Code writes for its own version when it loads the mod with claude --plugin-dir <mod>.

Remote shell

Mosh is installed on every machine and runs over Tailscale. On each destination Mac, enable System Settings → General → Sharing → Remote Login, then connect using its Tailscale MagicDNS name:

mosh luke@machine-name

The tailnet policy must allow TCP port 22 and UDP ports 60000–61000 from the client to the destination.

moshi-hook is installed from the trusted rjyo/moshi tap and registered as a per-user service. Its pairing token and generated agent-hook configuration remain machine-local.

Local configuration

Use ~/.zprofile.local and ~/.zshrc.local for machine-only shell configuration. These files are intentionally unmanaged.

Codex's config.toml is a create-only seed. Chezmoi supplies initial defaults on a new machine, then leaves the file untouched so Codex can persist session settings locally without creating dotfile drift.

Source 2 files
hooks/register.tsx 235 lines
1/* @jsx h */
2import type { Register, RenderInput, RenderNode } from 'claude-code'
3import { newGame, siteCells, tick, type Dir, type Game, type Site } from './game'
4
5// /pacman: Pac-Man played over the conversation on screen. Every transcript
6// row the screen shows becomes the board, drawn in place of the message, and
7// pacman eats it character by character while the ghosts chase him. A small
8// pane holds the keyboard (WASD or hjkl), so Escape closes the game without
9// reaching the prompt, where it would interrupt Claude. Closing it draws the
10// transcript as it was; the stored conversation is never touched.
11
12const PANE = 'pacman'
13const FRAME_MS = 110
14
15// What the transcript last reported of each message: the render input, the
16// order it first appeared in (messages never reorder, so first appearance is
17// top to bottom), and which of its rows are on screen.
18type OnScreen = { first: number; last: number; of: number }
19type Seen = { e: RenderInput; seq: number; onScreen: OnScreen | null }
20const seen = new Map<string, Seen>()
21let seq = 0
22
23let game: Game | undefined
24let pending = false
25let timer: { cancel: () => void } | undefined
26// the transcript's width as last drawn, and frames left until it counts as
27// settled: opening the pane narrows the transcript, and the board is built
28// only once every message has re-laid out at the new width
29let columns = 0
30let unsettled = 0
31const SETTLE_FRAMES = 3
32
33const TRANSCRIPT = new Set(['UserMessage', 'AssistantMessage', 'ToolUse', 'ToolResult', 'ToolGroup', 'CommandOutput', 'TurnDuration', 'InfoNotice'])
34
35function brief(value: unknown): string {
36  if (value === undefined || value === null) return ''
37  if (typeof value === 'string') return value
38  if (typeof value === 'object') {
39    const first = Object.values(value as Record<string, unknown>).find(v => typeof v === 'string')
40    if (typeof first === 'string') return first
41  }
42  return JSON.stringify(value)
43}
44
45function firstLine(value: unknown): string {
46  return brief(value).split('\n').find(line => line.trim() !== '') ?? ''
47}
48
49function plain(markdown: string): string {
50  return markdown
51    .replace(/```[^\n]*\n?/g, '')
52    .replace(/^#{1,6}\s+/gm, '')
53    .replace(/\*\*|__|`/g, '')
54    .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
55}
56
57// What a message's rows read as on the board: the text the engine draws for
58// it, in a plain form close to the engine's own layout.
59function textOf(e: RenderInput): { lead: string; text: string; spaced?: false } | undefined {
60  const p = e.props as Record<string, unknown>
61  switch (e.component) {
62    case 'UserMessage':
63      return { lead: '❯ ', text: String(p.text ?? '') }
64    case 'AssistantMessage':
65      return { lead: p.isFirstOfReply ? '⏺ ' : '  ', text: plain(String(p.text ?? '')) }
66    case 'ToolUse': {
67      const call = `${p.tool}(${firstLine(p.input)})`
68      const out = firstLine(p.output)
69      return { lead: '⏺ ', text: out ? `${call}\n  ⎿  ${out}` : call }
70    }
71    case 'ToolResult':
72      return { lead: '  ⎿  ', text: firstLine(p.output), spaced: false }
73    case 'ToolGroup': {
74      const calls = (p.calls as { tool: string; input: unknown }[]) ?? []
75      return { lead: '⏺ ', text: calls.map(c => `${c.tool}(${firstLine(c.input)})`).join('\n') }
76    }
77    case 'CommandOutput':
78      return { lead: '  ⎿  ', text: String(p.text ?? ''), spaced: false }
79    case 'TurnDuration':
80      return { lead: '✻ ', text: `${p.word} for ${Math.round(Number(p.durationMs ?? 0) / 1000)}s` }
81    case 'InfoNotice':
82      return { lead: '⏺ ', text: String(p.text ?? '') }
83  }
84  return undefined
85}
86
87// The board: every message with rows on screen, in transcript order, as
88// cells at the transcript's width (less its gutter and scrollbar, and the
89// extra cell some terminals give the ⏺ bullet).
90function buildGame(width: number): Game {
91  const sites: Site[] = []
92  for (const [id, s] of [...seen].sort((a, b) => a[1].seq - b[1].seq)) {
93    if (!s.onScreen) continue
94    const body = textOf(s.e)
95    if (!body) continue
96    const cells = siteCells(body.text, width, body.lead, s.onScreen.of, body.spaced !== false)
97    sites.push({ id, seq: s.seq, cells, first: s.onScreen.first, last: s.onScreen.last })
98  }
99  return newGame(sites, width)
100}
101
102const PAC: Record<Dir, string> = { right: 'ᗧ', left: 'ᗤ', up: 'ᗢ', down: 'ᗜ' }
103
104function stop() {
105  timer?.cancel()
106  timer = undefined
107  game = undefined
108  pending = false
109}
110
111export const register: Register = on => {
112  on('session.start', async ($, e, next) => {
113    const r = await next(e)
114    await $.command.register({
115      name: 'pacman',
116      description: 'Play Pac-Man over the conversation on screen; Esc puts it back',
117      immediate: true,
118    }).catch(err => $.ui.log(`pacman: /pacman not registered: ${err}`))
119    // a resumed session may bring the pane back without a game behind it
120    if ((await $.ui.panes()).some(p => p.id === PANE)) await $.ui.close({ id: PANE })
121    return r
122  })
123
124  on('command.run', { command: 'pacman' }, async ($, e) => {
125    if (!e.presentation.isFullscreen && e.presentation.columns < 20) return { text: 'pacman needs a wider terminal' }
126    stop()
127    pending = true
128    await $.ui.open({ id: PANE, title: 'pacman', focus: true, closeOnEscape: true, holdToasts: true, rows: 4, columns: 26 })
129    // the frame clock builds the board on its first tick, once the transcript
130    // has reported which rows it shows at the width the pane leaves it
131    unsettled = SETTLE_FRAMES
132    timer = $.clock.every(FRAME_MS, () => {
133      if (unsettled > 0) {
134        unsettled--
135        return
136      }
137      if (pending) {
138        pending = false
139        game = buildGame(Math.max(20, columns - 4))
140      } else if (game && game.state === 'playing') tick(game)
141      $.ui.invalidate('ui.render')
142    })
143    $.ui.invalidate('ui.render')
144    return { text: 'WASD or hjkl to move · Esc puts the conversation back' }
145  })
146
147  on('ui.close', { id: PANE }, async ($, e, next) => {
148    stop()
149    $.ui.invalidate('ui.render')
150    return next(e)
151  })
152
153  on('ui.render', { component: 'Pane' }, async ($, e, next) => {
154    if (e.requestId !== PANE || e.surface !== 'terminal') return next(e)
155    const { Box, Button, Text } = await $.ui.resolve(e)
156    const turn = (dir: Dir) => () => {
157      if (game) game.want = dir
158    }
159    const keys: [string, Dir, string][] = [
160      ['w', 'up', '↑'], ['a', 'left', '←'], ['s', 'down', '↓'], ['d', 'right', '→'],
161      ['k', 'up', '↑'], ['h', 'left', '←'], ['j', 'down', '↓'], ['l', 'right', '→'],
162    ]
163    const status =
164      !game ? 'ready'
165      : game.state === 'won' ? `you ate it all! score ${game.score}`
166      : game.state === 'over' ? `game over · score ${game.score}`
167      : `score ${game.score} · ${'ᗧ'.repeat(game.lives)}`
168    return (
169      <Box flexDirection="column">
170        <Text color="yellow" bold wrap="truncate-end">{`ᗧ ${status}`}</Text>
171        <Box flexDirection="row" columnGap={1}>
172          {keys.slice(0, 4).map(([key, dir, arrow]) => <Button key={`key:${key}`} label={arrow} hotkey={key} plain onPress={turn(dir)} />)}
173        </Box>
174        <Box flexDirection="row" columnGap={1}>
175          {keys.slice(4).map(([key, dir, arrow]) => <Button key={`key:${key}`} label={arrow} hotkey={key} plain onPress={turn(dir)} />)}
176        </Box>
177        <Text dimColor wrap="truncate-end">{e.props.isFocused ? 'Esc: back to the chat' : 'click here to steer'}</Text>
178      </Box>
179    )
180  })
181
182  on('ui.render', async ($, e, next) => {
183    if (!TRANSCRIPT.has(e.component) || e.surface !== 'terminal') return next(e)
184    const onScreen = (e.props as { onScreen?: OnScreen | null }).onScreen ?? null
185    const known = seen.get(e.requestId)
186    seen.set(e.requestId, { e, seq: known?.seq ?? ++seq, onScreen })
187
188    const width = e.viewport?.columns ?? 80
189    if (width !== columns) {
190      columns = width
191      unsettled = SETTLE_FRAMES
192      // a resize mid-game lays the board out anew at the new width
193      if (game) {
194        game = undefined
195        pending = timer !== undefined
196      }
197    }
198    const site = game?.sites.find(s => s.id === e.requestId)
199    if (!game || !site) return next(e)
200    if (onScreen) {
201      site.first = onScreen.first
202      site.last = onScreen.last
203    } else {
204      site.first = 1
205      site.last = 0
206    }
207
208    const { Box, Text } = await $.ui.resolve(e)
209    const chomp = game.tick % 4 < 2
210    const rows = site.cells.map((cells, row) => {
211      const line = cells.slice(0, game!.width)
212      const marks = new Map<number, [string, string]>()
213      for (const ghost of game!.ghosts) {
214        if (ghost.pos.site === site.id && ghost.pos.row === row) marks.set(ghost.pos.col, ['ᗣ', ghost.color])
215      }
216      if (game!.pac.site === site.id && game!.pac.row === row && game!.state !== 'over') {
217        marks.set(game!.pac.col, [chomp ? PAC[game!.dir] : '●', 'yellow'])
218      }
219      if (marks.size === 0) return <Text key={`r${row}`} wrap="truncate-end">{line.join('') || ' '}</Text>
220      while (line.length <= Math.max(...marks.keys())) line.push(' ')
221      const runs: RenderNode[] = []
222      let from = 0
223      for (const col of [...marks.keys()].sort((a, b) => a - b)) {
224        if (col > from) runs.push(line.slice(from, col).join(''))
225        const [glyph, color] = marks.get(col)!
226        runs.push(<Text key={`m${col}`} color={color} bold>{glyph}</Text>)
227        from = col + 1
228      }
229      if (from < line.length) runs.push(line.slice(from).join(''))
230      return <Text key={`r${row}`} wrap="truncate-end">{runs}</Text>
231    })
232    return <Box flexDirection="column">{rows}</Box>
233  })
234}
235
hooks/game.ts 240 lines
1// Pac-Man over the transcript rows on screen. The board is the visible text,
2// one cell per character: pacman eats every character he crosses, the ghosts
3// chase him, and there are no walls, so the conversation itself is the maze.
4// Pure state and rules; register.tsx turns transcript renders into sites and
5// draws the result.
6
7export type Dir = 'up' | 'down' | 'left' | 'right'
8
9// One transcript message as the board holds it: its rows as cells, and which
10// of those rows the screen shows now.
11export type Site = {
12  id: string
13  seq: number
14  cells: string[][]
15  first: number
16  last: number
17}
18
19export type Pos = { site: string; row: number; col: number }
20
21export type Ghost = { pos: Pos; color: string }
22
23export type Game = {
24  width: number
25  sites: Site[]
26  pac: Pos
27  dir: Dir
28  want: Dir
29  ghosts: Ghost[]
30  score: number
31  lives: number
32  tick: number
33  // ticks left before the ghosts start moving, after a start or a lost life
34  grace: number
35  state: 'playing' | 'won' | 'over'
36}
37
38const GRACE_TICKS = 18
39const GHOST_COLORS = ['red', 'magenta', 'cyan', '#ffb852'] as const
40const EMOJI = /\p{Extended_Pictographic}|\p{Regional_Indicator}|[\u{1100}-\u{115F}\u{2E80}-\u{A4CF}\u{AC00}-\u{D7A3}\u{F900}-\u{FAFF}\u{FE30}-\u{FE4F}\u{FF00}-\u{FF60}\u{FFE0}-\u{FFE6}]/u
41
42// Word-wraps `text` to `width` cells, the first row after `lead` and the rest
43// after spaces of the same width, one cell per character. Wide characters
44// become `*` so every character is exactly one cell.
45export function wrap(text: string, width: number, lead: string): string[][] {
46  const rows: string[][] = []
47  const indent = ' '.repeat([...lead].length)
48  const room = Math.max(8, width - indent.length)
49  for (const paragraph of text.replace(/\t/g, '  ').split('\n')) {
50    const chars = [...paragraph].map(c => (EMOJI.test(c) ? '*' : c))
51    if (chars.length === 0) {
52      rows.push([])
53      continue
54    }
55    let start = 0
56    while (start < chars.length) {
57      let end = Math.min(start + room, chars.length)
58      if (end < chars.length) {
59        const space = chars.lastIndexOf(' ', end)
60        if (space > start) end = space
61      }
62      rows.push(chars.slice(start, end))
63      start = end
64      while (chars[start] === ' ') start++
65    }
66  }
67  return rows.map((row, i) => [...(i === 0 ? lead : indent)].concat(row))
68}
69
70// A message's rows at exactly the height the engine laid it out at, so
71// drawing the board over it moves nothing on screen: the blank row the engine
72// draws above most messages (`spaced`), then the text, cut or padded to
73// `height` rows.
74export function siteCells(text: string, width: number, lead: string, height: number, spaced: boolean): string[][] {
75  const rows = [...(spaced ? [[]] : []), ...wrap(text, width, lead)].slice(0, height)
76  while (rows.length < height) rows.push([])
77  return rows
78}
79
80// Every visible row, top to bottom: which site and which of its rows.
81export function visibleRows(game: Game): { site: string; row: number }[] {
82  const rows: { site: string; row: number }[] = []
83  for (const site of game.sites) {
84    for (let row = Math.max(0, site.first); row <= Math.min(site.last, site.cells.length - 1); row++) {
85      rows.push({ site: site.id, row })
86    }
87  }
88  return rows
89}
90
91function rowIndex(rows: { site: string; row: number }[], pos: Pos): number {
92  return rows.findIndex(r => r.site === pos.site && r.row === pos.row)
93}
94
95// The visible row nearest to where `pos` was, so a scroll that takes a
96// sprite's row off screen puts it on the closest row still shown.
97function settle(game: Game, rows: { site: string; row: number }[], pos: Pos): Pos {
98  if (rows.length === 0) return pos
99  if (rowIndex(rows, pos) >= 0) return pos
100  const order = game.sites.map(s => s.id)
101  const at = order.indexOf(pos.site)
102  const before = rows.filter(r => order.indexOf(r.site) < at || (r.site === pos.site && r.row < pos.row))
103  const pick = before[before.length - 1] ?? rows[0]
104  return pick ? { ...pick, col: pos.col } : pos
105}
106
107export function step(from: Pos, dir: Dir, rows: { site: string; row: number }[], width: number): Pos {
108  if (dir === 'left') return { ...from, col: (from.col - 1 + width) % width }
109  if (dir === 'right') return { ...from, col: (from.col + 1) % width }
110  const i = rowIndex(rows, from)
111  const next = i < 0 ? undefined : rows[i + (dir === 'up' ? -1 : 1)]
112  return next ? { ...next, col: from.col } : from
113}
114
115function eat(game: Game, pos: Pos): boolean {
116  const row = game.sites.find(s => s.id === pos.site)?.cells[pos.row]
117  const c = row?.[pos.col]
118  if (!row || c === undefined || c === ' ') return false
119  row[pos.col] = ' '
120  return true
121}
122
123export function foodLeft(game: Game): number {
124  let left = 0
125  for (const r of visibleRows(game)) {
126    const row = game.sites.find(s => s.id === r.site)?.cells[r.row] ?? []
127    for (const c of row) if (c !== ' ') left++
128  }
129  return left
130}
131
132// Where the sprites begin: pacman low in the middle, a ghost in each corner
133// of the top two thirds. `rows` is never empty here.
134function starts(game: Game, rows: { site: string; row: number }[]): { pac: Pos; ghosts: Ghost[] } {
135  const w = game.width
136  const at = (i: number, col: number): Pos => {
137    const r = rows[Math.max(0, Math.min(rows.length - 1, i))] ?? { site: '', row: 0 }
138    return { site: r.site, row: r.row, col }
139  }
140  const bottom = rows.length - 1
141  return {
142    pac: at(Math.floor(bottom * 0.75), Math.floor(w / 2)),
143    ghosts: [
144      at(0, 1),
145      at(0, w - 2),
146      at(Math.floor(bottom / 3), 1),
147      at(Math.floor(bottom / 3), w - 2),
148    ].map((pos, i) => ({ pos, color: GHOST_COLORS[i % GHOST_COLORS.length] ?? 'red' })),
149  }
150}
151
152export function newGame(sites: Site[], width: number): Game {
153  const game: Game = {
154    width,
155    sites,
156    pac: { site: '', row: 0, col: 0 },
157    dir: 'left',
158    want: 'left',
159    ghosts: [],
160    score: 0,
161    lives: 3,
162    tick: 0,
163    grace: GRACE_TICKS,
164    state: 'playing',
165  }
166  const rows = visibleRows(game)
167  if (rows.length === 0) {
168    game.state = 'won'
169    return game
170  }
171  Object.assign(game, starts(game, rows))
172  eat(game, game.pac)
173  return game
174}
175
176const distance = (rows: { site: string; row: number }[], a: Pos, b: Pos): number =>
177  Math.abs(rowIndex(rows, a) - rowIndex(rows, b)) * 2 + Math.abs(a.col - b.col)
178
179// Cells are about twice as tall as they are wide, so a sprite moves up or
180// down on every other tick to cross the screen at one speed in both axes.
181const moves = (dir: Dir, tick: number): boolean => (dir === 'left' || dir === 'right' ? true : tick % 2 === 0)
182
183// A ghost heads for pacman one axis at a time and wanders one move in four,
184// at two thirds of his speed, so he can outrun them in a straight line.
185function chase(rows: { site: string; row: number }[], ghost: Ghost, target: Pos, width: number, random: () => number): Pos {
186  const dirs: Dir[] = ['up', 'down', 'left', 'right']
187  const wander = dirs[Math.floor(random() * dirs.length)]
188  if (random() < 0.25 && wander) return step(ghost.pos, wander, rows, width)
189  let best = ghost.pos
190  let bestDistance = distance(rows, ghost.pos, target)
191  for (const dir of dirs) {
192    const next = step(ghost.pos, dir, rows, width)
193    const d = distance(rows, next, target)
194    if (d < bestDistance) {
195      best = next
196      bestDistance = d
197    }
198  }
199  return best
200}
201
202const same = (a: Pos, b: Pos): boolean => a.site === b.site && a.row === b.row && a.col === b.col
203
204// One frame: pacman turns to the wanted direction and moves, eating what he
205// lands on; the ghosts move; a ghost on pacman costs a life and resets the
206// sprites; an empty screen wins.
207export function tick(game: Game, random: () => number = Math.random): Game {
208  if (game.state !== 'playing') return game
209  const rows = visibleRows(game)
210  if (rows.length === 0) return game
211  game.tick++
212  game.pac = settle(game, rows, game.pac)
213  for (const ghost of game.ghosts) ghost.pos = settle(game, rows, ghost.pos)
214  game.dir = game.want
215  if (moves(game.dir, game.tick)) {
216    game.pac = step(game.pac, game.dir, rows, game.width)
217    if (eat(game, game.pac)) game.score++
218  }
219  const caught = () => game.ghosts.some(g => same(g.pos, game.pac))
220  if (game.grace > 0) game.grace--
221  else if (!caught() && game.tick % 3 !== 0) {
222    for (const ghost of game.ghosts) {
223      const next = chase(rows, ghost, game.pac, game.width, random)
224      const dir: Dir = next.row !== ghost.pos.row || next.site !== ghost.pos.site ? 'up' : 'left'
225      if (moves(dir, game.tick)) ghost.pos = next
226    }
227  }
228  if (caught()) {
229    game.lives--
230    if (game.lives <= 0) {
231      game.state = 'over'
232      return game
233    }
234    Object.assign(game, starts(game, rows), { grace: GRACE_TICKS })
235  }
236  if (foodLeft(game) === 0) game.state = 'won'
237  return game
238}
239
240