SLOPSHOPPER

prompt-runner

Claude Run: an endless-runner game above the prompt. Jump Claude over bugs and duck under hallucination moths.

newpanecommandtoast
v0.1.0MITupdated 2026-10-10yurmix/prompt-runner
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · prompt-runner
│ ┃ Claude Run ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ ▣ client module ./runner.tsx │ prompt-runner │ │ ⏺ Read(src/auth.ts) │ Claude Run: press ctrl+x g to play while │ │ ⎿ Read 6 lines │ you wait. │ │ ⏺ 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 │ │ › /prompt-runner │ ⎿ prompt-runner: Claude Run is open. Space or a tap jumps. Esc put │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Claude Run
▣ client module ./runner.tsx
README

Claude Run

A one-button endless-runner game for Claude Code. It's like the browser's offline dinosaur game, but you play as a pixel Claude. It stays hidden until you call it up, for example while Claude works through a long prompt. The only move is a jump: jump over bugs and stacks of failing tests, and time it so you don't jump into a hallucination moth.

 ▄▄▄▄                                          HI 00412  00187
▀▀▀▀▀▀▀                              ▄▄▄▄
     █▀██▀█
    ▀██████▀                  ▀▄██▄▀
     █▀  ▀█                   ▀█▀▀█▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀

Install

Type this at the prompt of a Claude Code terminal session:

/plugin install prompt-runner --marketplace yurmix/prompt-runner

Answer y to add the marketplace, then press Enter to pick the user scope. The plugin is active right away, in the terminal and in the desktop app's Code tab.

Play

Run /prompt-runner to open the game, even while Claude is working. Run it again, or press Esc, to put the game away and get your prompt back.

Once the game is open, there is one button. It jumps, and it starts the next run after a crash.

  • Keyboard: Space
  • Mouse or touch: click or tap anywhere on the game

Your best score is saved between sessions.

Open it with one key

The first time the plugin loads, it binds ctrl+x g to /prompt-runner in ~/.claude/keybindings.json and shows a short notice. Claude Code doesn't use ctrl+x g for anything else. After that, ctrl+x g opens the game and closes it again.

It leaves your file alone if ctrl+x g is already taken, if you've already bound /prompt-runner to another key, or if the file isn't plain JSON (it has comments, for example). It only tries once, so if you remove or change the binding, it stays the way you left it.

To bind it yourself, or to pick another key, add this to ~/.claude/keybindings.json:

{
  "bindings": [
    {
      "context": "Chat",
      "bindings": { "ctrl+x g": "command:prompt-runner" }
    }
  ]
}

If the file already has a bindings list, add the block to it. ctrl+d won't work, because Claude Code reserves it for exit.

Obstacles

  • Bug: a small red bug. Jump over it.
  • Bug swarm: two bugs together. They start showing up once you're running fast.
  • Failing test stack: a grey stack, taller than a bug.
  • Hallucination moth: a purple moth. A low one skims the ground, so jump it like a bug. A high one flies over you, so don't jump until it has passed.

The game speeds up as you go, and the score flashes at every 100.

Where it runs

The game uses the Claude Code plugin API's Client element, which only the terminal and the Claude desktop app draw. On the mobile app and VS Code, /prompt-runner opens a note saying so instead.

Development

claude plugin validate .
claude plugin test .

hooks/register.tsx is the hooks module. It adds the /prompt-runner command, opens and closes the game's pane, and keeps the best score. hooks/runner.tsx is the game itself, drawn with half-block characters so each terminal cell holds two pixels.

License

MIT

