SLOPSHOPPER

arcade

Five games in the docked side pane: Rail Runner, Road Hopper, Block Drop, Flap, 2048. Hides below 110 columns.

newpanebandcommandtimer
v0.1.0GPL-3.0-or-laterupdated 2026-10-02shabier/claude-plugins/plugins/arcade
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · arcade
│ ┃ Arcade ✕ › fix the failing auth test and add an audit log call │ ┃ ARCADE pi │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ⏺ 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 │ ┃ │ ┃ › /arcade │ ┃ ⎿ arcade: Arcade opened. Click the pane, then pick a number. │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Arcade
ARCADE pick a number ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ 1: Rail Runner 2: Road Hopper 3: Block Drop 4: Flap 5: 2
README

arcade

Five small games in the side pane. /arcade opens the list.

KeyGameControls
1Rail Runnera d lanes, w jump, s roll
2Road Hopperw hop, a d sideways, s back
3Block Dropa d move, w turn, s down, x drop
4Flapw flap
52048w a s d slide, r new board

q back to the list, p pause. Keys work after a click on the pane or ctrl+x tab. A run pauses when the pane loses them.

Commands

CommandDoes
/arcadeopen or close
/arcade <game>start one: rail, hopper, blocks, flap, 2048
`/arcade autostart on\off`open at session start. Off by default

Best scores stick across sessions.

Credits: CREDITS.md.

