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

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.
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.
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.
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-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>.
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.
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.
hooks/register.tsx 235 lines1/* @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}
235hooks/game.ts 240 lines1// 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