SLOPSHOPPER

cc-dino

The Chrome offline dinosaur, above the Claude Code prompt. Jump the cacti and duck the pterodactyls while Claude works. Needs function hooks (early access) and…

newbandcommandtoast
★ 1v0.1.0MITupdated 2026-09-15manfye/cc-dino
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cc-dino
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /dino ⎿ cc-dino: dino · click the board, then space to jump and ↓ to duck · Esc returns to the prompt · best 0 [ close ] /dino stop closes it too ▣ client module ./boards/dino.tsx ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
[ close ] /dino stop closes it too ▣ client module ./boards/dino.tsx ⟨Claude Code's own drawing⟩
README

cc-dino

The offline dinosaur, above the Claude Code prompt.

Run /dino, click the board, and jump cacti while Claude works on your request. When Claude finishes the turn the run pauses and the status line says so, so you never miss a reply. Playing costs no tokens: the plugin answers every key and click itself, without asking the model.

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

Requirements

  • Claude Code 2.1.269 or later with CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 set. Function hooks are in early access and the API can change between releases.
  • An interactive terminal session. Nothing draws in claude -p, the desktop app or mobile.
  • A terminal that reports the mouse: one click is what gives the board the keyboard.
  • A terminal font with block-drawing characters.

Install

  1. Turn function hooks on. Add this to ~/.claude/settings.json (create the file if it does not exist, or merge the env key into what is there). Without it the plugin installs fine but does nothing.
   {
     "env": {
       "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1"
     }
   }

This also loads the hooks module of any other installed plugin that ships one. For a single session instead, prefix the command: CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude.

  1. Install from GitHub. The repo is its own marketplace:
   claude plugin marketplace add manfye/cc-dino
   claude plugin install cc-dino@cc-dino
  1. Start claude — an interactive session, not claude -p — and run /dino.
  1. Click the board, then press space. The click is what gives the game the keyboard; until then your keys go to the prompt. Esc gives the keyboard back.

To remove it:

claude plugin uninstall cc-dino
claude plugin marketplace remove cc-dino

To try it without installing, or to hack on it, clone and load it for one session:

git clone https://github.com/manfye/cc-dino
cd cc-dino
claude --plugin-dir .

The repo's own .claude/settings.json sets the variable for sessions started inside the folder.

Controls

space, ↑, w, Enter, or a clickjump — and start the next run after a crash
↓, sduck, for about 0.6s (a terminal reports no key release, so a duck is timed)
ppause
rstart again
Escgive the keyboard back to the prompt

/dino stop or the close button closes the board. /dino reset clears the best score.

The run

  • Small cacti, tall cacti and three-wide clusters, all jumped.
  • Pterodactyls from 200 points: a low one is in the dino's chest and has to be ducked or jumped, a high one only catches a dino on its way up.
  • The world speeds up with distance and stops at 2.4 cells a frame; the gap ahead scales with the speed, so a fast run still leaves room to land and jump again.
  • Night every 700 points — the border turns blue and stars come out, and the counter blinks at every hundred.
  • The best score is kept in the plugin's store, so it survives restarts and every session shares it.
  • The board fits the band it is given, and the jump is fitted to the board: on a short terminal it goes exactly as high as there is room for, since a dino drawn past the top row is a dino you cannot see. Its airtime is the same either way, so the gaps between cacti do not change with it. Below nine rows there is no room for a jump worth making, and the board says so instead.

How it works

It is a mod: a plugin whose behaviour is a hooks module of TypeScript running inside Claude Code's process, rather than shell commands in settings.json.

  • hooks/register.tsx is the hooks module. It registers /dino on session.start, answers it on command.run, draws the band on ui.render for AbovePrompt, pauses the run on turn.complete, and keeps the best score in $.store — taking a finished run's score off ui.message.
  • hooks/boards/dino.tsx is a surface module, mounted as a Client element. It runs on the drawing thread with its own 33 ms frame clock, keyboard and mouse, and posts a finished run's score back to the hooks module.
  • hooks/games/dino.ts is the rules — gravity, spawning, collision — as pure functions over a plain state object, so they can be simulated without a terminal.