Source 3 files
hooks/register.tsx 125 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4const isPlaying = atom({ plugin: 'prompt-runner', key: 'isPlaying' } as const, false)
5const best = atom({ plugin: 'prompt-runner', key: 'best' } as const, 0)
6
7const PANE = 'prompt-runner'
8
9// The shortcut the plugin binds for you, once, in ~/.claude/keybindings.json.
10const KEY = 'ctrl+x g'
11const ACTION = 'command:prompt-runner'
12
13type Block = { context?: unknown; bindings?: Record<string, unknown> }
14
15// Adds KEY -> ACTION to the person's keybindings file, unless the action is
16// bound already or KEY is taken in Chat. A file that is not plain JSON (it has
17// comments, say) is left alone. Resolves whether it wrote the binding.
18async function bindKey($: EngineInterface): Promise<boolean> {
19  const dir =
20    (await $.env.get('CLAUDE_CONFIG_DIR')) ??
21    `${(await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE')) ?? ''}/.claude`
22  if (dir === '/.claude') return false
23  const path = `${dir}/keybindings.json`
24
25  let file: { bindings: Block[] } = { bindings: [] }
26  if (await $.fs.exists(path)) {
27    try {
28      const parsed = JSON.parse(await $.fs.read(path)) as { bindings?: unknown }
29      if (!Array.isArray(parsed?.bindings)) return false
30      file = parsed as { bindings: Block[] }
31    } catch {
32      return false
33    }
34  }
35
36  const blocks = file.bindings
37  if (blocks.some(b => Object.values(b?.bindings ?? {}).includes(ACTION))) return false
38  let chat = blocks.find(b => b?.context === 'Chat' && b.bindings && typeof b.bindings === 'object')
39  if (chat?.bindings && KEY in chat.bindings) return false
40  if (!chat) {
41    chat = { context: 'Chat', bindings: {} }
42    blocks.push(chat)
43  }
44  chat.bindings = { ...chat.bindings, [KEY]: ACTION }
45
46  await $.fs.write(path, `${JSON.stringify(file, null, 2)}\n`)
47  return true
48}
49
50export const register: Register = on => {
51  on('session.start', async ($, e, next) => {
52    await $.command.register({
53      name: 'prompt-runner',
54      description: 'Play Claude Run while you wait. Run it again, or press Esc, to put it away',
55      // Runs at once while Claude is working, which is when you want it.
56      immediate: true,
57    })
58    // Bind the shortcut the first time the plugin loads. Only once: if you
59    // remove or change it later, it stays the way you left it.
60    if ((await $.store.get('keyOffered')) !== true) {
61      await $.store.set('keyOffered', true)
62      const bound = await bindKey($).catch(() => false)
63      if (bound) $.ui.toast(`Claude Run: press ${KEY} to play while you wait.`)
64    }
65
66    const saved = Number((await $.store.get('best')) ?? 0)
67    if (Number.isFinite(saved) && saved > 0) {
68      await update($, best, n => Math.max(n ?? 0, saved))
69    }
70
71    return next(e)
72  })
73
74  // /prompt-runner opens the game with the keyboard on it, and closes it if
75  // it is already open. Bind it to a key ("command:prompt-runner") to make
76  // that one press.
77  on('command.run', { command: 'prompt-runner' }, async ($, e) => {
78    if (await read($, isPlaying)) {
79      await $.ui.close({ id: PANE })
80      return { text: 'Claude Run is put away.' }
81    }
82
83    await update($, isPlaying, () => true)
84    await $.ui.open({ id: PANE, title: 'Claude Run', focus: true, closeOnEscape: true, rows: 8 })
85    await $.ui.focus({ requestId: PANE, key: 'game' }).catch(() => ({}))
86
87    return { text: 'Claude Run is open. Space or a tap jumps. Esc puts it away.' }
88  })
89
90  on('ui.close', { id: PANE }, async ($, e, next) => {
91    await update($, isPlaying, () => false)
92    return next(e)
93  })
94
95  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
96    const top = await read($, best)
97    if (e.surface !== 'terminal' && e.surface !== 'desktop') {
98      const { Text } = $.ui.resolve(e)
99      return <Text dimColor>Claude Run plays in the terminal and the desktop app.</Text>
100    }
101    const { Client } = $.ui.resolve(e)
102
103    return (
104      <Client
105        key="game"
106        module="./runner.tsx"
107        width={e.props.bodyColumns}
108        props={{ best: top, columns: e.props.bodyColumns, autoStart: true }}
109      />
110    )
111  })
112
113  // The game posts { score } when a run ends; keep the best one.
114  on('ui.message', async ($, e, next) => {
115    const data = e.data as { score?: unknown } | null
116    const score = typeof data?.score === 'number' ? Math.floor(data.score) : NaN
117    if (Number.isFinite(score) && score > 0 && score > (await read($, best))) {
118      await update($, best, n => Math.max(n ?? 0, score))
119      await $.store.set('best', score)
120    }
121
122    return next(e)
123  })
124}
125
hooks/runner.tsx 423 lines
1import type { ClientModule, ClientSurface, RenderElement } from 'claude-code'
2
3// Claude Run: a one-button endless runner drawn with half-block characters,
4// so each terminal cell holds two square-ish pixels (top and bottom). The
5// only move is a jump: tap anywhere on touch, or press Space on a keyboard.
6
7type Props = { best?: number; columns?: number; autoStart?: boolean }
8
9type Kind = 'bug' | 'bugs' | 'stack' | 'moth' | 'mothHigh'
10type Obstacle = { kind: Kind; x: number; top: number; w: number; hgt: number }
11type Cloud = { x: number; y: number }
12
13type Game = {
14  mode: 'idle' | 'run' | 'over'
15  t: number
16  overAt: number
17  jump: number // pixels above the ground
18  vy: number
19  speed: number
20  dist: number
21  score: number
22  best: number
23  posted: boolean
24  obs: Obstacle[]
25  clouds: Cloud[]
26  gap: number
27  flash: number
28}
29
30const TICK_MS = 40
31const FIELD_H = 14 // pixels: 7 rows of cells
32const FEET = 11 // the avatar's lowest pixel row when on the ground
33const GROUND = 12
34const PEBBLES = 13
35const AV_X = 4
36const GRAVITY = 0.32
37const JUMP_V = 2.0
38
39// One character per pixel; '.' is empty.
40const COLORS: Record<string, string> = {
41  O: 'claude',
42  K: '#1b1a18',
43  R: 'error',
44  G: '#9b978f',
45  g: '#6e6a63',
46  P: '#a990f0',
47  c: 'subtle',
48  _: 'inactive',
49}
50
51const BODY = ['.OOOOOO.', '.OKOOKO.', 'OOOOOOOO', '.OOOOOO.']
52const BODY_BLINK = ['.OOOOOO.', '.OOOOOO.', 'OOOOOOOO', '.OOOOOO.']
53const BODY_DEAD = ['.OOOOOO.', '.OROORO.', 'OOOOOOOO', '.OOOOOO.']
54const LEGS_A = ['.OO..OO.', '.O....O.']
55const LEGS_B = ['.OO..OO.', '..O..O..']
56const LEGS_AIR = ['.OO..OO.', '........']
57
58const SPRITES: Record<Kind, string[][]> = {
59  bug: [['R.RR.R', '.RRRR.', 'RRRRRR', '.R..R.']],
60  bugs: [['R.RR.R.R.RR.R', '.RRRR...RRRR.', 'RRRRRR.RRRRRR', '.R..R...R..R.']],
61  stack: [['GGGG', 'gggg', 'GGGG', 'gggg', 'GGGG']],
62  moth: [
63    ['P.....P', '.PPPPP.', '..P.P..'],
64    ['.......', 'PPPPPPP', '..P.P..'],
65  ],
66  mothHigh: [
67    ['P.....P', '.PPPPP.', '..P.P..'],
68    ['.......', 'PPPPPPP', '..P.P..'],
69  ],
70}
71const CLOUD = ['..cccc..', 'cccccccc']
72const CLOUD_W = 8
73
74// The one button: Space, however the host names it.
75const BUTTON_KEYS = new Set([' ', 'space'])
76
77function fresh(best: number): Game {
78  return {
79    mode: 'idle',
80    t: 0,
81    overAt: 0,
82    jump: 0,
83    vy: 0,
84    speed: 1.5,
85    dist: 0,
86    score: 0,
87    best,
88    posted: false,
89    obs: [],
90    clouds: [],
91    gap: 0,
92    flash: 0,
93  }
94}
95
96function start(g: Game, width: number): void {
97  g.mode = 'run'
98  g.t = 0
99  g.jump = 0
100  g.vy = 0
101  g.speed = 1.5
102  g.dist = 0
103  g.score = 0
104  g.posted = false
105  g.obs = []
106  g.clouds = [
107    { x: Math.floor(width * 0.3), y: 1 },
108    { x: Math.floor(width * 0.75), y: 3 },
109  ]
110  g.gap = width * 0.6
111  g.flash = 0
112}
113
114function makeObstacle(kind: Kind, x: number): Obstacle {
115  const art = SPRITES[kind][0] ?? []
116  const w = art[0]?.length ?? 0
117  const hgt = art.length
118  // A low moth skims the ground, so jump it like a bug. A high moth flies
119  // over a running Claude but meets a jumping one, so don't press.
120  const top = kind === 'moth' ? FEET - hgt : kind === 'mothHigh' ? 1 : FEET - hgt + 1
121  return { kind, x, top, w, hgt }
122}
123
124function pickKind(g: Game): Kind {
125  const roll = Math.random()
126  if (g.score > 150 && roll < 0.2) return Math.random() < 0.6 ? 'moth' : 'mothHigh'
127  if (g.speed > 2.2 && roll < 0.4) return 'bugs'
128  if (g.score > 40 && roll < 0.65) return 'stack'
129  return 'bug'
130}
131
132function avatarBox(g: Game): [number, number, number, number] {
133  const bottom = Math.round(FEET - g.jump)
134  return [AV_X + 1, bottom - 5, AV_X + 6, bottom]
135}
136
137function hits(g: Game, o: Obstacle): boolean {
138  const [ax0, ay0, ax1, ay1] = avatarBox(g)
139  const ox0 = Math.round(o.x) + 1
140  const ox1 = Math.round(o.x) + o.w - 2
141  const oy0 = o.top
142  const oy1 = o.top + o.hgt - 1
143  return ax0 <= ox1 && ax1 >= ox0 && ay0 <= oy1 && ay1 >= oy0
144}
145
146function jump(g: Game): void {
147  if (g.jump <= 0 && g.vy === 0) g.vy = JUMP_V
148}
149
150function step(g: Game, width: number): void {
151  g.t++
152  if (g.mode !== 'run') return
153
154  // Avatar
155  if (g.vy !== 0 || g.jump > 0) {
156    g.jump += g.vy
157    g.vy -= GRAVITY
158    if (g.jump <= 0) {
159      g.jump = 0
160      g.vy = 0
161    }
162  }
163
164  // World
165  g.speed = Math.min(3.6, 1.5 + g.score / 600)
166  g.dist += g.speed
167  const before = Math.floor(g.score / 100)
168  g.score = Math.floor(g.dist * 0.25)
169  if (Math.floor(g.score / 100) > before) g.flash = 24
170  if (g.flash > 0) g.flash--
171
172  for (const o of g.obs) o.x -= g.speed
173  g.obs = g.obs.filter(o => o.x + o.w > -2)
174  for (const c of g.clouds) {
175    c.x -= g.speed * 0.25
176    if (c.x < -CLOUD_W) {
177      c.x = width + Math.random() * 20
178      c.y = 1 + Math.floor(Math.random() * 3)
179    }
180  }
181
182  g.gap -= g.speed
183  if (g.gap <= 0) {
184    g.obs.push(makeObstacle(pickKind(g), width))
185    g.gap = g.speed * 14 + 10 + Math.random() * (g.speed * 22 + 24)
186  }
187
188  for (const o of g.obs) {
189    if (hits(g, o)) {
190      g.mode = 'over'
191      g.overAt = g.t
192      g.best = Math.max(g.best, g.score)
193      break
194    }
195  }
196}
197
198// Drawing ------------------------------------------------------------------
199
200type Pixels = { width: number; height: number; px: (string | undefined)[] }
201
202function canvas(width: number, height: number): Pixels {
203  return { width, height, px: new Array(width * height).fill(undefined) }
204}
205
206function blit(c: Pixels, art: string[], x: number, y: number): void {
207  const x0 = Math.round(x)
208  for (let r = 0; r < art.length; r++) {
209    const py = y + r
210    if (py < 0 || py >= c.height) continue
211    const line = art[r] ?? ''
212    for (let i = 0; i < line.length; i++) {
213      const color = COLORS[line[i] ?? '.']
214      const px = x0 + i
215      if (!color || px < 0 || px >= c.width) continue
216      c.px[py * c.width + px] = color
217    }
218  }
219}
220
221function avatarArt(g: Game): { art: string[]; y: number } {
222  if (g.mode === 'over') {
223    return { art: [...BODY_DEAD, ...LEGS_AIR], y: Math.round(FEET - g.jump) - 5 }
224  }
225  const blink = g.mode === 'idle' && g.t % 90 < 4
226  const body = blink ? BODY_BLINK : BODY
227  const legs =
228    g.mode === 'idle' ? LEGS_A : g.jump > 0 ? LEGS_AIR : Math.floor(g.t / 3) % 2 ? LEGS_A : LEGS_B
229  return { art: [...body, ...legs], y: Math.round(FEET - g.jump) - 5 }
230}
231
232function drawField(g: Game, width: number): Pixels {
233  const c = canvas(width, FIELD_H)
234  for (const cl of g.clouds) blit(c, CLOUD, cl.x, cl.y)
235  for (let x = 0; x < width; x++) {
236    c.px[GROUND * width + x] = COLORS._
237    if ((Math.imul(x + Math.floor(g.dist), 2654435761) >>> 0) % 19 === 0) c.px[PEBBLES * width + x] = COLORS._
238  }
239  for (const o of g.obs) {
240    const frames = SPRITES[o.kind]
241    blit(c, frames[Math.floor(g.t / 6) % frames.length] ?? [], o.x, o.top)
242  }
243  const a = avatarArt(g)
244  blit(c, a.art, AV_X, a.y)
245  return c
246}
247
248type Run = { text: string; fg?: string; bg?: string }
249
250function rowsOf(c: Pixels): Run[][] {
251  const rows: Run[][] = []
252  for (let y = 0; y < c.height; y += 2) {
253    const runs: Run[] = []
254    for (let x = 0; x < c.width; x++) {
255      const top = c.px[y * c.width + x]
256      const bot = y + 1 < c.height ? c.px[(y + 1) * c.width + x] : undefined
257      let ch = ' '
258      let fg: string | undefined
259      let bg: string | undefined
260      if (top && bot) {
261        if (top === bot) {
262          ch = '█'
263          fg = top
264        } else {
265          ch = '▀'
266          fg = top
267          bg = bot
268        }
269      } else if (top) {
270        ch = '▀'
271        fg = top
272      } else if (bot) {
273        ch = '▄'
274        fg = bot
275      }
276      const last = runs[runs.length - 1]
277      if (last && last.fg === fg && last.bg === bg) last.text += ch
278      else runs.push({ text: ch, fg, bg })
279    }
280    rows.push(runs)
281  }
282  return rows
283}
284
285function pad(n: number): string {
286  return String(Math.max(0, Math.floor(n))).padStart(5, '0')
287}
288
289function draw(g: Game, width: number, surface: ClientSurface<Game>): RenderElement {
290  const { Box, Text } = surface.elements
291
292  const paint = (runs: Run[]): RenderElement[] =>
293    runs.map(r =>
294      r.fg && r.bg ? (
295        <Text color={r.fg} backgroundColor={r.bg}>{r.text}</Text>
296      ) : r.fg ? (
297        <Text color={r.fg}>{r.text}</Text>
298      ) : (
299        <Text>{r.text}</Text>
300      ),
301    )
302
303  if (g.mode === 'idle') {
304    // A compact strip: Claude waiting on a patch of ground, and the hint.
305    const c = canvas(12, 8)
306    for (let x = 0; x < 12; x++) c.px[6 * 12 + x] = COLORS._
307    const a = avatarArt(g)
308    blit(c, a.art, 2, a.y - (FEET - 5))
309    const rows = rowsOf(c)
310    const hint =
311      width >= 70
312        ? 'Tap or press Space to play. One button: jump.'
313        : 'Tap or Space to play and jump'
314    return (
315      <Box flexDirection="row">
316        <Box flexDirection="column">
317          {rows.map(runs => (
318            <Box flexDirection="row">{paint(runs)}</Box>
319          ))}
320        </Box>
321        <Box flexDirection="column" paddingLeft={1}>
322          <Text> </Text>
323          <Text bold color="claude">Claude Run</Text>
324          <Text dimColor wrap="truncate-end">{hint}</Text>
325          <Text dimColor>{g.best > 0 ? `Best ${g.best}` : ' '}</Text>
326        </Box>
327      </Box>
328    )
329  }
330
331  const rows = rowsOf(drawField(g, width))
332  const showScore = g.flash === 0 || Math.floor(g.flash / 4) % 2 === 0
333  const scoreText = `HI ${pad(Math.max(g.best, g.score))}  ${showScore ? pad(g.score) : '     '}`
334  const left =
335    g.mode === 'over'
336      ? width >= 60
337        ? 'GAME OVER · tap or press Space to run again'
338        : 'GAME OVER · tap or Space'
339      : 'Tap or Space to jump'
340
341  return (
342    <Box flexDirection="column" width={width}>
343      <Box flexDirection="row" justifyContent="space-between">
344        {g.mode === 'over' ? (
345          <Text bold color="claude" wrap="truncate-end">{left}</Text>
346        ) : (
347          <Text dimColor wrap="truncate-end">{left}</Text>
348        )}
349        <Text color="text">{scoreText}</Text>
350      </Box>
351      {rows.map(runs => (
352        <Box flexDirection="row">{paint(runs)}</Box>
353      ))}
354    </Box>
355  )
356}
357
358const Runner: ClientModule<Props, Game> = (props, surface) => {
359  const width = Math.max(30, surface.columns > 0 ? surface.columns : (props?.columns ?? 60))
360  const known = Math.max(0, props?.best ?? 0)
361
362  let g = surface.state
363  if (g === undefined) {
364    g = fresh(known)
365    const s = surface
366    if (props?.autoStart) start(g, width)
367    s.setState(g)
368
369    // The one button: start, jump, or run again.
370    const press = (): void => {
371      const cur = s.state
372      if (!cur) return
373      const w = Math.max(30, s.columns > 0 ? s.columns : 60)
374      if (cur.mode === 'idle') {
375        start(cur, w)
376      } else if (cur.mode === 'over') {
377        if (cur.t - cur.overAt < 8) return // ignore the press that was mid-flight
378        start(cur, w)
379      } else {
380        jump(cur)
381      }
382      s.setState({ ...cur })
383    }
384
385    s.every(TICK_MS, () => {
386      const cur = s.state
387      if (!cur) return
388      const w = Math.max(30, s.columns > 0 ? s.columns : 60)
389      const wasRunning = cur.mode === 'run'
390      step(cur, w)
391      if (cur.mode === 'run') {
392        s.setState({ ...cur })
393      } else if (wasRunning) {
394        // The run just ended: redraw once and report the score.
395        if (!cur.posted && cur.score > 0) {
396          cur.posted = true
397          s.post({ score: cur.score })
398        }
399        s.setState({ ...cur })
400      } else if (cur.mode === 'idle' && (cur.t % 90 === 0 || cur.t % 90 === 4)) {
401        s.setState({ ...cur }) // blink
402      }
403    })
404
405    s.onKey(ev => {
406      if (ev.ctrl || ev.meta) return
407      if (BUTTON_KEYS.has(ev.key.toLowerCase())) press()
408    })
409
410    s.onPointer(ev => {
411      if (ev.type === 'down') press() // a tap or click anywhere
412    })
413  }
414
415  if (known > g.best) g.best = known
416  return draw(g, width, surface)
417}
418
419export default Runner
420
421// Exposed for tests.
422export const _internal = { fresh, start, step, jump, hits, makeObstacle, FEET }
423
types/index.d.ts 14 lines
1/** A best score: whole points. */
2export type Score = number
3
4declare module 'claude-code' {
5  interface PluginState {
6    'prompt-runner': {
7      /** True while the game pane (from /prompt-runner) is open. */
8      isPlaying: boolean
9      /** Best score, also kept in $.store across sessions. */
10      best: Score
11    }
12  }
13}
14