Source 10 files
hooks/register.tsx 279 lines
1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright (C) 2026 Shabier. Claude Code exception: see NOTICE.
3
4import { atom, read, update } from 'claude-code'
5import type { EngineInterface, Register } from 'claude-code'
6
7import type { ArcadePick } from '../types'
8import type { Cartridge, Game } from './cartridge'
9import { blockDrop } from './games/blocks'
10import { flap } from './games/flap'
11import { roadHopper } from './games/hopper'
12import { railRunner } from './games/rail'
13import { twenty48 } from './games/twenty48'
14import { menuWords } from './menu'
15import { encode } from './screen'
16
17const PANE = 'arcade'
18const TITLE = 'Arcade'
19// Dock floor. Below it the engine seats panes inline, above the prompt.
20const DOCK_COLUMNS = 110
21const WATCH_MS = 250
22// Fallback only, before the pane reports its body height.
23const CHROME_ROWS = 6
24
25const CARTS: readonly Cartridge[] = [railRunner, roadHopper, blockDrop, flap, twenty48]
26
27const ALIASES: Readonly<Record<string, string>> = {
28  subway: 'rail',
29  crossy: 'hopper',
30  road: 'hopper',
31  tetris: 'blocks',
32  block: 'blocks',
33  flappy: 'flap',
34}
35
36// Pick lives in $.state: survives hot reload, and a write redraws the pane.
37// The run is module-local; a reload restarts it. Best scores: $.store.
38const pick = atom({ plugin: 'arcade', key: 'pick' } as const, null as ArcadePick)
39
40let cart: Cartridge | undefined
41let game: Game | undefined
42let best: Record<string, number> = {}
43let size = { columns: 0, rows: 0 }
44let clock: { cancel: () => void } | undefined
45let lastTick = 0
46// Keys only arrive while the pane is focused. Unfocused run = unplayable
47// run, so it pauses.
48let isActive = false
49let placement: 'dock' | 'inline' | undefined
50let isHiddenByWidth = false
51let viewport = { columns: 0, isFullscreen: false }
52
53const clamp = (n: number, low: number, high: number) => Math.max(low, Math.min(high, Math.floor(n)))
54
55const frame = (columns: number, rows: number): string =>
56  encode(
57    game !== undefined && cart !== undefined
58      ? game.frame(columns, rows, best[cart.id] ?? 0)
59      : menuWords(CARTS, best, columns, rows),
60  )
61
62const find = (name: string): Cartridge | undefined => {
63  const wanted = name.trim().toLowerCase()
64  const id = ALIASES[wanted] ?? wanted
65
66  return CARTS.find(one => one.id === id || one.title.toLowerCase().startsWith(id))
67}
68
69// Timer and press callbacks have no caller to throw to. Failures go to
70// `claude --debug`.
71function report($: EngineInterface, error: unknown) {
72  $.ui.log(`arcade: ${error instanceof Error ? error.message : String(error)}`, { to: 'debug' })
73}
74
75async function paint($: EngineInterface) {
76  if (size.columns === 0) return
77  await $.ui.blit({ requestId: PANE, key: 'view', columns: size.columns, rows: size.rows, cells: frame(size.columns, size.rows) })
78}
79
80async function record($: EngineInterface) {
81  if (game === undefined || cart === undefined || game.phase !== 'over') return
82  if (game.score <= (best[cart.id] ?? 0)) return
83  best = { ...best, [cart.id]: game.score }
84  await $.store.set('best', best)
85}
86
87async function tick($: EngineInterface) {
88  if (game === undefined || cart === undefined) return
89  const now = await $.clock.now()
90  const ms = lastTick === 0 ? cart.tickMs : now - lastTick
91  lastTick = now
92
93  if (game.phase === 'play' && !isActive) {
94    game.pause()
95    await paint($)
96    return
97  }
98  if (game.step(ms)) {
99    await record($)
100    await paint($)
101  }
102}
103
104// The pane subscribes to the pick. Writing it swaps the control row.
105async function load($: EngineInterface, next: Cartridge | undefined) {
106  clock?.cancel()
107  clock = undefined
108  cart = next
109  game = next?.start(Math.floor(await $.clock.now()))
110  lastTick = 0
111  if (next !== undefined && next.tickMs > 0) {
112    clock = $.clock.every(next.tickMs, () => void tick($).catch(error => report($, error)))
113  }
114  await update($, pick, () => next?.id ?? null)
115}
116
117async function press($: EngineInterface, action: string) {
118  // A press proves focus. Without this the next tick can pause the run
119  // before watch() catches up.
120  isActive = true
121  if (game === undefined) return
122  game.act(action)
123  await record($)
124  await paint($)
125}
126
127// Below DOCK_COLUMNS the engine moves the pane above the prompt instead of
128// hiding it. Close it there, reopen once the window is wide again.
129async function watch($: EngineInterface) {
130  const pane = (await $.ui.panes()).find(one => one.id === PANE)
131  isActive = pane !== undefined && pane.isShown && pane.isPlaced && pane.isFocused
132
133  if (pane !== undefined && placement === 'inline') {
134    isHiddenByWidth = true
135    placement = undefined
136    size = { columns: 0, rows: 0 }
137    game?.pause()
138    await $.ui.close({ id: PANE })
139    return
140  }
141
142  const isWide = viewport.isFullscreen && viewport.columns >= DOCK_COLUMNS
143  if (pane === undefined && isHiddenByWidth && isWide) {
144    isHiddenByWidth = false
145    await $.ui.open({ id: PANE, title: TITLE })
146  }
147}
148
149// Off by default: no pane opens by itself unless the person turned this on.
150async function autostart($: EngineInterface, value: string): Promise<string> {
151  if (value === 'on' || value === 'off') await $.store.set('autostart', value === 'on')
152  else if (value !== '') return 'Usage: /arcade autostart on|off'
153  return (await $.store.get('autostart')) === true
154    ? 'The arcade opens at session start. /arcade autostart off undoes it.'
155    : 'The arcade opens on /arcade only. /arcade autostart on opens it at session start.'
156}
157
158export const register: Register = on => {
159  on('session.start', async ($, e, next) => {
160    const stored = await $.store.get('best')
161    best = typeof stored === 'object' && stored !== null ? { ...(stored as Record<string, number>) } : {}
162    await $.command.register({
163      name: 'arcade',
164      description: 'Open or close the arcade in the side pane; /arcade <game> starts one; /arcade autostart on|off',
165      argumentHint: '[rail|hopper|blocks|flap|2048|autostart]',
166    })
167    // Hot reload: resume the picked game.
168    const picked = await read($, pick)
169    if (picked !== null) await load($, CARTS.find(one => one.id === picked))
170    // Opt-in only. Unasked, the engine seats it from 144 columns, 110 once asked for.
171    if ((await $.store.get('autostart')) === true) void $.ui.open({ id: PANE, title: TITLE })
172    $.clock.every(WATCH_MS, () => void watch($).catch(error => report($, error)))
173
174    return next(e)
175  })
176
177  on('command.run', { command: 'arcade' }, async ($, e) => {
178    const [verb = '', value = ''] = e.args.trim().split(/\s+/)
179    if (verb === 'autostart') return { text: await autostart($, value) }
180    const wanted = e.args.trim() === '' ? undefined : find(e.args)
181    if (e.args.trim() !== '' && wanted === undefined) {
182      return { text: `No game called "${e.args.trim()}". Try: ${CARTS.map(one => one.id).join(', ')}.` }
183    }
184
185    const isOpen = (await $.ui.panes()).some(one => one.id === PANE)
186    if (isOpen && wanted === undefined) {
187      isHiddenByWidth = false
188      game?.pause()
189      await $.ui.close({ id: PANE })
190      return { text: 'Arcade closed.' }
191    }
192    if (!e.presentation.isFullscreen) {
193      return { text: 'The arcade needs the fullscreen layout, where panes dock beside the transcript.' }
194    }
195    if (e.presentation.columns < DOCK_COLUMNS) {
196      return { text: `The arcade needs a window of ${DOCK_COLUMNS} columns or more. This one has ${e.presentation.columns}.` }
197    }
198
199    isHiddenByWidth = false
200    if (wanted !== undefined) await load($, wanted)
201    await $.ui.open({ id: PANE, title: TITLE })
202
203    return { text: wanted === undefined ? 'Arcade opened. Click the pane, then pick a number.' : `${wanted.title} is up. Click the pane, then press w.` }
204  })
205
206  on('ui.close', ($, e, next) => {
207    if (e.id === PANE && e.origin.kind === 'person') {
208      // Closed by hand: stays closed until /arcade.
209      isHiddenByWidth = false
210      game?.pause()
211    }
212
213    return next(e)
214  })
215
216  // Width probe. The band re-renders on every resize, even with the pane
217  // closed. Returns the engine's band untouched.
218  on('ui.render', { component: 'AbovePrompt' }, ($, e, next) => {
219    if (e.surface === 'terminal' && e.viewport !== undefined) {
220      viewport = { columns: e.viewport.columns, isFullscreen: e.viewport.isFullscreen === true }
221    }
222
223    return next(e)
224  })
225
226  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
227    if (e.surface !== 'terminal') {
228      const { Text } = $.ui.resolve(e)
229      return <Text dimColor>The arcade plays in the terminal.</Text>
230    }
231
232    const { Box, Button, Raster, Text } = $.ui.resolve(e)
233    placement = e.props.placement
234    if (e.viewport !== undefined) {
235      viewport = { columns: e.viewport.columns, isFullscreen: e.viewport.isFullscreen === true }
236    }
237
238    if (e.props.placement === 'inline') {
239      return <Text dimColor>The arcade hides: the window is too narrow for the dock.</Text>
240    }
241
242    const columns = clamp(e.props.bodyColumns, 1, 512)
243    // The pane reports its body height; the button row under the Raster takes one.
244    const body = e.props.scroll.bodyRows > 0 ? e.props.scroll.bodyRows : (e.viewport?.rows ?? 30) - CHROME_ROWS
245    const rows = clamp(body - 1, 8, 256)
246    size = { columns, rows }
247
248    // Subscribes this render to the pick.
249    const picked = await read($, pick)
250    const shown = CARTS.find(one => one.id === picked)
251    const buttons =
252      shown === undefined || cart !== shown
253        ? CARTS.map((one, i) => (
254            <Button key={`pick-${one.id}`} hotkey={`${i + 1}`} label={one.title} plain onPress={() => void load($, one).catch(error => report($, error))} />
255          ))
256        : [
257            ...shown.controls.map(control => (
258              <Button
259                key={control.action}
260                hotkey={control.hotkey}
261                label={control.label}
262                plain
263                onPress={() => void press($, control.action).catch(error => report($, error))}
264              />
265            )),
266            <Button key="menu" hotkey="q" label="menu" plain onPress={() => void load($, undefined).catch(error => report($, error))} />,
267          ]
268
269    return (
270      <Box flexDirection="column">
271        <Raster key="view" columns={columns} rows={rows} cells={frame(columns, rows)} />
272        <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
273          {buttons}
274        </Box>
275      </Box>
276    )
277  })
278}
279
hooks/cartridge.ts 43 lines
1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright (C) 2026 Shabier. Claude Code exception: see NOTICE.
3
4// Arcade <-> game contract. Anything not in here is the game's business.
5
6export type Phase = 'title' | 'play' | 'paused' | 'over'
7
8// Engine limit: a hotkey is one lowercase letter or digit.
9export type Control = { hotkey: string; label: string; action: string }
10
11export type Game = {
12  readonly phase: Phase
13  readonly score: number
14  act(action: string): void
15  // false skips the blit.
16  step(ms: number): boolean
17  pause(): void
18  // [codePoint, fg, bg] per cell.
19  frame(columns: number, rows: number, best: number): Uint32Array
20}
21
22export type Cartridge = {
23  id: string
24  title: string
25  // 0 = turn based, no timer.
26  tickMs: number
27  controls: readonly Control[]
28  start(seed: number): Game
29}
30
31// Restart lockout. A late press meant for the dead run would start the next.
32export const RESTART_MS = 600
33
34// mulberry32 (Tommy Ettinger). State lives on the game, so a seed replays a run.
35export const nextRandom = (state: { rng: number }): number => {
36  state.rng = (state.rng + 0x6d2b79f5) | 0
37  let t = state.rng
38  t = Math.imul(t ^ (t >>> 15), t | 1)
39  t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
40
41  return ((t ^ (t >>> 14)) >>> 0) / 4294967296
42}
43
hooks/games/blocks.ts 345 lines
1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright (C) 2026 Shabier. Claude Code exception: see NOTICE.
3
4import { nextRandom, RESTART_MS, type Cartridge, type Phase } from '../cartridge'
5import { BAR_PX, mix, panel, Pixels, scoreBar, shade, UI, type Line } from '../screen'
6
7export const WIDTH = 10
8export const HEIGHT = 20
9// Spawn rows above the well. Never drawn.
10const HIDDEN = 2
11
12// Spawn rotation + rotation box. Box 0 = O piece, never turns.
13const SHAPES: readonly { cells: readonly (readonly [number, number])[]; box: number }[] = [
14  { cells: [[0, 1], [1, 1], [2, 1], [3, 1]], box: 4 },
15  { cells: [[1, 0], [2, 0], [1, 1], [2, 1]], box: 0 },
16  { cells: [[1, 0], [0, 1], [1, 1], [2, 1]], box: 3 },
17  { cells: [[1, 0], [2, 0], [0, 1], [1, 1]], box: 3 },
18  { cells: [[0, 0], [1, 0], [1, 1], [2, 1]], box: 3 },
19  { cells: [[0, 0], [0, 1], [1, 1], [2, 1]], box: 3 },
20  { cells: [[2, 0], [0, 1], [1, 1], [2, 1]], box: 3 },
21]
22
23const COLORS = [0x3fc6d9, 0xf2c94c, 0xa65fd6, 0x5cc46b, 0xe0533d, 0x3f78d9, 0xf08a3c] as const
24
25export type Piece = { kind: number; rot: number; x: number; y: number }
26
27export type Well = {
28  phase: Phase
29  rng: number
30  // Row-major, HIDDEN + HEIGHT rows. 0 empty, else kind + 1.
31  board: number[]
32  piece: Piece
33  next: number
34  bag: number[]
35  score: number
36  lines: number
37  fallMs: number
38  overMs: number
39}
40
41const ROWS = HIDDEN + HEIGHT
42
43export const cellsOf = (piece: Piece): [number, number][] => {
44  const shape = SHAPES[piece.kind] ?? SHAPES[0]!
45  return shape.cells.map(([cx, cy]) => {
46    let x = cx
47    let y = cy
48    for (let r = 0; r < piece.rot % 4 && shape.box > 0; r++) {
49      const turned = shape.box - 1 - y
50      y = x
51      x = turned
52    }
53
54    return [piece.x + x, piece.y + y]
55  })
56}
57
58const fits = (w: Well, piece: Piece): boolean =>
59  cellsOf(piece).every(([x, y]) => x >= 0 && x < WIDTH && y < ROWS && (y < 0 || w.board[y * WIDTH + x] === 0))
60
61// 7-bag: each kind once per bag. No droughts.
62const draw = (w: Well): number => {
63  if (w.bag.length === 0) {
64    w.bag = [0, 1, 2, 3, 4, 5, 6]
65    for (let i = w.bag.length - 1; i > 0; i--) {
66      const j = Math.floor(nextRandom(w) * (i + 1))
67      const swap = w.bag[i] ?? 0
68      w.bag[i] = w.bag[j] ?? 0
69      w.bag[j] = swap
70    }
71  }
72
73  return w.bag.pop() ?? 0
74}
75
76// Lower row spawns visible. Fully hidden spawns stayed invisible for 1.6s
77// at level 1.
78const spawnPiece = (w: Well) => {
79  w.piece = { kind: w.next, rot: 0, x: 3, y: HIDDEN - 1 }
80  w.next = draw(w)
81  if (!fits(w, w.piece)) {
82    w.phase = 'over'
83    w.overMs = 0
84  }
85}
86
87export const level = (w: Well): number => 1 + Math.floor(w.lines / 10)
88
89const fallInterval = (w: Well): number => Math.max(90, 800 - (level(w) - 1) * 70)
90
91export const newWell = (seed: number, phase: Phase = 'title'): Well => {
92  const w: Well = {
93    phase,
94    rng: seed | 0,
95    board: new Array(ROWS * WIDTH).fill(0),
96    piece: { kind: 0, rot: 0, x: 3, y: 0 },
97    next: 0,
98    bag: [],
99    score: 0,
100    lines: 0,
101    fallMs: 0,
102    overMs: 0,
103  }
104  w.next = draw(w)
105  spawnPiece(w)
106
107  return w
108}
109
110const LINE_SCORES = [0, 100, 300, 500, 800] as const
111
112const lock = (w: Well) => {
113  for (const [x, y] of cellsOf(w.piece)) {
114    if (y >= 0) w.board[y * WIDTH + x] = w.piece.kind + 1
115  }
116
117  let cleared = 0
118  for (let y = ROWS - 1; y >= 0; y--) {
119    const row = w.board.slice(y * WIDTH, (y + 1) * WIDTH)
120    if (row.every(v => v !== 0)) {
121      w.board.splice(y * WIDTH, WIDTH)
122      w.board.unshift(...new Array(WIDTH).fill(0))
123      cleared += 1
124      y += 1
125    }
126  }
127  w.score += (LINE_SCORES[cleared] ?? 800) * level(w)
128  w.lines += cleared
129  w.fallMs = 0
130  spawnPiece(w)
131}
132
133const shift = (w: Well, dx: number, dy: number): boolean => {
134  const moved = { ...w.piece, x: w.piece.x + dx, y: w.piece.y + dy }
135  if (!fits(w, moved)) return false
136  w.piece = moved
137
138  return true
139}
140
141// Kicks: 0, -1, +1, -2, +2 columns. Not SRS.
142const rotate = (w: Well) => {
143  const turned = { ...w.piece, rot: (w.piece.rot + 1) % 4 }
144  for (const kick of [0, -1, 1, -2, 2]) {
145    const tried = { ...turned, x: turned.x + kick }
146    if (fits(w, tried)) {
147      w.piece = tried
148      return
149    }
150  }
151}
152
153export const dropRow = (w: Well): number => {
154  let ghost = w.piece
155  for (;;) {
156    const lower = { ...ghost, y: ghost.y + 1 }
157    if (!fits(w, lower)) return ghost.y
158    ghost = lower
159  }
160}
161
162const start = (w: Well): Well => newWell(Math.floor(nextRandom(w) * 2 ** 31), 'play')
163
164export const act = (w: Well, action: string): Well => {
165  switch (w.phase) {
166    case 'title':
167      return action === 'pause' ? w : start(w)
168    case 'over':
169      return w.overMs < RESTART_MS || action === 'pause' ? w : start(w)
170    case 'paused':
171      w.phase = 'play'
172      return w
173    case 'play':
174      break
175  }
176
177  if (action === 'left') shift(w, -1, 0)
178  if (action === 'right') shift(w, 1, 0)
179  if (action === 'rotate') rotate(w)
180  if (action === 'down') {
181    if (shift(w, 0, 1)) w.score += 1
182    else lock(w)
183  }
184  if (action === 'drop') {
185    const to = dropRow(w)
186    w.score += (to - w.piece.y) * 2
187    w.piece = { ...w.piece, y: to }
188    lock(w)
189  }
190  if (action === 'pause') w.phase = 'paused'
191
192  return w
193}
194
195export const step = (w: Well, ms: number): boolean => {
196  if (w.phase === 'over') {
197    const wasWaiting = w.overMs < RESTART_MS
198    w.overMs += ms
199    return wasWaiting && w.overMs >= RESTART_MS
200  }
201  if (w.phase !== 'play') return false
202
203  w.fallMs += ms
204  let hasMoved = false
205  while (w.fallMs >= fallInterval(w) && w.phase === 'play') {
206    w.fallMs -= fallInterval(w)
207    if (!shift(w, 0, 1)) lock(w)
208    hasMoved = true
209  }
210
211  return hasMoved
212}
213
214const WELL_BG = 0x14110f
215const WELL_DOT = 0x221d1a
216const WELL_EDGE = 0x5b5651
217const BACKDROP = 0x1d1916
218
219const brick = (p: Pixels, x: number, y: number, size: number, color: number) => {
220  p.fill(x, x + size, y, y + size, color)
221  if (size < 3) return
222  p.fill(x, x + size, y, y + 1, mix(color, 0xffffff, 0.35))
223  p.fill(x, x + 1, y, y + size, mix(color, 0xffffff, 0.2))
224  p.fill(x, x + size, y + size - 1, y + size, shade(color, 0.6))
225  p.fill(x + size - 1, x + size, y, y + size, shade(color, 0.7))
226}
227
228const messageFor = (w: Well): Line[] => {
229  switch (w.phase) {
230    case 'title':
231      return [
232        { text: 'BLOCK DROP', color: UI.accent },
233        { text: '', color: UI.text },
234        { text: 'click here, then w', color: UI.text },
235        { text: 'a d move  w turn', color: UI.dim },
236        { text: 's down  x drop', color: UI.dim },
237      ]
238    case 'paused':
239      return [
240        { text: 'PAUSED', color: UI.accent },
241        { text: 'click here, then w', color: UI.text },
242      ]
243    case 'over':
244      return [
245        { text: 'TOPPED OUT', color: UI.accent },
246        { text: `score ${w.score}  lines ${w.lines}`, color: UI.text },
247        { text: w.overMs < RESTART_MS ? '' : 'w to play again', color: UI.dim },
248      ]
249    case 'play':
250      return []
251  }
252}
253
254export const frameWords = (w: Well, columns: number, rows: number, best: number): Uint32Array => {
255  const p = new Pixels(columns, rows)
256  p.fill(0, p.w, 0, p.h, BACKDROP)
257
258  // 12px side column: 4 bricks of the next piece at size 2.
259  const side = 12
260  const size = Math.max(1, Math.min(Math.floor((p.w - 2 - side) / WIDTH), Math.floor((p.h - BAR_PX - 4) / HEIGHT)))
261  const wellW = WIDTH * size
262  const wellH = HEIGHT * size
263  const left = Math.max(1, Math.floor((p.w - wellW - side) / 2))
264  const top = BAR_PX + Math.max(1, Math.floor((p.h - BAR_PX - wellH) / 2))
265
266  p.fill(left - 1, left + wellW + 1, top - 1, top + wellH + 1, WELL_EDGE)
267  p.fill(left, left + wellW, top, top + wellH, WELL_BG)
268  if (size >= 3) {
269    for (let y = 0; y < HEIGHT; y++) {
270      for (let x = 0; x < WIDTH; x++) p.dot(left + x * size + Math.floor(size / 2), top + y * size + Math.floor(size / 2), WELL_DOT)
271    }
272  }
273
274  for (let y = HIDDEN; y < ROWS; y++) {
275    for (let x = 0; x < WIDTH; x++) {
276      const v = w.board[y * WIDTH + x] ?? 0
277      if (v !== 0) brick(p, left + x * size, top + (y - HIDDEN) * size, size, COLORS[v - 1] ?? COLORS[0])
278    }
279  }
280
281  if (w.phase === 'play' || w.phase === 'paused') {
282    const color = COLORS[w.piece.kind] ?? COLORS[0]
283    const ghostY = dropRow(w)
284    for (const [x, y] of cellsOf({ ...w.piece, y: ghostY })) {
285      if (y >= HIDDEN) p.fill(left + x * size, left + (x + 1) * size, top + (y - HIDDEN) * size, top + (y - HIDDEN + 1) * size, shade(color, 0.3))
286    }
287    for (const [x, y] of cellsOf(w.piece)) {
288      if (y >= HIDDEN) brick(p, left + x * size, top + (y - HIDDEN) * size, size, color)
289    }
290  }
291
292  const small = Math.max(1, Math.min(size, 2))
293  const nx = left + wellW + 3
294  const ny = top + 2
295  for (const [x, y] of cellsOf({ kind: w.next, rot: 0, x: 0, y: 0 })) {
296    brick(p, nx + x * small, ny + y * small, small, COLORS[w.next] ?? COLORS[0])
297  }
298
299  const cells = p.cells()
300  scoreBar(
301    cells,
302    columns,
303    { text: `${w.score}`, color: UI.text },
304    { text: `L${level(w)} ${w.lines}`, color: UI.gold },
305    { text: `best ${Math.max(best, w.score)}`, color: UI.dim },
306  )
307  panel(cells, columns, rows, messageFor(w))
308
309  return cells
310}
311
312export const blockDrop: Cartridge = {
313  id: 'blocks',
314  title: 'Block Drop',
315  tickMs: 50,
316  controls: [
317    { hotkey: 'a', label: 'left', action: 'left' },
318    { hotkey: 'd', label: 'right', action: 'right' },
319    { hotkey: 'w', label: 'turn', action: 'rotate' },
320    { hotkey: 's', label: 'down', action: 'down' },
321    { hotkey: 'x', label: 'drop', action: 'drop' },
322    { hotkey: 'p', label: 'pause', action: 'pause' },
323  ],
324  start: seed => {
325    let w = newWell(seed)
326
327    return {
328      get phase() {
329        return w.phase
330      },
331      get score() {
332        return w.score
333      },
334      act: action => {
335        w = act(w, action)
336      },
337      step: ms => step(w, ms),
338      pause: () => {
339        if (w.phase === 'play') w.phase = 'paused'
340      },
341      frame: (columns, rows, best) => frameWords(w, columns, rows, best),
342    }
343  },
344}
345
hooks/games/flap.ts 273 lines
1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright (C) 2026 Shabier. Claude Code exception: see NOTICE.
3
4// Units are screen heights, 0 top, 1 bottom. Pane size changes the view,
5// not the physics.
6
7import { nextRandom, RESTART_MS, type Cartridge, type Phase } from '../cartridge'
8import { BAR_PX, hash, mix, panel, Pixels, scoreBar, shade, UI, type Line } from '../screen'
9
10export type Pipe = { x: number; gap: number; isPassed: boolean }
11
12export type Flight = {
13  phase: Phase
14  rng: number
15  y: number
16  vy: number
17  // Screen heights scrolled.
18  travel: number
19  pipes: Pipe[]
20  score: number
21  overMs: number
22  wingMs: number
23}
24
25export const GROUND = 0.88
26export const BIRD_X = 0.2
27export const BIRD_R = 0.028
28export const PIPE_W = 0.15
29export const GAP = 0.3
30const SPACING = 0.6
31const SPEED = 0.32
32const GRAVITY = 2.4
33const FLAP_V = -0.72
34const MAX_FALL = 1.15
35
36// Clamped so the lower pipe never starts below the ground line.
37const gapAt = (f: Flight): number => 0.1 + nextRandom(f) * (GROUND - GAP - 0.18)
38
39export const newFlight = (seed: number, phase: Phase = 'title'): Flight => {
40  const f: Flight = { phase, rng: seed | 0, y: 0.45, vy: 0, travel: 0, pipes: [], score: 0, overMs: 0, wingMs: 0 }
41  let x = 1.1
42  while (x < 3) {
43    f.pipes.push({ x, gap: gapAt(f), isPassed: false })
44    x += SPACING
45  }
46
47  return f
48}
49
50const start = (f: Flight): Flight => {
51  const next = newFlight(Math.floor(nextRandom(f) * 2 ** 31), 'play')
52  next.vy = FLAP_V
53
54  return next
55}
56
57export const act = (f: Flight, action: string): Flight => {
58  switch (f.phase) {
59    case 'title':
60      return action === 'pause' ? f : start(f)
61    case 'over':
62      return f.overMs < RESTART_MS || action === 'pause' ? f : start(f)
63    case 'paused':
64      f.phase = 'play'
65      return f
66    case 'play':
67      break
68  }
69
70  if (action === 'flap') {
71    f.vy = FLAP_V
72    f.wingMs = 160
73  }
74  if (action === 'pause') f.phase = 'paused'
75
76  return f
77}
78
79const hits = (f: Flight, pipe: Pipe): boolean => {
80  // Hitbox at 80% of the sprite. Forgiving on purpose.
81  const r = BIRD_R * 0.8
82  const birdX = f.travel + BIRD_X
83  if (birdX + r < pipe.x || birdX - r > pipe.x + PIPE_W) return false
84
85  return f.y - r < pipe.gap || f.y + r > pipe.gap + GAP
86}
87
88export const step = (f: Flight, ms: number): boolean => {
89  if (f.phase === 'over') {
90    const wasWaiting = f.overMs < RESTART_MS
91    f.overMs += ms
92    return wasWaiting && f.overMs >= RESTART_MS
93  }
94  if (f.phase !== 'play') return false
95
96  const dt = Math.min(ms, 100) / 1000
97  f.vy = Math.min(MAX_FALL, f.vy + GRAVITY * dt)
98  f.y = Math.max(0, f.y + f.vy * dt)
99  f.travel += SPEED * dt
100  f.wingMs = Math.max(0, f.wingMs - ms)
101
102  for (const pipe of f.pipes) {
103    if (!pipe.isPassed && pipe.x + PIPE_W < f.travel + BIRD_X) {
104      pipe.isPassed = true
105      f.score += 1
106    }
107  }
108  f.pipes = f.pipes.filter(pipe => pipe.x + PIPE_W > f.travel - 0.5)
109  const last = f.pipes[f.pipes.length - 1]
110  if (last !== undefined && last.x < f.travel + 3) {
111    f.pipes.push({ x: last.x + SPACING, gap: gapAt(f), isPassed: false })
112  }
113
114  if (f.y + BIRD_R >= GROUND || f.pipes.some(pipe => hits(f, pipe))) {
115    f.phase = 'over'
116    f.overMs = 0
117  }
118
119  return true
120}
121
122const COLOR = {
123  skyTop: 0x241a33,
124  skyLow: 0xd9774a,
125  hill: 0x3d2b45,
126  hillFar: 0x5a3a52,
127  pipe: 0x5aa845,
128  pipeLight: 0x8fd16a,
129  pipeDark: 0x2f6b26,
130  grass: 0x6cbf4a,
131  ground: 0xd9b36a,
132  groundDark: 0xb8924f,
133  bird: 0xf5c542,
134  belly: 0xfbe6a2,
135  wing: 0xe8a33a,
136  eye: 0xffffff,
137  pupil: 0x1a1513,
138  beak: 0xf07a2a,
139} as const
140
141const messageFor = (f: Flight): Line[] => {
142  switch (f.phase) {
143    case 'title':
144      return [
145        { text: 'FLAP', color: UI.accent },
146        { text: '', color: UI.text },
147        { text: 'click here, then w', color: UI.text },
148        { text: 'w flaps', color: UI.dim },
149      ]
150    case 'paused':
151      return [
152        { text: 'PAUSED', color: UI.accent },
153        { text: 'click here, then w', color: UI.text },
154      ]
155    case 'over':
156      return [
157        { text: 'BONK', color: UI.accent },
158        { text: `score ${f.score}`, color: UI.text },
159        { text: f.overMs < RESTART_MS ? '' : 'w to fly again', color: UI.dim },
160      ]
161    case 'play':
162      return []
163  }
164}
165
166export const frameWords = (f: Flight, columns: number, rows: number, best: number): Uint32Array => {
167  const p = new Pixels(columns, rows)
168  const h = p.h - BAR_PX
169  const toY = (y: number) => BAR_PX + y * h
170  const toX = (x: number) => (x - f.travel) * h
171  const ground = toY(GROUND)
172
173  for (let y = BAR_PX; y < ground; y++) {
174    p.fill(0, p.w, y, y + 1, mix(COLOR.skyTop, COLOR.skyLow, (y - BAR_PX) / (ground - BAR_PX)))
175  }
176
177  for (const [speed, height, color, seed] of [
178    [0.25, 0.16, COLOR.hillFar, 11],
179    [0.5, 0.1, COLOR.hill, 23],
180  ] as const) {
181    for (let x = 0; x < p.w; x++) {
182      const world = x / h + f.travel * speed
183      const at = Math.floor(world * 6)
184      const t = world * 6 - at
185      const a = hash(at + seed)
186      const b = hash(at + 1 + seed)
187      const top = ground - (0.3 + (a + (b - a) * (3 - 2 * t) * t * t) * 0.7) * height * h
188      p.fill(x, x + 1, top, ground, color)
189    }
190  }
191
192  for (const pipe of f.pipes) {
193    const x0 = toX(pipe.x)
194    const x1 = toX(pipe.x + PIPE_W)
195    if (x1 < 0 || x0 > p.w) continue
196    const lip = 0.025 * h
197    const gapTop = toY(pipe.gap)
198    const gapBottom = toY(pipe.gap + GAP)
199    for (const [y0, y1] of [
200      [BAR_PX, gapTop],
201      [gapBottom, ground],
202    ] as const) {
203      p.fill(x0, x1, y0, y1, COLOR.pipe)
204      p.fill(x0 + (x1 - x0) * 0.15, x0 + (x1 - x0) * 0.3, y0, y1, COLOR.pipeLight)
205      p.fill(x1 - (x1 - x0) * 0.15, x1, y0, y1, COLOR.pipeDark)
206    }
207    p.fill(x0 - lip * 0.6, x1 + lip * 0.6, gapTop - lip, gapTop, COLOR.pipe)
208    p.fill(x0 - lip * 0.6, x1 + lip * 0.6, gapBottom, gapBottom + lip, COLOR.pipe)
209    p.fill(x0 - lip * 0.6, x1 + lip * 0.6, gapTop - 1, gapTop, COLOR.pipeDark)
210    p.fill(x0 - lip * 0.6, x1 + lip * 0.6, gapBottom, gapBottom + 1, COLOR.pipeLight)
211  }
212
213  p.fill(0, p.w, ground, ground + 1, COLOR.grass)
214  for (let x = 0; x < p.w; x++) {
215    const stripe = Math.floor((x / h + f.travel) * 25) % 2 === 0
216    p.fill(x, x + 1, ground + 1, p.h, stripe ? COLOR.ground : COLOR.groundDark)
217  }
218
219  const cx = BIRD_X * h
220  const cy = toY(f.y)
221  const r = Math.max(2, BIRD_R * h * 1.25)
222  for (let y = Math.floor(cy - r); y <= Math.ceil(cy + r); y++) {
223    for (let x = Math.floor(cx - r * 1.2); x <= Math.ceil(cx + r * 1.2); x++) {
224      const dx = (x + 0.5 - cx) / (r * 1.2)
225      const dy = (y + 0.5 - cy) / r
226      if (dx * dx + dy * dy > 1) continue
227      p.dot(x, y, dy > 0.3 ? COLOR.belly : COLOR.bird)
228    }
229  }
230  const wingUp = f.wingMs > 0 || f.vy < -0.2
231  p.fill(cx - r * 1.1, cx - r * 0.1, cy + (wingUp ? -r * 0.7 : 0), cy + (wingUp ? -r * 0.1 : r * 0.6), COLOR.wing)
232  p.fill(cx + r * 0.25, cx + r * 0.85, cy - r * 0.75, cy - r * 0.1, COLOR.eye)
233  p.dot(cx + r * 0.55, cy - r * 0.45, COLOR.pupil)
234  p.fill(cx + r * 0.9, cx + r * 1.7, cy - r * 0.05, cy + r * 0.45, COLOR.beak)
235  if (f.phase === 'over') p.fill(cx - r, cx + r, cy - r, cy + r, shade(COLOR.bird, 0.7))
236
237  const cells = p.cells()
238  scoreBar(cells, columns, { text: `${f.score}`, color: UI.text }, null, { text: `best ${Math.max(best, f.score)}`, color: UI.dim })
239  panel(cells, columns, rows, messageFor(f))
240
241  return cells
242}
243
244export const flap: Cartridge = {
245  id: 'flap',
246  title: 'Flap',
247  tickMs: 33,
248  controls: [
249    { hotkey: 'w', label: 'flap', action: 'flap' },
250    { hotkey: 'p', label: 'pause', action: 'pause' },
251  ],
252  start: seed => {
253    let f = newFlight(seed)
254
255    return {
256      get phase() {
257        return f.phase
258      },
259      get score() {
260        return f.score
261      },
262      act: action => {
263        f = act(f, action)
264      },
265      step: ms => step(f, ms),
266      pause: () => {
267        if (f.phase === 'play') f.phase = 'paused'
268      },
269      frame: (columns, rows, best) => frameWords(f, columns, rows, best),
270    }
271  },
272}
273
hooks/games/hopper.ts 554 lines
1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright (C) 2026 Shabier. Claude Code exception: see NOTICE.
3
4import { nextRandom, RESTART_MS, type Cartridge, type Phase } from '../cartridge'
5import { BAR_PX, hash, panel, Pixels, scoreBar, shade, UI, type Line } from '../screen'
6
7// Fixed world width. Pane width scales the view, not the game.
8export const WIDTH = 9
9export const TRAIN_LEN = 16
10const START_GRASS = 4
11const HOP_MS = 110
12// Lanes/s once you move. Stand still ~16s and the eagle gets you.
13const CREEP = 0.28
14// Lanes kept below the player.
15const BEHIND = 4
16// Wrap margin. Must exceed the longest log (4) or logs pop in at the edge.
17const PAD = 5
18const LOOP = WIDTH + 2 * PAD
19const TRAIN_SPEED = 22
20const WARN_MS = 1300
21
22export type Mover = { x: number; len: number; tint: number }
23
24export type Lane =
25  | { kind: 'grass'; trees: number[] }
26  | { kind: 'road'; dir: 1 | -1; speed: number; movers: Mover[] }
27  | { kind: 'river'; dir: 1 | -1; speed: number; movers: Mover[] }
28  | { kind: 'rail'; dir: 1 | -1; waitMs: number; train: number | null }
29
30export type Kind = Lane['kind']
31export type Death = 'squashed' | 'splash' | 'swept' | 'train' | 'eagle'
32type Facing = 'up' | 'down' | 'left' | 'right'
33
34export type Hop = {
35  phase: Phase
36  rng: number
37  // lanes[i] is lane `base + i`. Lanes below the view get shifted out.
38  lanes: Lane[]
39  base: number
40  // Fractional while a log carries you.
41  x: number
42  lane: number
43  fromX: number
44  fromLane: number
45  hopMs: number
46  facing: Facing
47  // Lane at the bottom edge. Fractional, so the view scrolls smoothly.
48  view: number
49  hasMoved: boolean
50  score: number
51  overMs: number
52  death: Death | null
53  // Tree-free column carried between grass lanes. Without it two grass lanes
54  // can wall you in.
55  pathCol: number
56  plan: Kind[]
57}
58
59const below = (h: Hop, n: number): number => Math.floor(nextRandom(h) * n)
60const sign = (h: Hop): 1 | -1 => (nextRandom(h) < 0.5 ? 1 : -1)
61
62export const wrap = (x: number): number => (((x + PAD) % LOOP) + LOOP) % LOOP - PAD
63
64const CAR_TINTS = [0xd94f3a, 0x3f78d9, 0xf2c94c, 0x3fb6a8, 0xa65fd6] as const
65
66const fillLoop = (h: Hop, lengthOf: () => number, gapMin: number, gapMax: number, tinted: boolean): Mover[] => {
67  const movers: Mover[] = []
68  let u = nextRandom(h) * 2
69  for (;;) {
70    const len = lengthOf()
71    if (u + len + gapMin > LOOP) break
72    movers.push({ x: u - PAD, len, tint: tinted ? (CAR_TINTS[below(h, CAR_TINTS.length)] ?? CAR_TINTS[0]) : 0 })
73    u += len + gapMin + below(h, gapMax - gapMin + 1)
74  }
75
76  return movers
77}
78
79// Every hazard group ends in 1-2 grass lanes.
80const nextGroup = (h: Hop): Kind[] => {
81  const r = nextRandom(h)
82  const group: Kind[] =
83    r < 0.4
84      ? new Array<Kind>(1 + below(h, 3)).fill('road')
85      : r < 0.66
86        ? new Array<Kind>(1 + below(h, 2)).fill('river')
87        : r < 0.82
88          ? ['rail']
89          : []
90
91  return [...group, ...new Array<Kind>(1 + below(h, 2)).fill('grass')]
92}
93
94const makeLane = (h: Hop, n: number): Lane => {
95  if (n < START_GRASS) return { kind: 'grass', trees: [] }
96  if (h.plan.length === 0) h.plan = nextGroup(h)
97
98  // Up to 1.5x speed by lane 160.
99  const faster = 1 + Math.min(1, n / 160) * 0.5
100  const kind = h.plan.shift() ?? 'grass'
101  switch (kind) {
102    case 'grass': {
103      const next = Math.max(0, Math.min(WIDTH - 1, h.pathCol + below(h, 5) - 2))
104      const low = Math.min(h.pathCol, next)
105      const high = Math.max(h.pathCol, next)
106      h.pathCol = next
107      const trees: number[] = []
108      for (let i = below(h, 4); i > 0; i--) {
109        const col = below(h, WIDTH)
110        if ((col < low || col > high) && !trees.includes(col)) trees.push(col)
111      }
112      return { kind: 'grass', trees }
113    }
114    case 'road':
115      return {
116        kind: 'road',
117        dir: sign(h),
118        speed: (1.2 + nextRandom(h) * 1.8) * faster,
119        movers: fillLoop(h, () => (nextRandom(h) < 0.65 ? 1 : 2), 3, 6, true),
120      }
121    case 'river':
122      return {
123        kind: 'river',
124        dir: sign(h),
125        speed: (0.7 + nextRandom(h) * 0.8) * faster,
126        movers: fillLoop(h, () => 2 + below(h, 3), 1, 3, false),
127      }
128    case 'rail':
129      return { kind: 'rail', dir: sign(h), waitMs: 1500 + nextRandom(h) * 3500, train: null }
130  }
131}
132
133export const laneAt = (h: Hop, n: number): Lane => {
134  while (h.base + h.lanes.length <= n) h.lanes.push(makeLane(h, h.base + h.lanes.length))
135
136  return h.lanes[n - h.base] ?? { kind: 'grass', trees: [] }
137}
138
139export const newHop = (seed: number, phase: Phase = 'title'): Hop => {
140  const h: Hop = {
141    phase,
142    rng: seed | 0,
143    lanes: [],
144    base: 0,
145    x: Math.floor(WIDTH / 2),
146    lane: 0,
147    fromX: Math.floor(WIDTH / 2),
148    fromLane: 0,
149    hopMs: 0,
150    facing: 'up',
151    view: -BEHIND,
152    hasMoved: false,
153    score: 0,
154    overMs: 0,
155    death: null,
156    pathCol: Math.floor(WIDTH / 2),
157    plan: [],
158  }
159  laneAt(h, 40)
160
161  return h
162}
163
164const die = (h: Hop, death: Death) => {
165  h.phase = 'over'
166  h.death = death
167  h.overMs = 0
168}
169
170const hop = (h: Hop, dx: number, dLane: number, facing: Facing) => {
171  h.facing = facing
172  const toLane = h.lane + dLane
173  // Leaving a log snaps to the grid.
174  const toX = Math.round(h.x) + dx
175  if (toLane < 0 || toLane < h.view - 0.2 || toX < 0 || toX >= WIDTH) return
176  const target = laneAt(h, toLane)
177  if (target.kind === 'grass' && target.trees.includes(toX)) return
178
179  h.fromX = h.x
180  h.fromLane = h.lane
181  h.hopMs = HOP_MS
182  h.x = toX
183  h.lane = toLane
184  h.hasMoved = true
185  h.score = Math.max(h.score, toLane)
186  laneAt(h, toLane + 40)
187}
188
189const start = (h: Hop): Hop => newHop(Math.floor(nextRandom(h) * 2 ** 31), 'play')
190
191export const act = (h: Hop, action: string): Hop => {
192  switch (h.phase) {
193    case 'title':
194      return action === 'pause' ? h : start(h)
195    case 'over':
196      return h.overMs < RESTART_MS || action === 'pause' ? h : start(h)
197    case 'paused':
198      h.phase = 'play'
199      return h
200    case 'play':
201      break
202  }
203
204  if (action === 'forward') hop(h, 0, 1, 'up')
205  if (action === 'back') hop(h, 0, -1, 'down')
206  if (action === 'left') hop(h, -1, 0, 'left')
207  if (action === 'right') hop(h, 1, 0, 'right')
208  if (action === 'pause') h.phase = 'paused'
209
210  return h
211}
212
213const moveLane = (h: Hop, lane: Lane, ms: number) => {
214  const dt = ms / 1000
215  if (lane.kind === 'road' || lane.kind === 'river') {
216    for (const m of lane.movers) m.x = wrap(m.x + lane.dir * lane.speed * dt)
217    return
218  }
219  if (lane.kind !== 'rail') return
220
221  if (lane.train === null) {
222    lane.waitMs -= ms
223    if (lane.waitMs <= 0) lane.train = lane.dir > 0 ? -TRAIN_LEN - 1 : WIDTH + 1
224    return
225  }
226  lane.train += lane.dir * TRAIN_SPEED * dt
227  const isGone = lane.dir > 0 ? lane.train > WIDTH + 1 : lane.train + TRAIN_LEN < -1
228  if (isGone) {
229    lane.train = null
230    lane.waitMs = 2500 + nextRandom(h) * 4000
231  }
232}
233
234const overlaps = (a0: number, a1: number, b0: number, b1: number): boolean => a0 < b1 && b0 < a1
235
236export const logUnder = (lane: Lane, x: number): Mover | undefined =>
237  lane.kind === 'river' ? lane.movers.find(m => x + 0.5 >= m.x && x + 0.5 <= m.x + m.len) : undefined
238
239export const step = (h: Hop, ms: number): boolean => {
240  if (h.phase === 'over') {
241    const wasWaiting = h.overMs < RESTART_MS
242    h.overMs += ms
243    return wasWaiting && h.overMs >= RESTART_MS
244  }
245  if (h.phase !== 'play') return false
246
247  const clipped = Math.min(ms, 100)
248  const dt = clipped / 1000
249  h.hopMs = Math.max(0, h.hopMs - clipped)
250  for (const lane of h.lanes) moveLane(h, lane, clipped)
251
252  const here = laneAt(h, h.lane)
253  if (here.kind === 'river' && h.hopMs === 0) {
254    if (logUnder(here, h.x) === undefined) die(h, 'splash')
255    else {
256      h.x += here.dir * here.speed * dt
257      if (h.x < -0.45 || h.x > WIDTH - 0.55) die(h, 'swept')
258    }
259  }
260  if (here.kind === 'road' && here.movers.some(m => overlaps(m.x, m.x + m.len, h.x + 0.2, h.x + 0.8))) {
261    die(h, 'squashed')
262  }
263  if (here.kind === 'rail' && here.train !== null && overlaps(here.train, here.train + TRAIN_LEN, h.x + 0.1, h.x + 0.9)) {
264    die(h, 'train')
265  }
266
267  if (h.hasMoved) h.view += CREEP * dt
268  h.view = Math.max(h.view, h.lane - BEHIND)
269  if (h.phase === 'play' && h.lane < h.view - 0.6) die(h, 'eagle')
270
271  while (h.base < h.view - 6 && h.lanes.length > 1) {
272    h.lanes.shift()
273    h.base += 1
274  }
275
276  return true
277}
278
279const COLOR = {
280  grass: 0x5fa34a,
281  grassAlt: 0x569a44,
282  road: 0x3a3533,
283  dash: 0xd8d0c0,
284  curb: 0x5b5651,
285  water: 0x2e6fa8,
286  ripple: 0x4a8ccc,
287  log: 0x7a5232,
288  logEnd: 0x9c6b44,
289  bark: 0x5e3e26,
290  gravel: 0x4a3f38,
291  tie: 0x6d4b33,
292  rail: 0xc4beb6,
293  lightOn: 0xff4a36,
294  lightOff: 0x5a2a22,
295  trunk: 0x6b4a32,
296  leaf: 0x2f7a3a,
297  leafLight: 0x45a050,
298  leafDark: 0x245e2c,
299  glass: 0x9fd3e6,
300  cargo: 0xe8e2d8,
301  train: 0xc8452c,
302  chicken: 0xf5f2ea,
303  comb: 0xe0442f,
304  beak: 0xf08a3c,
305  shadow: 0x1a1513,
306  splash: 0xdff1ff,
307} as const
308
309type View = { p: Pixels; tile: number; ox: number; time: number }
310
311const laneTop = (v: View, h: Hop, n: number): number => v.p.h - (n - h.view + 1) * v.tile
312const colX = (v: View, x: number): number => v.ox + x * v.tile
313
314const backdrop = (v: View, h: Hop, n: number, lane: Lane) => {
315  const { p, tile } = v
316  const top = laneTop(v, h, n)
317  const bg =
318    lane.kind === 'grass'
319      ? n % 2 === 0
320        ? COLOR.grass
321        : COLOR.grassAlt
322      : lane.kind === 'road'
323        ? COLOR.road
324        : lane.kind === 'river'
325          ? COLOR.water
326          : COLOR.gravel
327  p.fill(0, p.w, top, top + tile, bg)
328  p.fill(0, v.ox, top, top + tile, shade(bg, 0.55))
329  p.fill(colX(v, WIDTH), p.w, top, top + tile, shade(bg, 0.55))
330
331  if (lane.kind === 'road') {
332    const ahead = laneAt(h, n + 1)
333    if (ahead.kind === 'road') {
334      for (let x = v.ox; x < colX(v, WIDTH); x++) {
335        if (Math.floor((x - v.ox) / 2) % 2 === 0) p.dot(x, top, COLOR.dash)
336      }
337    } else {
338      p.fill(v.ox, colX(v, WIDTH), top, top + 1, COLOR.curb)
339    }
340  }
341  if (lane.kind === 'river') {
342    for (let x = v.ox; x < colX(v, WIDTH); x++) {
343      for (let y = Math.ceil(top); y < top + tile; y++) {
344        if (hash(x * 31 + Math.floor(y + v.time / 400) * 977 + n * 7) < 0.08) p.dot(x, y, COLOR.ripple)
345      }
346    }
347  }
348  if (lane.kind === 'rail') {
349    for (let x = 0; x < WIDTH; x++) p.fill(colX(v, x) + tile * 0.4, colX(v, x) + tile * 0.6, top, top + tile, COLOR.tie)
350    p.fill(v.ox, colX(v, WIDTH), top + tile * 0.25, top + tile * 0.25 + 1, COLOR.rail)
351    p.fill(v.ox, colX(v, WIDTH), top + tile * 0.75, top + tile * 0.75 + 1, COLOR.rail)
352    const isWarning = lane.train !== null || lane.waitMs < WARN_MS
353    const isLit = isWarning && Math.floor(v.time / 250) % 2 === 0
354    p.fill(colX(v, WIDTH) + 1, colX(v, WIDTH) + Math.max(2, tile * 0.5), top + tile * 0.2, top + tile * 0.8, isLit ? COLOR.lightOn : COLOR.lightOff)
355  }
356}
357
358const things = (v: View, h: Hop, n: number, lane: Lane) => {
359  const { p, tile } = v
360  const top = laneTop(v, h, n)
361
362  if (lane.kind === 'grass') {
363    for (const col of lane.trees) {
364      const x = colX(v, col)
365      p.fill(x + tile * 0.42, x + tile * 0.58, top + tile * 0.6, top + tile, COLOR.trunk)
366      p.fill(x + tile * 0.12, x + tile * 0.88, top + tile * 0.05, top + tile * 0.72, COLOR.leaf)
367      p.fill(x + tile * 0.12, x + tile * 0.5, top + tile * 0.05, top + tile * 0.3, COLOR.leafLight)
368      p.fill(x + tile * 0.12, x + tile * 0.88, top + tile * 0.62, top + tile * 0.72, COLOR.leafDark)
369    }
370  }
371  if (lane.kind === 'road') {
372    for (const m of lane.movers) {
373      const x0 = colX(v, m.x)
374      const x1 = colX(v, m.x + m.len)
375      const y0 = top + tile * 0.15
376      const y1 = top + tile * 0.9
377      if (m.len > 1) {
378        const cab = (x1 - x0) * 0.3
379        const cabAt = lane.dir > 0 ? x1 - cab : x0
380        p.fill(x0, x1, y0, y1, COLOR.cargo)
381        p.fill(cabAt, cabAt + cab, y0, y1, m.tint)
382      } else {
383        p.fill(x0, x1, y0, y1, m.tint)
384        p.fill(x0 + (x1 - x0) * 0.3, x1 - (x1 - x0) * 0.3, y0, y1, shade(m.tint, 0.75))
385      }
386      const front = lane.dir > 0 ? x1 - Math.max(1, tile * 0.2) : x0
387      p.fill(front, front + Math.max(1, tile * 0.2), y0 + 1, y1 - 1, COLOR.glass)
388      p.fill(x0, x1, y1 - 1, y1, shade(m.tint, 0.4))
389    }
390  }
391  if (lane.kind === 'river') {
392    for (const m of lane.movers) {
393      const x0 = colX(v, m.x)
394      const x1 = colX(v, m.x + m.len)
395      p.fill(x0, x1, top + tile * 0.15, top + tile * 0.85, COLOR.log)
396      p.fill(x0, x1, top + tile * 0.5, top + tile * 0.5 + 1, COLOR.bark)
397      p.fill(x0, x0 + 1, top + tile * 0.15, top + tile * 0.85, COLOR.logEnd)
398      p.fill(x1 - 1, x1, top + tile * 0.15, top + tile * 0.85, COLOR.logEnd)
399    }
400  }
401  if (lane.kind === 'rail' && lane.train !== null) {
402    const x0 = colX(v, lane.train)
403    const x1 = colX(v, lane.train + TRAIN_LEN)
404    p.fill(x0, x1, top + tile * 0.05, top + tile * 0.95, COLOR.train)
405    for (let car = 0; car < TRAIN_LEN; car += 2) {
406      p.fill(colX(v, lane.train + car + 0.4), colX(v, lane.train + car + 1.4), top + tile * 0.3, top + tile * 0.6, COLOR.glass)
407    }
408    const nose = lane.dir > 0 ? x1 - 1 : x0
409    p.fill(nose, nose + 1, top + tile * 0.05, top + tile * 0.95, shade(COLOR.train, 0.6))
410  }
411}
412
413const chicken = (v: View, h: Hop) => {
414  const { p, tile } = v
415  const t = h.hopMs > 0 ? 1 - h.hopMs / HOP_MS : 1
416  const x = h.fromX + (h.x - h.fromX) * t
417  const lane = h.fromLane + (h.lane - h.fromLane) * t
418  const lift = Math.sin(Math.PI * t) * tile * 0.35
419  const top = v.p.h - (lane - h.view + 1) * tile
420  const x0 = colX(v, x) + tile * 0.18
421  const x1 = colX(v, x) + tile * 0.82
422  const y0 = top + tile * 0.15 - lift
423  const y1 = top + tile * 0.85 - lift
424
425  if (h.death === 'train') return
426  if (h.death === 'splash' || h.death === 'swept') {
427    p.fill(x0 - 1, x1 + 1, top + tile * 0.4, top + tile * 0.6, COLOR.splash)
428    p.fill(x0, x1, top + tile * 0.2, top + tile * 0.8, COLOR.splash)
429    return
430  }
431  if (h.death === 'squashed') {
432    p.fill(x0 - 1, x1 + 1, top + tile * 0.45, top + tile * 0.7, COLOR.chicken)
433    return
434  }
435
436  if (lift > 0.5) p.fill(x0, x1, top + tile * 0.7, top + tile * 0.9, COLOR.shadow)
437  p.fill(x0, x1, y0, y1, COLOR.chicken)
438  const mid = (x0 + x1) / 2
439  const midY = (y0 + y1) / 2
440  switch (h.facing) {
441    case 'up':
442      p.fill(mid - 0.5, mid + 0.5, y0 - 1, y0, COLOR.comb)
443      break
444    case 'down':
445      p.fill(mid - 0.5, mid + 0.5, y1, y1 + 1, COLOR.beak)
446      p.fill(mid - 0.5, mid + 0.5, y0, y0 + 1, COLOR.comb)
447      break
448    case 'left':
449      p.fill(x0 - 1, x0, midY - 0.5, midY + 0.5, COLOR.beak)
450      p.fill(mid - 0.5, mid + 0.5, y0 - 1, y0, COLOR.comb)
451      break
452    case 'right':
453      p.fill(x1, x1 + 1, midY - 0.5, midY + 0.5, COLOR.beak)
454      p.fill(mid - 0.5, mid + 0.5, y0 - 1, y0, COLOR.comb)
455      break
456  }
457}
458
459const DEATHS: Record<Death, string> = {
460  squashed: 'SQUASHED',
461  splash: 'SPLASH',
462  swept: 'SWEPT AWAY',
463  train: 'HIT BY A TRAIN',
464  eagle: 'TOO SLOW',
465}
466
467const messageFor = (h: Hop): Line[] => {
468  switch (h.phase) {
469    case 'title':
470      return [
471        { text: 'ROAD HOPPER', color: UI.accent },
472        { text: '', color: UI.text },
473        { text: 'click here, then w', color: UI.text },
474        { text: 'w hop  a d sideways  s back', color: UI.dim },
475      ]
476    case 'paused':
477      return [
478        { text: 'PAUSED', color: UI.accent },
479        { text: 'click here, then w', color: UI.text },
480      ]
481    case 'over':
482      return [
483        { text: DEATHS[h.death ?? 'squashed'], color: UI.accent },
484        { text: `score ${h.score}`, color: UI.text },
485        { text: h.overMs < RESTART_MS ? '' : 'w to hop again', color: UI.dim },
486      ]
487    case 'play':
488      return []
489  }
490}
491
492export const frameWords = (h: Hop, columns: number, rows: number, best: number, time = 0): Uint32Array => {
493  const p = new Pixels(columns, rows)
494  const tile = Math.max(2, Math.floor(p.w / WIDTH))
495  const v: View = { p, tile, ox: Math.floor((p.w - WIDTH * tile) / 2), time }
496
497  const first = Math.floor(h.view)
498  const last = Math.ceil(h.view + (p.h - BAR_PX) / tile) + 1
499  for (let n = first; n <= last; n++) backdrop(v, h, n, laneAt(h, n))
500  for (let n = first; n <= last; n++) {
501    const lane = laneAt(h, n)
502    if (lane.kind !== 'rail') things(v, h, n, lane)
503  }
504  chicken(v, h)
505  // Trains last: they draw over the chicken.
506  for (let n = first; n <= last; n++) {
507    const lane = laneAt(h, n)
508    if (lane.kind === 'rail') things(v, h, n, lane)
509  }
510
511  const cells = p.cells()
512  scoreBar(cells, columns, { text: `${h.score}`, color: UI.text }, null, { text: `best ${Math.max(best, h.score)}`, color: UI.dim })
513  panel(cells, columns, rows, messageFor(h))
514
515  return cells
516}
517
518export const roadHopper: Cartridge = {
519  id: 'hopper',
520  title: 'Road Hopper',
521  tickMs: 50,
522  controls: [
523    { hotkey: 'w', label: 'hop', action: 'forward' },
524    { hotkey: 'a', label: 'left', action: 'left' },
525    { hotkey: 'd', label: 'right', action: 'right' },
526    { hotkey: 's', label: 'back', action: 'back' },
527    { hotkey: 'p', label: 'pause', action: 'pause' },
528  ],
529  start: seed => {
530    let h = newHop(seed)
531    let time = 0
532
533    return {
534      get phase() {
535        return h.phase
536      },
537      get score() {
538        return h.score
539      },
540      act: action => {
541        h = act(h, action)
542      },
543      step: ms => {
544        time += ms
545        return step(h, ms)
546      },
547      pause: () => {
548        if (h.phase === 'play') h.phase = 'paused'
549      },
550      frame: (columns, rows, best) => frameWords(h, columns, rows, best, time),
551    }
552  },
553}
554
hooks/games/rail.ts 532 lines
1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright (C) 2026 Shabier. Claude Code exception: see NOTICE.
3
4import { nextRandom, RESTART_MS, type Cartridge, type Phase } from '../cartridge'
5import { BAR_PX, clamp01, frac, hash, mix, panel, Pixels, scoreBar, shade, UI, type Line } from '../screen'
6
7export type Action = 'left' | 'right' | 'jump' | 'roll' | 'pause'
8export type Kind = 'train' | 'low' | 'high' | 'coin'
9
10// `z`: metres to the front edge. Negative once passed.
11export type Thing = { kind: Kind; lane: number; z: number; tint: number }
12
13export type Run = {
14  phase: Phase
15  rng: number
16  lane: number
17  x: number
18  y: number
19  vy: number
20  rollMs: number
21  speed: number
22  distance: number
23  coins: number
24  score: number
25  things: Thing[]
26  untilRow: number
27  overMs: number
28}
29
30export const LANE_W = 2.2
31export const TRAIN_LEN = 14
32const FAR = 72
33const GRAVITY = 34
34const JUMP_V = 11
35const SLAM_V = -22
36const ROLL_MS = 650
37const LANE_RATE = 7
38const START_SPEED = 14
39const MAX_SPEED = 32
40const ACCEL = 0.22
41const LOW_CLEARANCE = 0.8
42
43const TINTS = [0xc8452c, 0x2e6fa8, 0xd9a23a, 0x3f8f5a] as const
44
45const below = (g: Run, n: number): number => Math.floor(nextRandom(g) * n)
46
47export const newRun = (seed: number, phase: Phase = 'title'): Run => {
48  const g: Run = {
49    phase,
50    rng: seed | 0,
51    lane: 1,
52    x: 1,
53    y: 0,
54    vy: 0,
55    rollMs: 0,
56    speed: START_SPEED,
57    distance: 0,
58    coins: 0,
59    score: 0,
60    things: [],
61    untilRow: 0,
62    overMs: 0,
63  }
64
65  // 30m run-up before the first row.
66  let z = 30
67  while (z < FAR) {
68    z += spawnRow(g, z)
69  }
70  g.untilRow = z - FAR
71
72  return g
73}
74
75const coinLine = (g: Run, lane: number, z: number) => {
76  for (let i = 0; i < 6; i++) {
77    g.things.push({ kind: 'coin', lane, z: z + i * 2.4, tint: 0 })
78  }
79}
80
81// Every row leaves a lane open. The gap after a train fits a 2-lane switch.
82const spawnRow = (g: Run, z: number): number => {
83  let hasTrain = false
84  const put = (kind: Kind, lane: number) => {
85    hasTrain ||= kind === 'train'
86    g.things.push({ kind, lane, z, tint: TINTS[below(g, TINTS.length)] ?? TINTS[0] })
87  }
88  const blocker = (): Kind => {
89    const r = nextRandom(g)
90
91    return r < 0.55 ? 'train' : r < 0.8 ? 'low' : 'high'
92  }
93
94  const roll = nextRandom(g)
95  if (roll < 0.4) {
96    const open = below(g, 3)
97    for (const lane of [0, 1, 2]) {
98      if (lane !== open) put(blocker(), lane)
99    }
100    coinLine(g, open, z)
101  } else if (roll < 0.78) {
102    const blocked = below(g, 3)
103    put(blocker(), blocked)
104    if (nextRandom(g) < 0.5) coinLine(g, (blocked + 1 + below(g, 2)) % 3, z)
105  } else {
106    const kind = nextRandom(g) < 0.5 ? 'low' : 'high'
107    for (const lane of [0, 1, 2]) put(kind, lane)
108  }
109
110  const room = Math.max(12, g.speed * 0.95) + nextRandom(g) * 8
111
112  return (hasTrain ? TRAIN_LEN : 0) + room
113}
114
115const start = (g: Run): Run => newRun(below(g, 2 ** 31), 'play')
116
117export const act = (g: Run, action: Action): Run => {
118  switch (g.phase) {
119    case 'title':
120      return action === 'pause' ? g : start(g)
121    case 'over':
122      return g.overMs < RESTART_MS || action === 'pause' ? g : start(g)
123    case 'paused':
124      g.phase = 'play'
125      return g
126    case 'play':
127      break
128  }
129
130  if (action === 'left') g.lane = Math.max(0, g.lane - 1)
131  if (action === 'right') g.lane = Math.min(2, g.lane + 1)
132  if (action === 'jump' && g.y === 0) {
133    g.vy = JUMP_V
134    g.rollMs = 0
135  }
136  if (action === 'roll') {
137    g.rollMs = ROLL_MS
138    if (g.y > 0) g.vy = Math.min(g.vy, SLAM_V)
139  }
140  if (action === 'pause') g.phase = 'paused'
141
142  return g
143}
144
145export const pause = (g: Run): void => {
146  if (g.phase === 'play') g.phase = 'paused'
147}
148
149const lengthOf = (thing: Thing): number => (thing.kind === 'train' ? TRAIN_LEN : 0)
150
151// Swept check. At 32 m/s one tick moves 1.6m; a point check misses thin
152// barriers.
153const crossed = (before: number, after: number): boolean => before >= -0.3 && after <= 0.3
154
155export const step = (g: Run, ms: number): Phase => {
156  if (g.phase === 'over') {
157    g.overMs += ms
158    return g.phase
159  }
160  if (g.phase !== 'play') return g.phase
161
162  const dt = Math.min(ms, 100) / 1000
163  g.speed = Math.min(MAX_SPEED, g.speed + ACCEL * dt)
164
165  const reach = LANE_RATE * dt
166  g.x += Math.max(-reach, Math.min(reach, g.lane - g.x))
167
168  if (g.y > 0 || g.vy > 0) {
169    g.vy -= GRAVITY * dt
170    g.y = Math.max(0, g.y + g.vy * dt)
171    if (g.y === 0) g.vy = 0
172  }
173  g.rollMs = Math.max(0, g.rollMs - ms)
174
175  const run = g.speed * dt
176  g.distance += run
177  const lane = Math.round(g.x)
178
179  for (const thing of g.things) {
180    const before = thing.z
181    thing.z -= run
182    if (thing.lane !== lane) continue
183
184    if (thing.kind === 'coin') {
185      if (crossed(before, thing.z) && g.y < 2.2) {
186        g.coins += 1
187        thing.z = -1000
188      }
189      continue
190    }
191
192    const isHit =
193      thing.kind === 'train'
194        ? thing.z <= 0.3 && thing.z + TRAIN_LEN >= -0.3
195        : thing.kind === 'low'
196          ? crossed(before, thing.z) && g.y < LOW_CLEARANCE
197          : crossed(before, thing.z) && g.rollMs === 0
198
199    if (isHit) {
200      g.phase = 'over'
201      g.overMs = 0
202    }
203  }
204
205  g.things = g.things.filter(thing => thing.z + lengthOf(thing) > -8)
206
207  g.untilRow -= run
208  while (g.untilRow <= 0) {
209    g.untilRow += spawnRow(g, FAR + g.untilRow)
210  }
211
212  g.score = Math.floor(g.distance) + g.coins * 10
213
214  return g.phase
215}
216
217const COLOR = {
218  skyTop: 0x0c0a12,
219  skyLow: 0x3b1e2a,
220  glow: 0x7a3426,
221  star: 0x8a7f9a,
222  city: 0x1b1520,
223  window: 0xffb35c,
224  side: 0x2b2522,
225  sideDark: 0x231e1c,
226  platform: 0x5b5651,
227  bed: 0x4a3f38,
228  bedAlt: 0x433832,
229  tie: 0x6d4b33,
230  rail: 0xc4beb6,
231  fog: 0x3b1e2a,
232  glass: 0x9fd3e6,
233  lamp: 0xfff2c0,
234  bumper: 0x1e1a18,
235  post: 0x8a8580,
236  white: 0xf0e8e0,
237  red: 0xd23c2c,
238  yellow: 0xe8b420,
239  black: 0x1e1a18,
240  coin: 0xffcf3f,
241  coinLight: 0xfff3b0,
242  hoodie: 0xe8692c,
243  hood: 0xb84f1f,
244  pants: 0x2b3a67,
245  shoe: 0xf2f2f2,
246  hair: 0x3a2a22,
247  cap: 0x2fa37a,
248  shadow: 0x1a1513,
249} as const
250
251const CAM = 8
252
253type View = { p: Pixels; horizon: number; feet: number; ppm: number; cx: number }
254
255const makeView = (p: Pixels): View => {
256  const horizon = BAR_PX + Math.round((p.h - BAR_PX) * 0.28)
257  const feet = p.h - Math.max(3, Math.round(p.h * 0.06))
258  const ppm = Math.max(1, Math.min(p.w / 8.6, (feet - horizon) / 5.5))
259
260  return { p, horizon, feet, ppm, cx: p.w / 2 }
261}
262
263const scaleAt = (z: number): number => CAM / (z + CAM)
264const groundAt = (v: View, s: number): number => v.horizon + s * (v.feet - v.horizon)
265const laneX = (v: View, lane: number, s: number): number => v.cx + (lane - 1) * LANE_W * s * v.ppm
266const fogAt = (z: number): number => clamp01((z - 22) / 55) * 0.8
267
268// `lane` may be fractional. Sizes in metres.
269const block = (v: View, lane: number, z: number, width: number, base: number, height: number, color: number) => {
270  const s = scaleAt(z)
271  const k = s * v.ppm
272  const mid = laneX(v, lane, s)
273  const ground = groundAt(v, s)
274  v.p.fill(mid - (width / 2) * k, mid + (width / 2) * k, ground - (base + height) * k, ground - base * k, color)
275}
276
277const groundColor = (lx: number, world: number, s: number, ppm: number, hasTies: boolean): number => {
278  const half = LANE_W * 1.5
279  const ax = Math.abs(lx)
280  if (ax > half + 0.5) return Math.floor(world / 3) % 2 === 0 ? COLOR.side : COLOR.sideDark
281  if (ax > half) return COLOR.platform
282
283  const u = lx + half
284  const lane = Math.min(2, Math.floor(u / LANE_W))
285  const off = u - (lane + 0.5) * LANE_W
286  const metresPerPixel = 1 / (s * ppm)
287  const bed = lane % 2 === 0 ? COLOR.bed : COLOR.bedAlt
288  // Far pixels are mostly gravel. Blend rail by coverage or the far track
289  // turns into a grey funnel.
290  if (Math.abs(Math.abs(off) - 0.55) < Math.max(0.07, metresPerPixel * 0.5)) {
291    return mix(bed, COLOR.rail, Math.min(1, 0.22 / metresPerPixel))
292  }
293  if (hasTies && Math.abs(off) < 0.85 && frac(world / 1.1) < 0.4) return COLOR.tie
294
295  return bed
296}
297
298const drawBackdrop = (v: View, g: Run) => {
299  const { p } = v
300
301  for (let y = BAR_PX; y < v.horizon; y++) {
302    const t = (y - BAR_PX) / Math.max(1, v.horizon - BAR_PX)
303    const sky = t < 0.75 ? mix(COLOR.skyTop, COLOR.skyLow, t / 0.75) : mix(COLOR.skyLow, COLOR.glow, (t - 0.75) / 0.25)
304    for (let x = 0; x < p.w; x++) {
305      p.px[y * p.w + x] = hash(x * 7919 + y * 104729) < 0.012 && t < 0.6 ? COLOR.star : sky
306    }
307  }
308
309  const room = Math.max(2, (v.horizon - BAR_PX) * 0.7)
310  for (let x = 0; x < p.w; x++) {
311    const tall = Math.round((0.2 + hash(Math.floor(x / 4) + 31) * 0.65) * room)
312    for (let y = Math.max(BAR_PX, v.horizon - tall); y < v.horizon; y++) {
313      const isWindow = x % 4 !== 0 && hash(x * 131 + y * 977) < 0.14
314      p.px[y * p.w + x] = isWindow ? COLOR.window : COLOR.city
315    }
316  }
317
318  for (let y = v.horizon; y < p.h; y++) {
319    const s = (y + 0.5 - v.horizon) / (v.feet - v.horizon)
320    const z = CAM / s - CAM
321    const zNext = CAM / ((y + 1.5 - v.horizon) / (v.feet - v.horizon)) - CAM
322    const hasTies = z - zNext < 0.45
323    const world = z + g.distance
324    const fog = fogAt(z)
325    for (let x = 0; x < p.w; x++) {
326      const lx = (x + 0.5 - v.cx) / (s * v.ppm)
327      p.px[y * p.w + x] = mix(groundColor(lx, world, s, v.ppm, hasTies), COLOR.fog, fog)
328    }
329  }
330}
331
332type Op = { z: number; draw: () => void }
333
334const trainFace = (v: View, t: Thing) => {
335  const tone = (color: number) => mix(color, COLOR.fog, fogAt(t.z))
336  block(v, t.lane, t.z, 2.0, 0, 3.3, tone(t.tint))
337  block(v, t.lane, t.z, 2.0, 0, 0.35, tone(COLOR.bumper))
338  block(v, t.lane, t.z, 1.5, 1.9, 0.9, tone(COLOR.glass))
339  block(v, t.lane - 0.27, t.z, 0.3, 0.6, 0.3, tone(COLOR.lamp))
340  block(v, t.lane + 0.27, t.z, 0.3, 0.6, 0.3, tone(COLOR.lamp))
341}
342
343// 0.6m slices, back to front. Roof and inner side show without a mesh.
344const train = (v: View, t: Thing, ops: Op[]) => {
345  const near = -CAM + 1.5
346  const back = Math.min(t.z + TRAIN_LEN, FAR + 2)
347  const front = Math.max(t.z, near)
348  if (back <= front) return
349
350  for (let z = back; z > front; z -= 0.6) {
351    ops.push({
352      z,
353      draw: () => {
354        block(v, t.lane, z, 2.0, 0, 3.3, mix(shade(t.tint, 0.6), COLOR.fog, fogAt(z)))
355        block(v, t.lane, z, 2.0, 3.15, 0.15, mix(shade(t.tint, 0.85), COLOR.fog, fogAt(z)))
356      },
357    })
358  }
359  if (t.z >= near) ops.push({ z: t.z, draw: () => trainFace(v, t) })
360}
361
362const stripes = (v: View, t: Thing, base: number, height: number, a: number, b: number) => {
363  const s = scaleAt(t.z)
364  const k = s * v.ppm
365  const mid = laneX(v, t.lane, s)
366  const ground = groundAt(v, s)
367  const fog = fogAt(t.z)
368  const left = Math.round(mid - 1.0 * k)
369  const right = Math.max(left + 1, Math.round(mid + 1.0 * k))
370  for (let x = left; x < right; x++) {
371    const m = (x + 0.5 - mid) / k
372    const color = Math.floor((m + 5) / 0.4) % 2 === 0 ? a : b
373    v.p.fill(x, x + 1, ground - (base + height) * k, ground - base * k, mix(color, COLOR.fog, fog))
374  }
375}
376
377const barrier = (v: View, t: Thing) => {
378  const isLow = t.kind === 'low'
379  const top = isLow ? 1.0 : 2.1
380  const post = mix(COLOR.post, COLOR.fog, fogAt(t.z))
381  block(v, t.lane - 0.43, t.z, 0.12, 0, top, post)
382  block(v, t.lane + 0.43, t.z, 0.12, 0, top, post)
383  if (isLow) stripes(v, t, 0.55, 0.45, COLOR.red, COLOR.white)
384  else stripes(v, t, 1.35, 0.75, COLOR.yellow, COLOR.black)
385}
386
387const coin = (v: View, t: Thing, spin: number) => {
388  const s = scaleAt(t.z)
389  const k = s * v.ppm
390  const mid = laneX(v, t.lane, s)
391  const centre = groundAt(v, s) - 0.75 * k
392  const ry = Math.max(0.5, 0.3 * k)
393  const rx = Math.max(0.5, ry * Math.abs(Math.cos(spin + t.z * 0.5)))
394  const fog = fogAt(t.z)
395  for (let y = Math.floor(centre - ry); y <= Math.ceil(centre + ry); y++) {
396    for (let x = Math.floor(mid - rx); x <= Math.ceil(mid + rx); x++) {
397      const dx = (x + 0.5 - mid) / rx
398      const dy = (y + 0.5 - centre) / ry
399      if (dx * dx + dy * dy > 1.3) continue
400      v.p.dot(x, y, mix(dy < -0.2 && dx < 0 ? COLOR.coinLight : COLOR.coin, COLOR.fog, fog))
401    }
402  }
403}
404
405const runner = (v: View, g: Run) => {
406  const lane = g.x
407  const b = g.y
408  const s = scaleAt(0)
409  const k = s * v.ppm
410  const ground = groundAt(v, s)
411  const mid = laneX(v, lane, s)
412
413  v.p.fill(mid - 0.45 * k, mid + 0.45 * k, ground - 1, ground, COLOR.shadow)
414
415  if (g.rollMs > 0) {
416    const r = 0.45 * k
417    const centre = ground - (b + 0.45) * k
418    for (let y = Math.floor(centre - r); y <= Math.ceil(centre + r); y++) {
419      for (let x = Math.floor(mid - r); x <= Math.ceil(mid + r); x++) {
420        const dx = x + 0.5 - mid
421        const dy = y + 0.5 - centre
422        if (dx * dx + dy * dy > r * r * 1.2) continue
423        v.p.dot(x, y, dy < -r * 0.3 ? COLOR.hair : COLOR.hoodie)
424      }
425    }
426    return
427  }
428
429  const isAir = b > 0.05
430  const legs = isAir ? 0.5 : 0.75
431  const swing = isAir ? 0 : Math.sin(g.distance * 2.2) > 0 ? 0.15 : 0
432  block(v, lane - 0.1, 0, 0.2, b + swing, legs - swing, COLOR.pants)
433  block(v, lane + 0.1, 0, 0.2, b + (0.15 - swing), legs - (0.15 - swing), COLOR.pants)
434  block(v, lane - 0.1, 0, 0.22, b + swing, 0.12, COLOR.shoe)
435  block(v, lane + 0.1, 0, 0.22, b + (0.15 - swing), 0.12, COLOR.shoe)
436  block(v, lane, 0, 0.7, b + legs, 0.62, COLOR.hoodie)
437  block(v, lane, 0, 0.5, b + legs + 0.5, 0.14, COLOR.hood)
438  block(v, lane, 0, 0.36, b + legs + 0.62, 0.3, COLOR.hair)
439  block(v, lane, 0, 0.4, b + legs + 0.84, 0.12, COLOR.cap)
440}
441
442const messageFor = (g: Run): Line[] => {
443  switch (g.phase) {
444    case 'title':
445      return [
446        { text: 'RAIL RUNNER', color: UI.accent },
447        { text: '', color: UI.text },
448        { text: 'click here, then w', color: UI.text },
449        { text: 'a d move  w jump  s roll', color: UI.dim },
450      ]
451    case 'paused':
452      return [
453        { text: 'PAUSED', color: UI.accent },
454        { text: 'click here, then w', color: UI.text },
455      ]
456    case 'over':
457      return [
458        { text: 'CRASHED', color: UI.accent },
459        { text: `score ${g.score}  $${g.coins}`, color: UI.text },
460        { text: g.overMs < RESTART_MS ? '' : 'w to run again', color: UI.dim },
461      ]
462    case 'play':
463      return []
464  }
465}
466
467export const frameWords = (g: Run, columns: number, rows: number, best: number): Uint32Array => {
468  const p = new Pixels(columns, rows)
469  const v = makeView(p)
470  drawBackdrop(v, g)
471
472  const ops: Op[] = []
473  const spin = g.distance * 0.35
474  for (const t of g.things) {
475    if (t.z > FAR + 2) continue
476    if (t.kind === 'train') train(v, t, ops)
477    else if (t.z > -CAM + 1.5) ops.push({ z: t.z, draw: () => (t.kind === 'coin' ? coin(v, t, spin) : barrier(v, t)) })
478  }
479  ops.push({ z: -0.01, draw: () => runner(v, g) })
480  ops.sort((a, b) => b.z - a.z)
481  for (const op of ops) op.draw()
482
483  const cells = p.cells()
484  scoreBar(
485    cells,
486    columns,
487    { text: `${g.score}`, color: UI.text },
488    { text: `$${g.coins}`, color: UI.gold },
489    { text: `best ${Math.max(best, g.score)}`, color: UI.dim },
490  )
491  panel(cells, columns, rows, messageFor(g))
492
493  return cells
494}
495
496export const railRunner: Cartridge = {
497  id: 'rail',
498  title: 'Rail Runner',
499  tickMs: 50,
500  controls: [
501    { hotkey: 'a', label: 'left', action: 'left' },
502    { hotkey: 'd', label: 'right', action: 'right' },
503    { hotkey: 'w', label: 'jump', action: 'jump' },
504    { hotkey: 's', label: 'roll', action: 'roll' },
505    { hotkey: 'p', label: 'pause', action: 'pause' },
506  ],
507  start: seed => {
508    let g = newRun(seed)
509
510    return {
511      get phase() {
512        return g.phase
513      },
514      get score() {
515        return g.score
516      },
517      act: action => {
518        g = act(g, action as Action)
519      },
520      step: ms => {
521        const before = g.phase
522        const wasWaiting = before === 'over' && g.overMs < RESTART_MS
523        step(g, ms)
524
525        return before === 'play' || (wasWaiting && g.overMs >= RESTART_MS)
526      },
527      pause: () => pause(g),
528      frame: (columns, rows, best) => frameWords(g, columns, rows, best),
529    }
530  },
531}
532
hooks/games/twenty48.ts 218 lines
1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright (C) 2026 Shabier. Claude Code exception: see NOTICE.
3
4import { nextRandom, type Cartridge, type Phase } from '../cartridge'
5import { panel, Pixels, scoreBar, UI, write } from '../screen'
6
7export type Board = {
8  phase: Phase
9  rng: number
10  // Row-major, 0 empty.
11  tiles: number[]
12  score: number
13  hasWon: boolean
14}
15
16export type Direction = 'up' | 'down' | 'left' | 'right'
17
18const SIZE = 4
19
20// Indices per line, the end a slide packs towards first.
21const linesFor = (direction: Direction): number[][] => {
22  const lines: number[][] = []
23  for (let a = 0; a < SIZE; a++) {
24    const line: number[] = []
25    for (let b = 0; b < SIZE; b++) {
26      switch (direction) {
27        case 'left':
28          line.push(a * SIZE + b)
29          break
30        case 'right':
31          line.push(a * SIZE + (SIZE - 1 - b))
32          break
33        case 'up':
34          line.push(b * SIZE + a)
35          break
36        case 'down':
37          line.push((SIZE - 1 - b) * SIZE + a)
38          break
39      }
40    }
41    lines.push(line)
42  }
43
44  return lines
45}
46
47// One merge per pair: [2,2,2,2] -> [4,4], not [8].
48export const slideLine = (values: readonly number[]): { values: number[]; gained: number } => {
49  const packed = values.filter(v => v !== 0)
50  const out: number[] = []
51  let gained = 0
52  for (let i = 0; i < packed.length; i++) {
53    const v = packed[i] ?? 0
54    if (v === packed[i + 1]) {
55      out.push(v * 2)
56      gained += v * 2
57      i++
58    } else {
59      out.push(v)
60    }
61  }
62  while (out.length < values.length) out.push(0)
63
64  return { values: out, gained }
65}
66
67const spawn = (b: Board) => {
68  const empty = b.tiles.flatMap((v, i) => (v === 0 ? [i] : []))
69  if (empty.length === 0) return
70  const at = empty[Math.floor(nextRandom(b) * empty.length)] ?? 0
71  b.tiles[at] = nextRandom(b) < 0.9 ? 2 : 4
72}
73
74const canMove = (b: Board): boolean =>
75  b.tiles.some((v, i) => {
76    if (v === 0) return true
77    const right = i % SIZE < SIZE - 1 ? b.tiles[i + 1] : undefined
78    const down = b.tiles[i + SIZE]
79
80    return v === right || v === down
81  })
82
83export const newBoard = (seed: number): Board => {
84  const b: Board = { phase: 'play', rng: seed | 0, tiles: new Array(SIZE * SIZE).fill(0), score: 0, hasWon: false }
85  spawn(b)
86  spawn(b)
87
88  return b
89}
90
91export const slide = (b: Board, direction: Direction): boolean => {
92  if (b.phase !== 'play') return false
93
94  let hasMoved = false
95  for (const line of linesFor(direction)) {
96    const before = line.map(i => b.tiles[i] ?? 0)
97    const { values, gained } = slideLine(before)
98    line.forEach((i, k) => {
99      if (b.tiles[i] !== values[k]) hasMoved = true
100      b.tiles[i] = values[k] ?? 0
101    })
102    b.score += gained
103  }
104  if (!hasMoved) return false
105
106  spawn(b)
107  if (b.tiles.some(v => v >= 2048)) b.hasWon = true
108  if (!canMove(b)) b.phase = 'over'
109
110  return true
111}
112
113const TILE: Record<number, { bg: number; fg: number }> = {
114  0: { bg: 0x3a332e, fg: 0x3a332e },
115  2: { bg: 0xeee4da, fg: 0x776e65 },
116  4: { bg: 0xede0c8, fg: 0x776e65 },
117  8: { bg: 0xf2b179, fg: 0xf9f6f2 },
118  16: { bg: 0xf59563, fg: 0xf9f6f2 },
119  32: { bg: 0xf67c5f, fg: 0xf9f6f2 },
120  64: { bg: 0xf65e3b, fg: 0xf9f6f2 },
121  128: { bg: 0xedcf72, fg: 0xf9f6f2 },
122  256: { bg: 0xedcc61, fg: 0xf9f6f2 },
123  512: { bg: 0xedc850, fg: 0xf9f6f2 },
124  1024: { bg: 0xedc53f, fg: 0xf9f6f2 },
125  2048: { bg: 0xedc22e, fg: 0xf9f6f2 },
126}
127const BIG = { bg: 0x3c3a32, fg: 0xf9f6f2 }
128const BACKDROP = 0x1d1916
129const FRAME = 0x2a2420
130
131export const frameWords = (b: Board, columns: number, rows: number, best: number): Uint32Array => {
132  const p = new Pixels(columns, rows)
133  p.fill(0, p.w, 0, p.h, BACKDROP)
134
135  // Cells are 1:2, so a square tile is 2 columns per row.
136  const byWidth = Math.floor((columns - 5) / SIZE)
137  const byHeight = Math.floor((rows - 3 - 5) / SIZE) * 2
138  const tileCols = Math.max(4, Math.min(14, byWidth, byHeight))
139  const tileRows = Math.max(2, Math.round(tileCols / 2))
140  const boardCols = SIZE * tileCols + SIZE + 1
141  const boardRows = SIZE * tileRows + SIZE + 1
142  const left = Math.max(0, Math.floor((columns - boardCols) / 2))
143  const top = Math.max(2, Math.floor((rows - boardRows) / 2))
144
145  p.fill(left, left + boardCols, top * 2, (top + boardRows) * 2, FRAME)
146  const spots: { row: number; col: number; value: number }[] = []
147  b.tiles.forEach((value, i) => {
148    const col = left + 1 + (i % SIZE) * (tileCols + 1)
149    const row = top + 1 + Math.floor(i / SIZE) * (tileRows + 1)
150    p.fill(col, col + tileCols, row * 2, (row + tileRows) * 2, (TILE[value] ?? BIG).bg)
151    spots.push({ row, col, value })
152  })
153
154  const cells = p.cells()
155  for (const { row, col, value } of spots) {
156    if (value === 0) continue
157    const colors = TILE[value] ?? BIG
158    const text = String(value).slice(0, tileCols)
159    write(cells, columns, row + Math.floor((tileRows - 1) / 2), col + Math.floor((tileCols - text.length) / 2), text, colors.fg, colors.bg)
160  }
161
162  scoreBar(
163    cells,
164    columns,
165    { text: `${b.score}`, color: UI.text },
166    b.hasWon ? { text: '2048!', color: UI.gold } : null,
167    { text: `best ${Math.max(best, b.score)}`, color: UI.dim },
168  )
169  if (b.phase === 'over') {
170    panel(cells, columns, rows, [
171      { text: 'NO MOVES LEFT', color: UI.accent },
172      { text: `score ${b.score}`, color: UI.text },
173      { text: 'r for a new board', color: UI.dim },
174    ])
175  }
176
177  return cells
178}
179
180const DIRECTIONS: Record<string, Direction> = { up: 'up', down: 'down', left: 'left', right: 'right' }
181
182export const twenty48: Cartridge = {
183  id: '2048',
184  title: '2048',
185  tickMs: 0,
186  controls: [
187    { hotkey: 'w', label: 'up', action: 'up' },
188    { hotkey: 'a', label: 'left', action: 'left' },
189    { hotkey: 's', label: 'down', action: 'down' },
190    { hotkey: 'd', label: 'right', action: 'right' },
191    { hotkey: 'r', label: 'new', action: 'new' },
192  ],
193  start: seed => {
194    let b = newBoard(seed)
195
196    return {
197      get phase() {
198        return b.phase
199      },
200      get score() {
201        return b.score
202      },
203      act: action => {
204        if (action === 'new') {
205          b = newBoard(Math.floor(nextRandom(b) * 2 ** 31))
206          return
207        }
208        const direction = DIRECTIONS[action]
209        if (direction !== undefined) slide(b, direction)
210      },
211      step: () => false,
212      // Turn based. Nothing to pause.
213      pause: () => {},
214      frame: (columns, rows, best) => frameWords(b, columns, rows, best),
215    }
216  },
217}
218
hooks/menu.ts 44 lines
1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright (C) 2026 Shabier. Claude Code exception: see NOTICE.
3
4import type { Cartridge } from './cartridge'
5import { BAR_PX, hash, mix, Pixels, scoreBar, UI, write } from './screen'
6
7const SKY_TOP = 0x0c0a12
8const SKY_LOW = 0x3b1e2a
9const STAR = 0x8a7f9a
10
11export const menuWords = (
12  carts: readonly Cartridge[],
13  best: Readonly<Record<string, number>>,
14  columns: number,
15  rows: number,
16): Uint32Array => {
17  const p = new Pixels(columns, rows)
18  for (let y = BAR_PX; y < p.h; y++) {
19    const sky = mix(SKY_TOP, SKY_LOW, (y - BAR_PX) / p.h)
20    for (let x = 0; x < p.w; x++) {
21      p.px[y * p.w + x] = hash(x * 7919 + y * 104729) < 0.015 ? STAR : sky
22    }
23  }
24
25  const cells = p.cells()
26  scoreBar(cells, columns, { text: 'ARCADE', color: UI.accent }, null, { text: 'pick a number', color: UI.dim })
27
28  const width = Math.min(columns - 2, 30)
29  const left = Math.max(1, Math.floor((columns - width) / 2))
30  const first = Math.max(3, Math.floor(rows * 0.25))
31  carts.forEach((cart, i) => {
32    const row = first + i * 2
33    const score = best[cart.id]
34    const right = score === undefined ? '' : `${score}`
35    write(cells, columns, row, left, ' '.repeat(width), UI.text, UI.bar)
36    write(cells, columns, row, left + 1, `${i + 1}`, UI.gold, UI.bar)
37    write(cells, columns, row, left + 4, cart.title, UI.text, UI.bar)
38    write(cells, columns, row, left + width - right.length - 1, right, UI.dim, UI.bar)
39  })
40  write(cells, columns, first + carts.length * 2 + 1, left, 'click here, then a number'.slice(0, width), UI.dim, SKY_LOW)
41
42  return cells
43}
44
hooks/screen.ts 167 lines
1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright (C) 2026 Shabier. Claude Code exception: see NOTICE.
3
4// Half-block renderer: U+2580 per cell, fg = top pixel, bg = bottom.
5// Text overlays whole cells. Colours are 0xRRGGBB.
6
7const UPPER_HALF = 0x2580
8
9// Row 0 is the score bar. Pixels start at y = 2.
10export const BAR_PX = 2
11
12export const UI = {
13  bar: 0x15110f,
14  text: 0xf3e6d8,
15  dim: 0x9c8b7d,
16  accent: 0xff7a3d,
17  gold: 0xffcf3f,
18} as const
19
20export const clamp01 = (n: number): number => Math.max(0, Math.min(1, n))
21export const frac = (n: number): number => n - Math.floor(n)
22
23export const mix = (a: number, b: number, t: number): number => {
24  const k = clamp01(t)
25  const channel = (shift: number) => {
26    const x = (a >> shift) & 0xff
27    const y = (b >> shift) & 0xff
28
29    return Math.round(x + (y - x) * k) << shift
30  }
31
32  return channel(16) | channel(8) | channel(0)
33}
34
35export const shade = (c: number, f: number): number => mix(0, c, f)
36
37export const hash = (n: number): number => {
38  let t = Math.imul(n ^ 0x9e3779b9, 0x85ebca6b)
39  t ^= t >>> 13
40  t = Math.imul(t, 0xc2b2ae35)
41  t ^= t >>> 16
42
43  return (t >>> 0) / 4294967296
44}
45
46export class Pixels {
47  readonly w: number
48  readonly h: number
49  readonly px: Uint32Array
50
51  constructor(
52    readonly columns: number,
53    readonly rows: number,
54  ) {
55    this.w = columns
56    this.h = rows * 2
57    this.px = new Uint32Array(this.w * this.h)
58  }
59
60  // Min 1px each way. Far objects shrink below a pixel and would vanish.
61  fill(x0: number, x1: number, y0: number, y1: number, color: number): void {
62    const left = Math.max(0, Math.round(x0))
63    const right = Math.min(this.w, Math.max(left + 1, Math.round(x1)))
64    const top = Math.max(BAR_PX, Math.round(y0))
65    const bottom = Math.min(this.h, Math.max(top + 1, Math.round(y1)))
66    for (let y = top; y < bottom; y++) {
67      this.px.fill(color, y * this.w + left, y * this.w + right)
68    }
69  }
70
71  dot(x: number, y: number, color: number): void {
72    const px = Math.floor(x)
73    const py = Math.floor(y)
74    if (px < 0 || px >= this.w || py < BAR_PX || py >= this.h) return
75    this.px[py * this.w + px] = color
76  }
77
78  cells(): Uint32Array {
79    const out = new Uint32Array(this.columns * this.rows * 3)
80    for (let r = 0; r < this.rows; r++) {
81      for (let x = 0; x < this.columns; x++) {
82        const at = (r * this.columns + x) * 3
83        out[at] = UPPER_HALF
84        out[at + 1] = this.px[2 * r * this.w + x] ?? 0
85        out[at + 2] = this.px[(2 * r + 1) * this.w + x] ?? 0
86      }
87    }
88
89    return out
90  }
91}
92
93// ASCII only. Raster refuses anything not width 1, and ambiguous-width
94// glyphs differ per terminal.
95export const write = (
96  cells: Uint32Array,
97  columns: number,
98  row: number,
99  col: number,
100  text: string,
101  fg: number,
102  bg: number,
103): void => {
104  if (row < 0 || row * columns * 3 >= cells.length) return
105  for (let i = 0; i < text.length; i++) {
106    const x = col + i
107    if (x < 0 || x >= columns) continue
108    const at = (row * columns + x) * 3
109    cells[at] = text.charCodeAt(i)
110    cells[at + 1] = fg
111    cells[at + 2] = bg
112  }
113}
114
115export type Line = { text: string; color: number }
116
117// Narrow bar: the middle drops first, then the right.
118export const scoreBar = (cells: Uint32Array, columns: number, left: Line, middle: Line | null, right: Line): void => {
119  write(cells, columns, 0, 0, ' '.repeat(columns), UI.text, UI.bar)
120  const l = ` ${left.text}`
121  const r = `${right.text} `
122  write(cells, columns, 0, 0, l, left.color, UI.bar)
123  if (middle !== null && columns >= l.length + middle.text.length + r.length + 2) {
124    write(cells, columns, 0, Math.floor((columns - middle.text.length) / 2), middle.text, middle.color, UI.bar)
125  }
126  if (columns >= l.length + r.length + 1) write(cells, columns, 0, columns - r.length, r, right.color, UI.bar)
127}
128
129// `at`: panel top as a fraction of screen height.
130export const panel = (cells: Uint32Array, columns: number, rows: number, lines: readonly Line[], at = 0.3): void => {
131  if (lines.length === 0) return
132  const width = Math.min(columns, Math.max(...lines.map(line => line.text.length)) + 4)
133  const left = Math.floor((columns - width) / 2)
134  const first = Math.max(2, Math.floor(rows * at))
135  for (let i = -1; i <= lines.length; i++) {
136    write(cells, columns, first + i, left, ' '.repeat(width), UI.text, UI.bar)
137  }
138  lines.forEach((line, i) => {
139    const text = line.text.slice(0, columns)
140    write(cells, columns, first + i, Math.floor((columns - text.length) / 2), text, line.color, UI.bar)
141  })
142}
143
144const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
145
146// Native toBase64 in the mod sandbox (2.1.287). Manual path is for Node 20
147// previews.
148export const encode = (words: Uint32Array): string => {
149  const bytes = new Uint8Array(words.buffer, words.byteOffset, words.byteLength)
150  const native = (bytes as unknown as { toBase64?: () => string }).toBase64
151  if (typeof native === 'function') return native.call(bytes)
152
153  const out: string[] = []
154  let i = 0
155  for (; i + 2 < bytes.length; i += 3) {
156    const n = ((bytes[i] ?? 0) << 16) | ((bytes[i + 1] ?? 0) << 8) | (bytes[i + 2] ?? 0)
157    out.push(B64.charAt((n >> 18) & 63), B64.charAt((n >> 12) & 63), B64.charAt((n >> 6) & 63), B64.charAt(n & 63))
158  }
159  const rest = bytes.length - i
160  if (rest > 0) {
161    const n = ((bytes[i] ?? 0) << 16) | (rest > 1 ? (bytes[i + 1] ?? 0) << 8 : 0)
162    out.push(B64.charAt((n >> 18) & 63), B64.charAt((n >> 12) & 63), rest > 1 ? B64.charAt((n >> 6) & 63) : '=', '=')
163  }
164
165  return out.join('')
166}
167
types/index.d.ts 12 lines
1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright (C) 2026 Shabier. Claude Code exception: see NOTICE.
3
4// Cartridge id of the shown game. null = menu.
5export type ArcadePick = string | null
6
7declare module 'claude-code' {
8  interface PluginState {
9    arcade: { pick: ArcadePick }
10  }
11}
12