Smoothness

The dino is drawn from a bitmap of half-rows, not from characters: a terminal row holding the sprite's upper half is ▀, its lower half ▄, both █. So the sprite moves in half cells, and a jump draws 11 distinct positions where whole cells would give 6.

The bitmap is five columns by four half-rows, which is about square once the terminal's 2:1 cell is accounted for — the room a tyrannosaur's silhouette needs:

  hr3   . . . # #        snout                 ▄█▀
  hr2   . . # # .        head and neck       ▀▀█▄
  hr1   # # # . .        tail and body
  hr0   . . # # .        legs

The two leg frames differ in hr0 alone, so the run cycle is a foot lifting rather than the whole body bobbing. The hit box covers the three columns of body, head and legs; the tail overhangs two cells further left and is not hittable, since a tail that ends a run is a hit the player cannot read off the sprite. The frame clock runs at 33 ms for the other half of it. The game constants are per-frame and therefore tied to FRAME_MS — at twice the frame rate a velocity halves and an acceleration quarters — so hooks/games/dino.ts derives the obstacle gap from the jump's airtime rather than from a hand-tuned cell count, and the tuning survives a change of frame rate.

The hit test rounds the dino's feet to the nearest cell instead of flooring them, so the half cell the board can draw but the test cannot see is an error in the player's favour.

The other half of smoothness is not clipping: the region holds two border rows, the sky, the ground and the status line, and a board that asks for more than that loses the top of every jump.

Two things that will bite you if you write one of these:

  • Never name a local h in a surface module: every JSX tag compiles to a call of h.
  • module= on a Client must be a string literal — the engine reads the path off the source, not off the running value.

Developing

claude plugin validate .                          # manifest, hooks, and the $ calls each module makes
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude -p "/plugin-types"   # writes .claude/types/claude-code.d.ts
npm i -D typescript && ./node_modules/.bin/tsc -p tsconfig.json

.claude/types/claude-code.d.ts is the real API reference; it is regenerated per release and is not checked in.

License

MIT

