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

Five small games in the side pane. /arcade opens the list.
| Key | Game | Controls |
|---|---|---|
| 1 | Rail Runner | a d lanes, w jump, s roll |
| 2 | Road Hopper | w hop, a d sideways, s back |
| 3 | Block Drop | a d move, w turn, s down, x drop |
| 4 | Flap | w flap |
| 5 | 2048 | w 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.
| Command | Does | |
|---|---|---|
/arcade | open 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.
hooks/register.tsx 279 lines1// 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}
279hooks/cartridge.ts 43 lines1// 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}
43hooks/games/blocks.ts 345 lines1// 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}
345hooks/games/flap.ts 273 lines1// 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}
273hooks/games/hopper.ts 554 lines1// 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}
554hooks/games/rail.ts 532 lines1// 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}
532hooks/games/twenty48.ts 218 lines1// 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}
218hooks/menu.ts 44 lines1// 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}
44hooks/screen.ts 167 lines1// 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}
167types/index.d.ts 12 lines1// 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