Source 3 files
hooks/register.tsx 110 lines
1/* @jsx h */
2import type { Register } from 'claude-code'
3
4// One command, /dino. It opens the board above the prompt, where it stays until /dino stop or the
5// close button. This module keeps the best score in $.store, pauses the run when Claude finishes a
6// turn, and hands the board its props; the board in ./boards/dino.tsx is a surface module that runs
7// on the drawing thread with its own frame clock, keys and mouse.
8
9let open = false
10// bumped at each finished turn; the board pauses when it sees a new value
11let turnsDone = 0
12let best = 0
13
14export const register: Register = on => {
15  on('session.start', async ($, e, next) => {
16    const r = await next(e)
17    // a store that cannot be read costs the best score, never the game: an unhandled rejection here
18    // would unmount the whole module
19    const saved = await $.store
20      .get('best')
21      .catch(err => {
22        $.ui.log(`cc-dino: store read failed: ${err}`)
23        return undefined
24      })
25    if (typeof saved === 'number') best = saved
26    await $.command
27      .register({
28        name: 'dino',
29        description: 'The offline dinosaur above the prompt: jump the cacti while Claude works (cc-dino)',
30        argumentHint: '[stop | reset]',
31        immediate: true,
32      })
33      .catch(err => $.ui.log(`cc-dino: /dino not registered: ${err}`))
34    return r
35  })
36
37  on('command.run', { command: 'dino' }, async ($, e) => {
38    const arg = e.args.trim().toLowerCase()
39    if (arg === 'stop') {
40      open = false
41      $.ui.invalidate('ui.render')
42      return { text: 'dino closed' }
43    }
44    if (arg === 'reset') {
45      best = 0
46      await $.store.set('best', 0).catch(err => $.ui.log(`cc-dino: store write failed: ${err}`))
47      $.ui.invalidate('ui.render')
48      return { text: 'dino: best score cleared' }
49    }
50    if (arg !== '') return { text: `dino: no such argument "${arg}" · /dino, /dino stop, /dino reset` }
51    open = true
52    $.ui.invalidate('ui.render')
53    return {
54      text: `dino · click the board, then space to jump and ↓ to duck · Esc returns to the prompt · best ${best}`,
55    }
56  })
57
58  on('turn.complete', async ($, e, next) => {
59    const r = await next(e)
60    if (open) {
61      turnsDone++
62      $.ui.invalidate('ui.render')
63    }
64    return r
65  })
66
67  // a finished run, posted by the board: keep the best score and hand it back with the next props
68  on('ui.message', async ($, e, next) => {
69    const data = e.data as { game?: unknown; score?: unknown } | null
70    if (data?.game !== 'dino' || typeof data.score !== 'number') return next(e)
71    if (data.score > best) {
72      best = data.score
73      await $.store.set('best', best).catch(err => $.ui.log(`cc-dino: store write failed: ${err}`))
74      $.ui.toast(`dino: new best ${best}`)
75    }
76    return { props: { best, done: turnsDone } }
77  })
78
79  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
80    // the board needs a terminal's keys and mouse; the desktop and mobile surfaces draw their own band
81    if (!open || e.props.hasSurvey || e.surface !== 'terminal') return next(e)
82    const { Box, Button, Client, Text } = await $.ui.resolve(e)
83    const close = () => {
84      open = false
85      $.ui.invalidate('ui.render')
86    }
87    // one row for the close button, one for whatever else draws in the band. The board fits itself
88    // to what it is given (two border rows, the sky, the ground and its status line), so this is a
89    // ceiling on how tall it grows, not a floor it has to meet.
90    const rows = Math.max(7, e.props.maxRows - 2)
91    return (
92      <Box flexDirection="column">
93        <Box flexDirection="row" columnGap={1}>
94          <Button key="dino:close" label="close" onPress={close} />
95          <Text dimColor>{'/dino stop closes it too'}</Text>
96        </Box>
97        {/* the module path must be a string literal: the engine reads it off this source */}
98        <Client
99          key="board:dino"
100          module="./boards/dino.tsx"
101          width={e.viewport?.columns ?? 80}
102          height={Math.min(rows, 14)}
103          props={{ best, done: turnsDone }}
104        />
105        {await next(e)}
106      </Box>
107    )
108  })
109}
110
hooks/boards/dino.tsx 247 lines
1/* @jsx h */
2import type { ClientSurface } from 'claude-code'
3import {
4  DINO_X,
5  FRAME_MS,
6  MIN_ROWS,
7  duck,
8  jump,
9  newDino,
10  tick,
11  type DinoGame,
12} from '../games/dino.ts'
13
14// The board is a surface module: it runs on the drawing thread with its own frame clock, keys
15// (after a click gives it focus; Esc gives the prompt back) and mouse.
16//
17// Never name a local `h` in this file: every JSX tag compiles to a call of `h`.
18//
19// The dino is drawn from a bitmap of half-rows rather than from characters, so it moves in half
20// cells: a terminal row holding the sprite's upper half is '▀', the lower half '▄', both '█'. That
21// doubles the resolution of the jump arc, which is the only motion with a shape to it.
22
23type Props = { best?: number; done?: number } | undefined
24type State = {
25  playing: boolean
26  banner: boolean
27  seenDone: number
28  frame: number
29  /** frames of ducking left; a terminal reports no key release, so a duck is timed */
30  duckFor: number
31  game?: DinoGame
32}
33
34type Paint = readonly [glyph: string, color?: string]
35type Run = [text: string, color?: string]
36
37/** one styled span per stretch of cells sharing a colour, so a row is a handful of nodes, not bw */
38function runs(cols: number, at: (x: number) => Paint): Run[] {
39  const out: Run[] = []
40  for (let x = 0; x < cols; x++) {
41    const [glyph, color] = at(x)
42    const last = out[out.length - 1]
43    if (last && last[1] === color) last[0] += glyph
44    else out.push([glyph, color])
45  }
46  return out
47}
48
49// Sprites as half-rows, bottom row first; a '#' is a filled half cell. Five columns and four
50// half-rows is about square once the terminal's 2:1 cell is accounted for, which is the room a
51// tyrannosaur's silhouette needs: tail low at the left, body over the legs, neck and snout raised
52// to the right.
53//
54//   hr3   . . . # #      snout
55//   hr2   . . # # .      head and neck
56//   hr1   # # # . .      tail and body
57//   hr0   . . # # .      legs
58//
59// composited into half blocks, that is
60//
61//     ▄█▀
62//   ▀▀█▄
63//
64// The two leg frames differ in hr0 alone, so the run cycle is a foot lifting, not the body bobbing.
65const STAND = [
66  ['..##.', '###..', '..##.', '...##'],
67  ['...#.', '###..', '..##.', '...##'],
68]
69const DUCK = ['..###', '####.']
70/** the sprite's left column: two cells left of the hit box, the overhang the tail draws into */
71const SPRITE_X = DINO_X - 2
72const BIRD = ['▀▄', '▄▀']
73/** x offset and height, scrolled and wrapped; clouds drift slower than the ground */
74const CLOUDS: [at: number, up: number][] = [
75  [10, 1],
76  [38, 2],
77  [67, 0],
78  [95, 2],
79]
80const CLOUD = '(~~)'
81/** ' ', upper half, lower half, both */
82const HALVES = [' ', '▀', '▄', '█']
83
84/** the sprite's glyph for one whole cell: its two half-rows, looked up and combined */
85function halfCell(sprite: string[], col: number, up: number, hy: number): string | undefined {
86  if (col < 0 || col >= sprite[0].length) return undefined
87  const lower = sprite[2 * up - hy]?.[col] === '#'
88  const upper = sprite[2 * up + 1 - hy]?.[col] === '#'
89  const glyph = HALVES[(upper ? 1 : 0) + (lower ? 2 : 0)]
90  return glyph === ' ' ? undefined : glyph
91}
92
93export default function Dino(props: Props, surface: ClientSurface<State>) {
94  const { Box, Text } = surface.elements
95  // the region holds two border rows, bh sky rows, the ground row and the status line. Asking for
96  // more than that is what clips the board, and a clipped board loses the top of every jump.
97  const size = () => ({
98    bw: Math.max(20, Math.min(96, surface.columns - 2)),
99    bh: Math.min(9, surface.rows - 4),
100  })
101
102  const start = () => {
103    const s = surface.state
104    if (!s) return
105    const { bw, bh } = size()
106    surface.setState({ ...s, banner: false, playing: true, duckFor: 0, game: newDino(bw, bh) })
107  }
108
109  /** space, up, Enter or a click: jump, or start a fresh run when the last one ended */
110  const up = () => {
111    const s = surface.state
112    if (!s) return
113    if (!s.game || s.game.over) return start()
114    surface.setState({ ...s, banner: false, playing: true, duckFor: 0, game: jump(duck(s.game, false)) })
115  }
116
117  const down = () => {
118    const s = surface.state
119    if (!s?.game || s.game.over) return
120    surface.setState({ ...s, banner: false, playing: true, duckFor: 18, game: duck(s.game, true) })
121  }
122
123  if (surface.state === undefined) {
124    surface.setState({ playing: false, banner: false, seenDone: props?.done ?? 0, frame: 0, duckFor: 0 })
125    surface.every(FRAME_MS, () => {
126      const s = surface.state
127      if (!s?.game || !s.playing || s.game.over) return
128      const duckFor = Math.max(0, s.duckFor - 1)
129      const game = tick(duckFor === 0 ? duck(s.game, false) : s.game)
130      if (game.over) surface.post({ game: 'dino', score: game.score })
131      surface.setState({ ...s, frame: s.frame + 1, duckFor, game })
132    })
133    surface.onKey(({ key }) => {
134      const s = surface.state
135      if (!s) return
136      // the space bar may arrive as the character or by name
137      const k = key === 'space' ? ' ' : key.toLowerCase()
138      if (k === 'p') {
139        if (s.game && !s.game.over) surface.setState({ ...s, banner: false, playing: !s.playing })
140      } else if (k === 'r') {
141        start()
142      } else if (k === ' ' || k === 'up' || k === 'w' || k === 'return') {
143        up()
144      } else if (k === 'down' || k === 's') {
145        down()
146      }
147    })
148    surface.onPointer(ev => {
149      if (ev.type === 'down') up()
150    })
151  }
152
153  // the hooks module bumps props.done when Claude finishes a turn: pause and say so, once per turn
154  const seen = surface.state
155  const done = props?.done ?? 0
156  if (seen && done !== seen.seenDone) {
157    surface.setState({ ...seen, seenDone: done, banner: true, playing: false })
158  }
159
160  const s = surface.state
161  const g = s?.game
162  const { bw, bh } = g ? { bw: g.w, bh: g.h } : size()
163  if (bh < MIN_ROWS) {
164    return <Text dimColor wrap="truncate-end">{`dino · this band is ${surface.rows} rows; the board wants ${MIN_ROWS + 4} · a taller terminal gives it one`}</Text>
165  }
166  const frame = s?.frame ?? 0
167  const score = g?.score ?? 0
168  const night = Math.floor(score / 700) % 2 === 1
169  const dist = g?.dist ?? 0
170  const grounded = !g || g.y <= 0
171
172  // the feet in half cells, and the sprite standing on them
173  const hy = g ? Math.round(g.y * 2) : 0
174  const ducking = !!g?.ducking && grounded
175  const sprite = ducking ? DUCK : STAND[grounded && !g?.over ? Math.floor(frame / 6) % 2 : 0]
176  const dinoColor = g?.over ? 'red' : night ? 'whiteBright' : 'white'
177
178  const obstacleAt = (x: number, at: number): Paint | undefined => {
179    for (const o of g?.obstacles ?? []) {
180      const left = Math.round(o.x)
181      if (x < left || x > left + o.w - 1 || at < o.bottom || at > o.top) continue
182      const i = x - left
183      if (o.kind === 'bird') return [BIRD[Math.floor(frame / 8) % 2][i], night ? 'magentaBright' : 'magenta']
184      if (o.kind === 'tall') return [at === o.top ? 'Ψ' : '║', 'green']
185      return ['Ψ', 'green']
186    }
187    return undefined
188  }
189
190  const at = (x: number, up_: number): Paint => {
191    const dino = halfCell(sprite, x - SPRITE_X, up_, hy)
192    if (dino) return [dino, dinoColor]
193    const obstacle = obstacleAt(x, up_)
194    if (obstacle) return obstacle
195    for (const [start_, upAt] of CLOUDS) {
196      if (up_ !== bh - 2 - upAt) continue
197      const span = bw + 24
198      const left = ((start_ - Math.floor(dist * 0.35)) % span + span) % span
199      const i = x - left
200      if (i >= 0 && i < CLOUD.length) return [CLOUD[i], 'gray']
201    }
202    // a night sky gets a few fixed stars, so the change of hour is visible without a colour swap
203    if (night && up_ >= bh - 3 && (x * 7 + up_ * 13) % 29 === 0) return ['·', 'gray']
204    return [' ', undefined]
205  }
206
207  const sky = Array.from({ length: bh }, (_, y) => {
208    const up_ = bh - 1 - y
209    return <Text>{runs(bw, x => at(x, up_)).map(([t, c]) => <Text color={c}>{t}</Text>)}</Text>
210  })
211  const ground = Array.from({ length: bw }, (_, x) =>
212    ((x + Math.floor(dist)) * 31) % 23 === 0 ? '.' : '─',
213  ).join('')
214
215  // the original blinks the counter at every hundred; a second of it, at this frame rate
216  const cheering = !!g && !g.over && !!s?.playing && score > 0 && score % 100 < 30
217  const counter = String(score).padStart(5, '0')
218  const text = !g
219    ? 'dino · click here, then space to jump · ↓ ducks · p pause'
220    : g.over
221      ? `dino · ouch · score ${g.score} · space runs again`
222      : !s?.playing
223        ? `dino · paused · score ${g.score} · p resumes`
224        : `dino · ${counter} · space jumps · ↓ ducks${night ? ' · night' : ''}`
225
226  return (
227    <Box flexDirection="column">
228      <Box
229        flexDirection="column"
230        borderStyle="round"
231        borderColor={g?.over ? 'red' : night ? 'blue' : 'gray'}
232        width={bw + 2}
233      >
234        {sky}
235        <Text color="gray" dimColor>{ground}</Text>
236      </Box>
237      {s?.banner ? (
238        <Text color="yellow" bold wrap="truncate-end">{`● Claude is done · ${text} · best ${props?.best ?? 0}`}</Text>
239      ) : cheering ? (
240        <Text color="yellowBright" wrap="truncate-end">{`${text} · best ${props?.best ?? 0}`}</Text>
241      ) : (
242        <Text dimColor wrap="truncate-end">{`${text} · best ${props?.best ?? 0}`}</Text>
243      )}
244    </Box>
245  )
246}
247
hooks/games/dino.ts 167 lines
1// The offline dinosaur: pure game logic, drawn by the Dino module in ../boards/dino.tsx.
2//
3// Units are board cells. Heights are measured up from the floor: an entity at height 0 sits on the
4// ground row, height 1 is the row above it. One tick is one frame of FRAME_MS.
5//
6// The constants are per-frame, so they are tied to FRAME_MS: at twice the frame rate a velocity
7// halves and an acceleration quarters. The board draws the dino at half-cell resolution, which is
8// why the frame rate is worth having — the jump arc is the motion the eye follows.
9
10export type ObstacleKind = 'cactus' | 'cluster' | 'tall' | 'bird'
11
12/** `x` is the left column, as a float; `bottom`/`top` are inclusive heights. */
13export type Obstacle = {
14  kind: ObstacleKind
15  x: number
16  w: number
17  bottom: number
18  top: number
19}
20
21export type DinoGame = {
22  w: number
23  h: number
24  /** the jump's launch speed and gravity, fitted to `h` by `newDino` */
25  v0: number
26  g: number
27  /** the dino's feet, in cells above the floor */
28  y: number
29  vy: number
30  ducking: boolean
31  obstacles: Obstacle[]
32  /** cells travelled; the score and the speed are both read off it */
33  dist: number
34  score: number
35  over: boolean
36  /** cells of clear ground to leave before the next obstacle */
37  gap: number
38}
39
40/** 30 frames a second: the surface's frame clock is asked for this in ../boards/dino.tsx */
41export const FRAME_MS = 33
42
43export const DINO_X = 4
44export const DINO_W = 3
45
46/** holding duck in the air drops the dino, as it does in the original */
47const FAST_FALL_FACTOR = 1.4
48const BASE_SPEED = 0.45
49const ACCEL = 0.000175
50const MAX_SPEED = 1.2
51/** every jump lasts this many frames, whatever the board's height, so the gaps do not change with it */
52const JUMP_FRAMES = 20
53/** birds only start once the run is going, like the original's 450 */
54const BIRD_SCORE = 200
55
56/**
57 * How high a jump goes on a board of `bh` rows: as high as the room allows, since a dino drawn past
58 * the top row is a dino the player cannot see. The dino stands two rows tall, so the apex has to
59 * leave one. The floor of 2.8 still clears a tall cactus, whose top is at height 1.
60 */
61export const apexFor = (bh: number) => Math.max(2.8, Math.min(5, bh - 2))
62
63/** the launch speed and gravity that reach `apex` in exactly JUMP_FRAMES frames */
64export const jumpArc = (bh: number) => {
65  const apex = apexFor(bh)
66  return { v0: (4 * apex) / JUMP_FRAMES, g: (8 * apex) / (JUMP_FRAMES * JUMP_FRAMES) }
67}
68
69/** below this the board has no room for a jump worth making, and the board says so instead */
70export const MIN_ROWS = 5
71
72/** a run gets faster the further it goes, and then stops getting faster */
73export const speedOf = (g: DinoGame) => Math.min(MAX_SPEED, BASE_SPEED + g.dist * ACCEL)
74
75/** three cells of travel to a point, so the counter climbs at about the original's rate */
76export const scoreOf = (dist: number) => Math.floor(dist / 3)
77
78/**
79 * The dino's box this frame: three cells of body, head and legs, two rows tall standing and one
80 * ducking. The board draws a tail two cells further left than this; a tail that ends a run would be
81 * a hit the player cannot read off the sprite, so the box does not cover it.
82 *
83 * The feet round to the nearest cell rather than flooring, so a dino drawn on the upper half of a
84 * cell is judged to be in the cell above — the board can draw a half-cell the hit test cannot see,
85 * and rounding puts that error on the player's side.
86 */
87export function dinoBox(g: DinoGame) {
88  const bottom = Math.round(g.y)
89  const grounded = g.y <= 0
90  return g.ducking && grounded
91    ? { left: DINO_X, right: DINO_X + DINO_W - 1, bottom: 0, top: 0 }
92    : { left: DINO_X, right: DINO_X + DINO_W - 1, bottom, top: bottom + 1 }
93}
94
95export function newDino(w: number, h: number): DinoGame {
96  const { v0, g } = jumpArc(h)
97  return { w, h, v0, g, y: 0, vy: 0, ducking: false, obstacles: [], dist: 0, score: 0, over: false, gap: 24 }
98}
99
100export const jump = (g: DinoGame): DinoGame =>
101  g.over || g.y > 0 ? g : { ...g, vy: g.v0, ducking: false }
102
103export const duck = (g: DinoGame, on: boolean): DinoGame => (g.over ? g : { ...g, ducking: on })
104
105function spawn(g: DinoGame, rand: () => number): Obstacle {
106  const roll = rand()
107  if (g.score >= BIRD_SCORE && roll < 0.25) {
108    // height 1 is in the standing dino's chest, so it has to be ducked or jumped; height 2 is only
109    // ever hit by a dino on its way up
110    const bottom = rand() < 0.65 ? 1 : 2
111    return { kind: 'bird', x: g.w, w: 2, bottom, top: bottom }
112  }
113  if (roll < 0.5) return { kind: 'cactus', x: g.w, w: 1, bottom: 0, top: 0 }
114  if (roll < 0.78) return { kind: 'tall', x: g.w, w: 1, bottom: 0, top: 1 }
115  return { kind: 'cluster', x: g.w, w: 3, bottom: 0, top: 0 }
116}
117
118/**
119 * The gap ahead, in cells: the frames it should last, times the cells a frame covers. A jump has to
120 * fit inside it with room to land and take off again, so the floor is well over JUMP_FRAMES.
121 */
122const nextGap = (speed: number, rand: () => number) =>
123  (JUMP_FRAMES * 1.3 + rand() * JUMP_FRAMES * 1.5) * speed
124
125const overlaps = (box: ReturnType<typeof dinoBox>, o: Obstacle) => {
126  const left = Math.round(o.x)
127  return (
128    left <= box.right &&
129    left + o.w - 1 >= box.left &&
130    o.bottom <= box.top &&
131    o.top >= box.bottom
132  )
133}
134
135/** one frame: gravity, the world scrolls left, a new obstacle when the gap has passed, then a hit test */
136export function tick(g: DinoGame, rand: () => number = Math.random): DinoGame {
137  if (g.over) return g
138  const speed = speedOf(g)
139
140  let { y, vy } = g
141  if (y > 0 || vy > 0) {
142    vy -= g.ducking ? g.g * FAST_FALL_FACTOR : g.g
143    y += vy
144    if (y <= 0) {
145      y = 0
146      vy = 0
147    }
148  }
149
150  const moved = g.obstacles.map(o => ({ ...o, x: o.x - speed })).filter(o => o.x + o.w > 0)
151  const gap = g.gap - speed
152  const obstacles = gap <= 0 ? [...moved, spawn(g, rand)] : moved
153
154  const dist = g.dist + speed
155  const next: DinoGame = {
156    ...g,
157    y,
158    vy,
159    obstacles,
160    gap: gap <= 0 ? nextGap(speed, rand) : gap,
161    dist,
162    score: scoreOf(dist),
163  }
164  const box = dinoBox(next)
165  return { ...next, over: obstacles.some(o => overlaps(box, o)) }
166}
167