A top-down driving game above the prompt. Task coins rain in as Claude works. /roadtrip to play.

hooks/register.tsx 7 lines1import type { Register } from 'claude-code'
2import { registerRoadTrip } from './road-trip'
3
4export const register: Register = on => {
5 registerRoadTrip(on)
6}
7hooks/road-trip.tsx 253 lines1import type { On } from 'claude-code'
2import {
3 createGame, resizeGame, tickGame, renderFrame, handleKey, garageBuy,
4 triggerArrived, resumeFromArrived, parseProfile,
5 type GameState, type Profile,
6} from './road-trip-game'
7
8const PANE = 'road-trip'
9
10// Module-level game state (reset each session start)
11let game: GameState | null = null
12let rasterCols = 0
13let rasterRows = 0
14let gameLoopCancel: (() => void) | undefined
15let disabled = false
16let globalRetro = false
17
18// ─── Helpers ──────────────────────────────────────────────────────────────────
19
20async function loadProfile($: Parameters<Parameters<On>[2]>[0]): Promise<Profile> {
21 try {
22 const v = await $.store.get({ key: 'road-trip-profile' })
23 return parseProfile(v?.value)
24 } catch { return parseProfile(null) }
25}
26
27async function saveProfile($: Parameters<Parameters<On>[2]>[0], g: GameState): Promise<void> {
28 try {
29 await $.store.set({
30 key: 'road-trip-profile',
31 value: {
32 bank: g.profile.bank,
33 ownedCars: g.profile.ownedCars,
34 currentCar: g.profile.currentCar,
35 xp: g.profile.xp,
36 bestDistance: g.profile.bestDistance,
37 },
38 })
39 } catch { /* save failure must never stop the game */ }
40}
41
42// ─── Register ─────────────────────────────────────────────────────────────────
43
44export function registerRoadTrip(on: On): void {
45
46 // ── Session start ──────────────────────────────────────────────────────────
47 on('session.start', async ($, e, next) => {
48 game = null
49 rasterCols = 0
50 rasterRows = 0
51 gameLoopCancel?.()
52 gameLoopCancel = undefined
53
54 // Restore disabled/retro from store
55 try {
56 const v = await $.store.get({ key: 'road-trip-meta' })
57 if (v?.value && typeof v.value === 'object') {
58 const m = v.value as Record<string, unknown>
59 if (m['disabled'] === true) disabled = true
60 if (m['retro'] === true) globalRetro = true
61 }
62 } catch {}
63
64 await $.command.register({ name: 'roadtrip', description: 'Open Road Trip game. /roadtrip on|off|retro on|off' })
65
66 return next(e)
67 })
68
69 // ── Command ────────────────────────────────────────────────────────────────
70 on('command.run', { command: 'roadtrip' }, async ($, e) => {
71 const args = (e.args ?? '').trim().toLowerCase()
72
73 if (args === 'off') {
74 disabled = true
75 try { await $.store.set({ key: 'road-trip-meta', value: { disabled: true, retro: globalRetro } }) } catch {}
76 gameLoopCancel?.()
77 gameLoopCancel = undefined
78 return { text: 'Road Trip hidden. /roadtrip on to re-enable.' }
79 }
80 if (args === 'on') {
81 disabled = false
82 try { await $.store.set({ key: 'road-trip-meta', value: { disabled: false, retro: globalRetro } }) } catch {}
83 return { text: 'Road Trip enabled. Type /roadtrip to open.' }
84 }
85 if (args === 'retro on') {
86 globalRetro = true
87 if (game) { game.retro = true }
88 try { await $.store.set({ key: 'road-trip-meta', value: { disabled, retro: true } }) } catch {}
89 return { text: 'Retro mode on.' }
90 }
91 if (args === 'retro off') {
92 globalRetro = false
93 if (game) { game.retro = false }
94 try { await $.store.set({ key: 'road-trip-meta', value: { disabled, retro: false } }) } catch {}
95 return { text: 'Retro mode off.' }
96 }
97
98 if (disabled) {
99 return { text: 'Road Trip is off. Type /roadtrip on to re-enable.' }
100 }
101
102 // Open pane (responds instantly — answer before opening)
103 const openResult = await $.ui.open({ id: PANE, title: 'Road Trip', focus: true, closeOnEscape: true, rows: 30, columns: 110 })
104 if (!openResult.isPlaced) {
105 $.ui.toast('Road Trip needs a little more room: widen the window')
106 return { text: 'Pane not placed (window too small).' }
107 }
108
109 // Start game loop if not running
110 if (!gameLoopCancel) {
111 gameLoopCancel = $.clock.every(70, () => {
112 if (!game || !rasterCols) return
113 tickGame(game)
114 void $.ui.blit({ requestId: PANE, key: 'game', cells: renderFrame(game) })
115 })
116 }
117
118 return { text: 'Road Trip opened. Controls: A/D lanes P pause G garage' }
119 })
120
121 // ── Pane render ────────────────────────────────────────────────────────────
122 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
123 if (e.surface !== 'terminal') {
124 const { Text } = $.ui.resolve(e)
125 return <Text>Road Trip plays in the terminal.</Text>
126 }
127
128 const cols = e.props.bodyColumns
129 const rows = e.props.scroll.bodyRows
130
131 if (cols < 36 || rows < 5) {
132 const { Text } = $.ui.resolve(e)
133 return <Text>Drag this pane taller to play (cols={cols} rows={rows})</Text>
134 }
135
136 const newRC = cols
137 const newRR = Math.max(4, rows - 1) // leave 1 row for buttons
138
139 let needInit = !game
140 if (newRC !== rasterCols || newRR !== rasterRows) {
141 rasterCols = newRC
142 rasterRows = newRR
143 needInit = needInit || true
144 }
145
146 if (needInit && !game) {
147 const profile = await loadProfile($)
148 game = createGame(rasterCols, rasterRows, profile, globalRetro)
149 } else if (game && (newRC !== game.cols || newRR !== game.rows)) {
150 game = resizeGame(game, newRC, newRR)
151 rasterCols = newRC
152 rasterRows = newRR
153 }
154
155 if (!game) {
156 const { Text } = $.ui.resolve(e)
157 return <Text>Loading...</Text>
158 }
159
160 const { Box, Button } = $.ui.resolve(e)
161 const elements = $.ui.resolve(e) as unknown as { Raster: (p: { key: string; columns: number; rows: number; cells: string }) => unknown }
162 const g = game
163
164 const onKeyA = (): void => handleKey(g, 'a')
165 const onKeyD = (): void => handleKey(g, 'd')
166 const onKeyP = (): void => handleKey(g, 'p')
167 const onKeyG = (): void => handleKey(g, 'g')
168 const onKeyB = (): void => {
169 if (g.phase === 'arrived') void $.ui.close({ id: PANE })
170 else garageBuy(g)
171 }
172 return (
173 <Box flexDirection="column">
174 {h(elements.Raster, { key: 'game', columns: rasterCols, rows: rasterRows, cells: renderFrame(g) })}
175 <Box flexDirection="row" gap={2}>
176 <Button key="btn-a" label="A" hotkey="a" onPress={onKeyA} />
177 <Button key="btn-d" label="D" hotkey="d" onPress={onKeyD} />
178 <Button key="btn-p" label="P" hotkey="p" onPress={onKeyP} />
179 <Button key="btn-g" label="G" hotkey="g" onPress={onKeyG} />
180 <Button key="btn-b" label="B" hotkey="b" onPress={onKeyB} />
181 </Box>
182 </Box>
183 )
184 })
185
186 // ── Pane close ────────────────────────────────────────────────────────────
187 on('ui.close', { id: PANE }, async ($, e, next) => {
188 gameLoopCancel?.()
189 gameLoopCancel = undefined
190 if (game) await saveProfile($, game)
191 return next(e)
192 })
193
194 // ── Turn complete: show Arrived card ──────────────────────────────────────
195 on('turn.complete', async ($, e, next) => {
196 // Only main agent (not subagents)
197 if ((e as unknown as Record<string, unknown>)['agentId'] !== undefined) return next(e)
198 if (game && (game.phase === 'driving' || game.phase === 'countdown')) {
199 triggerArrived(game)
200 // Trigger a blit so the Arrived card is shown immediately
201 if (rasterCols) void $.ui.blit({ requestId: PANE, key: 'game', cells: renderFrame(game) })
202 }
203 return next(e)
204 })
205
206 // ── Turn start: resume driving ────────────────────────────────────────────
207 on('turn.start', async ($, e, next) => {
208 if (game && game.phase === 'arrived') {
209 resumeFromArrived(game)
210 }
211 return next(e)
212 })
213
214 // ── Task coins from completed Clean View steps ────────────────────────────
215 on('tool.call', { tool: 'mcp__clean-view__report_progress' as 'mcp__clean-view__report_progress' }, async ($, e, next) => {
216 const result = await next(e)
217 const pct = (e as unknown as Record<string, unknown>)['percent']
218 if (pct === 100 && game && game.phase === 'driving') {
219 game.taskCoinQueue = Math.min(3, game.taskCoinQueue + 1)
220 }
221 return result
222 })
223
224 // ── Task coins from TodoWrite (fallback when Clean View not installed) ─────
225 on('tool.call', { tool: 'TodoWrite' }, async ($, e, next) => {
226 const todos: unknown[] = (e as unknown as Record<string, unknown[]>)['todos'] ?? []
227 const beforeCompleted = game?.seenCompleted ?? 0
228 const result = await next(e)
229 if (!result.isError && game) {
230 const nowCompleted = todos.filter((t: unknown) => {
231 const todo = t as Record<string, unknown>
232 return todo['status'] === 'completed'
233 }).length
234 if (nowCompleted > beforeCompleted) {
235 const delta = Math.min(3, nowCompleted - beforeCompleted)
236 game.taskCoinQueue = Math.min(3, game.taskCoinQueue + delta)
237 game.seenCompleted = nowCompleted
238 }
239 }
240 return result
241 })
242
243 // ── Task coins from TaskUpdate ────────────────────────────────────────────
244 on('tool.call', { tool: 'TaskUpdate' }, async ($, e, next) => {
245 const result = await next(e)
246 const status = (e as unknown as Record<string, unknown>)['status']
247 if (!result.isError && status === 'completed' && game && game.phase === 'driving') {
248 game.taskCoinQueue = Math.min(3, game.taskCoinQueue + 1)
249 }
250 return result
251 })
252}
253hooks/road-trip-game.ts 741 lines1// Pure game logic + Raster renderer. No $ references.
2
3import { CARS, QUIZ, LEVEL_XP, levelFromXp, xpProgress, type CarDef, type Perk, type QuizQ } from './road-trip-art'
4
5// ─── Colors ───────────────────────────────────────────────────────────────────
6
7const T = 0x01000000 // terminal default
8
9const D = {
10 road: 0x1C1B24, grass: 0x0F2218, edge: 0x6E7CF6, lane: 0x2E2D3A,
11 hud: 0xFFF6E8, car: 0x6E7CF6, carBody: 0x9AA5FF, coin: 0xFFD700,
12 task: 0xFF3D97, obs: 0xFF5533, obsStripe: 0xCC2200,
13 nitro: 0x00FFFF, right: 0x44FF88, wrong: 0xFF3D97,
14 tree: 0x2A5C38, dot: 0x334433, overlay: 0x1A1928,
15 gateA: 0x3A3060, gateB: 0x603030, gateC: 0x305030,
16 shield: 0xFFDD00, quizHdr: 0xFF8C00, dimRoad: 0x252430,
17}
18const R = {
19 road: 0x000000, grass: 0x001400, edge: 0xFF00FF, lane: 0x006666,
20 hud: 0x00FF00, car: 0x00FFFF, carBody: 0x00DDDD, coin: 0xFFAA00,
21 task: 0xFF00FF, obs: 0xFF2200, obsStripe: 0xAA0000,
22 nitro: 0xFF00FF, right: 0x00FF00, wrong: 0xFF0000,
23 tree: 0x004400, dot: 0x002200, overlay: 0x0A0A0A,
24 gateA: 0x220066, gateB: 0x660022, gateC: 0x006622,
25 shield: 0xFFCC00, quizHdr: 0xFF6600, dimRoad: 0x0A0A0A,
26}
27
28// ─── Base64 ───────────────────────────────────────────────────────────────────
29
30const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
31
32function encodeB64(bytes: Uint8Array): string {
33 const len = bytes.length
34 const out: string[] = new Array(Math.ceil(len / 3) * 4)
35 let o = 0
36 for (let i = 0; i < len; i += 3) {
37 const b0 = bytes[i]!, b1 = bytes[i + 1] ?? 0, b2 = bytes[i + 2] ?? 0
38 out[o++] = B64[b0 >> 2]!
39 out[o++] = B64[((b0 & 3) << 4) | (b1 >> 4)]!
40 out[o++] = B64[((b1 & 0xf) << 2) | (b2 >> 6)]!
41 out[o++] = B64[b2 & 0x3f]!
42 }
43 const rem = len % 3
44 if (rem === 1) { out[o - 2] = '='; out[o - 1] = '=' }
45 else if (rem === 2) { out[o - 1] = '=' }
46 return out.join('')
47}
48
49// ─── Types ────────────────────────────────────────────────────────────────────
50
51export type Phase = 'countdown' | 'driving' | 'paused' | 'flattire' | 'arrived' | 'garage'
52export type ObjKind = 'coin' | 'taskcoin' | 'barrier' | 'cone' | 'pothole' | 'gate'
53
54export interface RoadObj {
55 id: number
56 kind: ObjKind
57 row: number // current screen row in road area (2 = just below HUD)
58 lane: 0 | 1 | 2 // for gate: the "correct" lane; use lanes[] for barriers
59 lanes?: (0 | 1 | 2)[] // barrier: which lanes are blocked
60 quizIdx?: number // gate only
61 collected: boolean
62}
63
64export interface Profile {
65 bank: number
66 ownedCars: string[]
67 currentCar: string
68 xp: number
69 bestDistance: number
70}
71
72export interface GameState {
73 // Layout
74 cols: number
75 rows: number // total Raster rows
76 laneW: number // lane width in columns
77 roadL: number // column of left road edge (▌)
78 // Profile
79 profile: Profile
80 // Per-run
81 phase: Phase
82 tick: number
83 countdown: number
84 cdTimer: number
85 carX: number
86 carTargetLane: 0 | 1 | 2
87 carLane: 0 | 1 | 2
88 speed: number
89 scrollAcc: number
90 scrollPhase: number // 0..7, drives lane-dash scroll
91 distance: number
92 objects: RoadObj[]
93 nextId: number
94 spawnAcc: number
95 spawnNext: number
96 coinsThisRun: number
97 quizRightThisRun: number
98 nitroTicks: number
99 shieldActive: boolean
100 flatTireTimer: number
101 shakeTicks: number
102 seenCompleted: number
103 taskCoinQueue: number
104 lastQuizTick: number
105 nextQuizTicks: number
106 activeQuiz: QuizQ | null
107 quizAnswered: boolean
108 garagePage: number
109 retro: boolean
110 arrivedData: { distance: number; coins: number; quizRight: number } | null
111}
112
113// ─── Layout helpers ──────────────────────────────────────────────────────────
114
115function calcLayout(cols: number): { laneW: number; roadL: number } {
116 const laneW = Math.max(5, Math.floor((cols - 16) / 3))
117 const roadW = 3 * laneW + 4 // 2 edges + 2 dividers
118 const roadL = Math.max(2, Math.floor((cols - roadW) / 2))
119 return { laneW, roadL }
120}
121
122export function laneCenter(g: Pick<GameState, 'roadL' | 'laneW'>, lane: 0 | 1 | 2): number {
123 return g.roadL + 1 + lane * (g.laneW + 1) + Math.floor(g.laneW / 2)
124}
125
126function roadRight(g: Pick<GameState, 'roadL' | 'laneW'>): number {
127 return g.roadL + 1 + 3 * g.laneW + 2 // after lane 2, the ▐ edge
128}
129
130// ─── Profile validation ───────────────────────────────────────────────────────
131
132export function parseProfile(raw: unknown): Profile {
133 const def: Profile = { bank: 0, ownedCars: ['hatch'], currentCar: 'hatch', xp: 0, bestDistance: 0 }
134 if (!raw || typeof raw !== 'object') return def
135 const r = raw as Record<string, unknown>
136 const validIds = CARS.map(c => c.id)
137 if (typeof r['bank'] === 'number' && r['bank'] >= 0) def.bank = Math.floor(r['bank'])
138 if (Array.isArray(r['ownedCars'])) {
139 def.ownedCars = r['ownedCars'].filter((x: unknown) => typeof x === 'string' && validIds.includes(x)) as string[]
140 if (!def.ownedCars.includes('hatch')) def.ownedCars.unshift('hatch')
141 }
142 if (typeof r['currentCar'] === 'string' && def.ownedCars.includes(r['currentCar'])) {
143 def.currentCar = r['currentCar']
144 }
145 if (typeof r['xp'] === 'number' && r['xp'] >= 0) def.xp = Math.floor(r['xp'])
146 if (typeof r['bestDistance'] === 'number' && r['bestDistance'] >= 0) def.bestDistance = Math.floor(r['bestDistance'])
147 return def
148}
149
150// ─── Game creation ────────────────────────────────────────────────────────────
151
152export function createGame(cols: number, rows: number, profile: Profile, retro: boolean): GameState {
153 const { laneW, roadL } = calcLayout(cols)
154 const car = CARS.find(c => c.id === profile.currentCar) ?? CARS[0]!
155 return {
156 cols, rows, laneW, roadL,
157 profile: { ...profile },
158 phase: 'countdown',
159 tick: 0,
160 countdown: 3,
161 cdTimer: 0,
162 carX: laneCenter({ laneW, roadL }, 1),
163 carTargetLane: 1,
164 carLane: 1,
165 speed: car.perk === 'all' ? 0.8 : 0.5,
166 scrollAcc: 0,
167 scrollPhase: 0,
168 distance: 0,
169 objects: [],
170 nextId: 1,
171 spawnAcc: 0,
172 spawnNext: 20,
173 coinsThisRun: 0,
174 quizRightThisRun: 0,
175 nitroTicks: car.perk === 'all' ? 90 : 0,
176 shieldActive: car.perk === 'shield' || car.perk === 'all',
177 flatTireTimer: 0,
178 shakeTicks: 0,
179 seenCompleted: 0,
180 taskCoinQueue: 0,
181 lastQuizTick: 0,
182 nextQuizTicks: pickQuizInterval(),
183 activeQuiz: null,
184 quizAnswered: false,
185 garagePage: CARS.findIndex(c => c.id === profile.currentCar),
186 retro,
187 arrivedData: null,
188 }
189}
190
191export function resizeGame(g: GameState, cols: number, rows: number): GameState {
192 const { laneW, roadL } = calcLayout(cols)
193 const newG = { ...g, cols, rows, laneW, roadL }
194 // Reposition car to same lane
195 newG.carX = laneCenter(newG, newG.carLane)
196 // Reposition objects horizontally (they're row-based anyway, no x stored)
197 return newG
198}
199
200function pickQuizInterval(): number {
201 // 25–30 seconds at 70ms/tick = 357–428 ticks
202 return 357 + Math.floor(Math.random() * 72)
203}
204
205// ─── Input ────────────────────────────────────────────────────────────────────
206
207export function handleKey(g: GameState, key: 'a' | 'd' | 'p' | 'g' | 'b'): void {
208 switch (g.phase) {
209 case 'countdown':
210 case 'driving':
211 if (key === 'a') { const t = Math.max(0, g.carTargetLane - 1) as 0|1|2; g.carTargetLane = t }
212 else if (key === 'd') { const t = Math.min(2, g.carTargetLane + 1) as 0|1|2; g.carTargetLane = t }
213 else if (key === 'p') g.phase = 'paused'
214 else if (key === 'g') { g.phase = 'garage'; g.garagePage = CARS.findIndex(c => c.id === g.profile.currentCar) }
215 break
216 case 'paused':
217 if (key === 'p' || key === 'g') g.phase = 'driving'
218 else if (key === 'a') { const t = Math.max(0, g.carTargetLane - 1) as 0|1|2; g.carTargetLane = t }
219 else if (key === 'd') { const t = Math.min(2, g.carTargetLane + 1) as 0|1|2; g.carTargetLane = t }
220 break
221 case 'garage':
222 if (key === 'a') g.garagePage = Math.max(0, g.garagePage - 1)
223 else if (key === 'd') g.garagePage = Math.min(CARS.length - 1, g.garagePage + 1)
224 else if (key === 'b') garageBuy(g)
225 else if (key === 'p' || key === 'g') g.phase = g.arrivedData ? 'arrived' : 'driving'
226 break
227 case 'arrived':
228 if (key === 'a') startNewRun(g) // Keep Driving
229 else if (key === 'g') { g.phase = 'garage'; g.garagePage = CARS.findIndex(c => c.id === g.profile.currentCar) }
230 // 'b' = Close (handled by caller via $.ui.close)
231 break
232 case 'flattire':
233 break
234 }
235}
236
237export function garageBuy(g: GameState): 'ok' | 'locked' | 'poor' | 'driving' {
238 const car = CARS[g.garagePage]
239 if (!car) return 'poor'
240 const level = levelFromXp(g.profile.xp)
241 if (level < car.levelReq) return 'locked'
242 if (g.profile.currentCar === car.id && g.profile.ownedCars.includes(car.id)) return 'driving'
243 if (!g.profile.ownedCars.includes(car.id)) {
244 if (g.profile.bank < car.price) return 'poor'
245 g.profile.bank -= car.price
246 g.profile.ownedCars = [...g.profile.ownedCars, car.id]
247 }
248 g.profile.currentCar = car.id
249 if (g.arrivedData) {
250 g.phase = 'countdown'
251 g.arrivedData = null
252 startNewRun(g)
253 } else {
254 startNewRun(g)
255 }
256 return 'ok'
257}
258
259function startNewRun(g: GameState): void {
260 const { laneW, roadL } = calcLayout(g.cols)
261 Object.assign(g, {
262 phase: 'countdown', tick: 0, countdown: 3, cdTimer: 0,
263 laneW, roadL,
264 carX: laneCenter({ laneW, roadL }, 1), carTargetLane: 1, carLane: 1,
265 speed: 0.5, scrollAcc: 0, scrollPhase: 0, distance: 0,
266 objects: [], nextId: g.nextId, spawnAcc: 0, spawnNext: 20,
267 coinsThisRun: 0, quizRightThisRun: 0,
268 flatTireTimer: 0, shakeTicks: 0, arrivedData: null,
269 })
270 const car = CARS.find(c => c.id === g.profile.currentCar) ?? CARS[0]!
271 if (car.perk === 'all') { g.speed = 0.8; g.nitroTicks = 90 } else { g.nitroTicks = 0 }
272 g.shieldActive = car.perk === 'shield' || car.perk === 'all'
273 g.lastQuizTick = 0
274 g.nextQuizTicks = pickQuizInterval()
275 g.activeQuiz = null
276 g.quizAnswered = false
277}
278
279// ─── Game tick ────────────────────────────────────────────────────────────────
280
281export function tickGame(g: GameState): void {
282 if (g.phase === 'paused' || g.phase === 'arrived' || g.phase === 'garage') return
283
284 if (g.phase === 'countdown') {
285 g.cdTimer++
286 if (g.cdTimer >= 18) { // ~1.3s per count
287 g.cdTimer = 0
288 g.countdown--
289 if (g.countdown <= 0) g.phase = 'driving'
290 }
291 return
292 }
293
294 if (g.phase === 'flattire') {
295 g.flatTireTimer--
296 if (g.flatTireTimer <= 0) startNewRun(g)
297 return
298 }
299
300 // Driving
301 g.tick++
302
303 // Smooth car movement (ease toward target lane center)
304 const targetX = laneCenter(g, g.carTargetLane)
305 const dx = targetX - g.carX
306 g.carX += dx * 0.18
307 if (Math.abs(dx) < 0.6) { g.carX = targetX; g.carLane = g.carTargetLane }
308
309 // Shake decay
310 if (g.shakeTicks > 0) g.shakeTicks--
311
312 // Nitro decay
313 if (g.nitroTicks > 0) g.nitroTicks--
314
315 // Speed ramp-up (max ~2.5 rows/tick)
316 const baseSpeed = 0.5 + g.distance / 20000
317 g.speed = Math.min(2.5, (g.nitroTicks > 0 ? 2.5 : baseSpeed))
318
319 // Scroll
320 g.scrollAcc += g.speed
321 const scrolled = Math.floor(g.scrollAcc)
322 g.scrollAcc -= scrolled
323 g.scrollPhase = (g.scrollPhase + g.speed) % 8
324
325 // Update distance
326 g.distance += g.speed * 1.5 // meters (approx)
327
328 // XP from distance (1 XP per ~50 distance)
329 if (Math.floor(g.distance / 50) > Math.floor((g.distance - g.speed * 1.5) / 50)) {
330 g.profile.xp += 1
331 }
332
333 // Move objects down the screen (they scroll toward car, which is at bottom)
334 for (const obj of g.objects) {
335 obj.row += scrolled
336 }
337
338 // Spawn task coins from queue
339 if (g.taskCoinQueue > 0 && g.tick % 8 === 0) {
340 const lane = (Math.floor(Math.random() * 3)) as 0 | 1 | 2
341 g.objects.push({ id: g.nextId++, kind: 'taskcoin', row: 2, lane, collected: false })
342 g.taskCoinQueue--
343 }
344
345 // Spawn new objects
346 g.spawnAcc += g.speed
347 if (g.spawnAcc >= g.spawnNext) {
348 g.spawnAcc = 0
349 g.spawnNext = 15 + Math.floor(Math.random() * 12)
350 spawnObject(g)
351 }
352
353 // Quiz timer
354 if (g.tick - g.lastQuizTick >= g.nextQuizTicks && !g.activeQuiz) {
355 const q = QUIZ[Math.floor(Math.random() * QUIZ.length)]!
356 g.activeQuiz = q
357 g.quizAnswered = false
358 const correctLane = Math.floor(Math.random() * 3) as 0 | 1 | 2
359 // Build gate permutation so correct answer lands in correctLane
360 const perm: [0|1|2, 0|1|2, 0|1|2] = [0, 1, 2]
361 // swap correct answer index into correctLane
362 const correctOptIdx = q.correct
363 const tmp = perm[correctLane]!
364 perm[correctLane] = correctOptIdx as 0|1|2
365 perm[correctOptIdx] = tmp as 0|1|2
366 g.objects.push({
367 id: g.nextId++, kind: 'gate', row: 2,
368 lane: correctLane,
369 quizIdx: QUIZ.indexOf(q),
370 collected: false,
371 })
372 ;(g.objects[g.objects.length - 1] as RoadObj & { perm?: number[] })['perm'] = perm
373 g.lastQuizTick = g.tick
374 g.nextQuizTicks = pickQuizInterval()
375 }
376
377 // Collision detection
378 const carTopRow = g.rows - 3 // top of car in Raster coords
379 const carBotRow = g.rows - 1
380 const carEffLane = closestLane(g, g.carX)
381 const car = CARS.find(c => c.id === g.profile.currentCar) ?? CARS[0]!
382 const hasMagnet = car.perk === 'magnet' || car.perk === 'all'
383
384 for (const obj of g.objects) {
385 if (obj.collected) continue
386 if (obj.row < carTopRow || obj.row > carBotRow + 1) continue
387
388 // Lane match
389 const inLane = obj.lane === carEffLane
390 const adjacentCoin = hasMagnet && (obj.kind === 'coin' || obj.kind === 'taskcoin') &&
391 Math.abs(obj.lane - carEffLane) === 1
392
393 if (!inLane && !adjacentCoin) {
394 // Check barrier: it blocks specific lanes
395 if (obj.kind === 'barrier') {
396 const blocked = (obj as RoadObj & { lanes?: (0|1|2)[] }).lanes ?? [obj.lane]
397 if (!blocked.includes(carEffLane)) continue
398 } else continue
399 }
400
401 if (obj.kind === 'coin') {
402 obj.collected = true
403 const mult = car.perk === 'doublecoin' || car.perk === 'all' ? 2 : 1
404 g.profile.bank += mult
405 g.coinsThisRun += mult
406 } else if (obj.kind === 'taskcoin') {
407 obj.collected = true
408 const mult = car.perk === 'doublecoin' || car.perk === 'all' ? 2 : 1
409 g.profile.bank += 10 * mult
410 g.coinsThisRun += 10 * mult
411 } else if (obj.kind === 'barrier' || obj.kind === 'cone' || obj.kind === 'pothole') {
412 if (g.nitroTicks > 0) {
413 // Nitro smashes through: collect 2 coins
414 obj.collected = true
415 g.profile.bank += 2
416 g.coinsThisRun += 2
417 } else if (g.shieldActive) {
418 obj.collected = true
419 g.shieldActive = false
420 } else {
421 obj.collected = true
422 hitObstacle(g)
423 return
424 }
425 } else if (obj.kind === 'gate' && !g.quizAnswered) {
426 obj.collected = true
427 g.quizAnswered = true
428 const correctLane = obj.lane
429 if (carEffLane === correctLane) {
430 // Correct lane
431 g.activeQuiz = null
432 const mult = car.perk === 'doublecoin' || car.perk === 'all' ? 2 : 1
433 g.profile.bank += 15 * mult
434 g.coinsThisRun += 15 * mult
435 g.profile.xp += 50
436 g.nitroTicks = Math.max(g.nitroTicks, 60 + Math.floor(60 * Math.random()))
437 g.quizRightThisRun++
438 } else {
439 g.activeQuiz = null
440 }
441 }
442 }
443
444 // Remove off-screen or collected objects
445 g.objects = g.objects.filter(o => !o.collected && o.row <= g.rows + 2)
446}
447
448export function hitObstacle(g: GameState): void {
449 g.shakeTicks = 8
450 g.phase = 'flattire'
451 g.flatTireTimer = 28 // ~2s
452}
453
454function spawnObject(g: GameState): void {
455 const r = Math.random()
456 if (r < 0.45) {
457 // Coins: 2-4 in a line, random lane
458 const lane = (Math.floor(Math.random() * 3)) as 0 | 1 | 2
459 const count = 2 + Math.floor(Math.random() * 3)
460 for (let i = 0; i < count; i++) {
461 g.objects.push({ id: g.nextId++, kind: 'coin', row: 2 + i * 3, lane, collected: false })
462 }
463 } else if (r < 0.70) {
464 // Obstacle: barrier, cone or pothole
465 const kind: ObjKind = r < 0.55 ? 'barrier' : r < 0.62 ? 'cone' : 'pothole'
466 // Pick which lanes to block (never all 3)
467 const blockedCount = Math.random() < 0.4 ? 2 : 1
468 const lanes: (0 | 1 | 2)[] = [0, 1, 2]
469 for (let i = lanes.length - 1; i > 0; i--) {
470 const j = Math.floor(Math.random() * (i + 1))
471 ;[lanes[i], lanes[j]] = [lanes[j]!, lanes[i]!]
472 }
473 const blocked = lanes.slice(0, blockedCount) as (0 | 1 | 2)[]
474 const primaryLane = blocked[0]!
475 g.objects.push({ id: g.nextId++, kind, row: 2, lane: primaryLane, lanes: blocked, collected: false })
476 }
477 // else: nothing this cycle
478}
479
480function closestLane(g: GameState, x: number): 0 | 1 | 2 {
481 let best: 0 | 1 | 2 = 0
482 let bestDist = Infinity
483 for (const lane of [0, 1, 2] as const) {
484 const d = Math.abs(laneCenter(g, lane) - x)
485 if (d < bestDist) { bestDist = d; best = lane }
486 }
487 return best
488}
489
490// ─── Arrived / Resume ─────────────────────────────────────────────────────────
491
492export function triggerArrived(g: GameState): void {
493 if (g.phase !== 'driving' && g.phase !== 'countdown') return
494 if (g.distance > g.profile.bestDistance) g.profile.bestDistance = Math.floor(g.distance)
495 g.arrivedData = { distance: Math.floor(g.distance), coins: g.coinsThisRun, quizRight: g.quizRightThisRun }
496 g.phase = 'arrived'
497}
498
499export function resumeFromArrived(g: GameState): void {
500 if (g.phase !== 'arrived') return
501 startNewRun(g)
502}
503
504// ─── Raster renderer ──────────────────────────────────────────────────────────
505
506let frameBuffer = new Uint32Array(0)
507
508function ensureBuf(cols: number, rows: number): Uint32Array {
509 const need = cols * rows * 3
510 if (frameBuffer.length !== need) frameBuffer = new Uint32Array(need)
511 return frameBuffer
512}
513
514function sc(buf: Uint32Array, col: number, row: number, cols: number, rows: number, cp: number, fg: number, bg: number): void {
515 if (col < 0 || col >= cols || row < 0 || row >= rows) return
516 const i = (row * cols + col) * 3
517 buf[i] = cp; buf[i + 1] = fg; buf[i + 2] = bg
518}
519
520function sw(buf: Uint32Array, col: number, row: number, cols: number, rows: number, str: string, fg: number, bg: number): void {
521 let c = col
522 for (let i = 0; i < str.length; ) {
523 const cp = str.codePointAt(i)!
524 sc(buf, c++, row, cols, rows, cp, fg, bg)
525 i += cp > 0xFFFF ? 2 : 1
526 }
527}
528
529function fr(buf: Uint32Array, r1: number, c1: number, r2: number, c2: number, cols: number, rows: number, cp: number, fg: number, bg: number): void {
530 for (let row = r1; row <= r2; row++) {
531 for (let col = c1; col <= c2; col++) {
532 sc(buf, col, row, cols, rows, cp, fg, bg)
533 }
534 }
535}
536
537export function renderFrame(g: GameState): string {
538 const { cols, rows, laneW, roadL } = g
539 const buf = ensureBuf(cols, rows)
540 const c = g.retro ? R : D
541 const roadR = roadRight(g)
542 const sp = Math.floor(g.scrollPhase)
543
544 // ── Background ─────────────────────────────────────────────────────────────
545 // Grass
546 fr(buf, 0, 0, rows - 1, cols - 1, cols, rows, 0x20, c.dot, c.grass)
547 // Road
548 fr(buf, 0, roadL, rows - 1, roadR, cols, rows, 0x20, T, c.road)
549
550 // ── Trees in grass ─────────────────────────────────────────────────────────
551 const treePositions = [1, 4, 7, 10, 13, 16, 19, 22]
552 for (const tp of treePositions) {
553 const rr = ((tp * 3 + sp * 2) % (rows - 2)) + 2
554 if (rr >= 2 && rr < rows - 1) {
555 // left tree
556 sc(buf, Math.max(0, roadL - 2), rr, cols, rows, 0x2663, c.tree, c.grass) // ♣
557 // right tree
558 sc(buf, Math.min(cols - 1, roadR + 2), rr, cols, rows, 0x2663, c.tree, c.grass)
559 }
560 }
561
562 // ── Road edges ─────────────────────────────────────────────────────────────
563 for (let row = 2; row < rows; row++) {
564 sc(buf, roadL, row, cols, rows, 0x258C, c.edge, c.grass) // ▌
565 sc(buf, roadR, row, cols, rows, 0x2590, c.edge, c.grass) // ▐
566 }
567
568 // ── Lane dividers (dashed, scroll) ─────────────────────────────────────────
569 const div1 = roadL + 1 + laneW
570 const div2 = roadL + 2 + laneW * 2
571 for (let row = 2; row < rows; row++) {
572 if ((row + sp) % 4 === 0) {
573 sc(buf, div1, row, cols, rows, 0x2503, c.lane, c.road) // ┃
574 sc(buf, div2, row, cols, rows, 0x2503, c.lane, c.road)
575 }
576 }
577
578 // ── Objects ────────────────────────────────────────────────────────────────
579 for (const obj of g.objects) {
580 if (obj.collected) continue
581 const row = obj.row
582 if (row < 2 || row >= rows) continue
583 const cx = laneCenter(g, obj.lane)
584
585 if (obj.kind === 'coin') {
586 sc(buf, cx, row, cols, rows, 0x24, c.coin, c.road) // $
587 } else if (obj.kind === 'taskcoin') {
588 sc(buf, cx - 1, row, cols, rows, 0x28, c.task, c.road) // (
589 sc(buf, cx, row, cols, rows, 0x24, c.task, c.road) // $
590 sc(buf, cx + 1, row, cols, rows, 0x29, c.task, c.road) // )
591 } else if (obj.kind === 'barrier') {
592 const lanes = (obj as RoadObj & { lanes?: (0|1|2)[] }).lanes ?? [obj.lane]
593 for (const lane of lanes) {
594 const x = laneCenter(g, lane)
595 for (let dc = -Math.floor(laneW / 2); dc <= Math.floor(laneW / 2); dc++) {
596 const c2 = ((x + dc) + Math.abs(dc)) % 2 === 0 ? c.obs : c.obsStripe
597 sc(buf, x + dc, row, cols, rows, 0x259E, T, c2) // ▞
598 }
599 }
600 } else if (obj.kind === 'cone') {
601 sc(buf, cx, row, cols, rows, 0x25B2, c.obs, c.road) // ▲
602 } else if (obj.kind === 'pothole') {
603 sc(buf, cx - 1, row, cols, rows, 0x28, c.obs, c.road)
604 sc(buf, cx, row, cols, rows, 0x25C9, c.obs, c.road) // ◉
605 sc(buf, cx + 1, row, cols, rows, 0x29, c.obs, c.road)
606 } else if (obj.kind === 'gate') {
607 const perm = (obj as RoadObj & { perm?: number[] })['perm'] ?? [0, 1, 2]
608 const gateColors = [c.gateA, c.gateB, c.gateC]
609 for (const lane of [0, 1, 2] as const) {
610 const optIdx = perm[lane] ?? lane
611 const lx = laneCenter(g, lane)
612 const lbl = ['A', 'B', 'C'][optIdx] ?? 'A'
613 const q = g.activeQuiz
614 const opt = q?.opts[optIdx] ?? ''
615 const bg = gateColors[lane]!
616 // Fill lane with gate color
617 for (let dc = -Math.floor(laneW / 2); dc <= Math.floor(laneW / 2); dc++) {
618 sc(buf, lx + dc, row, cols, rows, 0x20, T, bg)
619 }
620 // Write label
621 const labelStr = lbl + ' ' + opt.slice(0, Math.min(opt.length, laneW - 3))
622 sw(buf, lx - Math.floor(laneW / 2), row, cols, rows, labelStr, 0xFFFFFF, bg)
623 }
624 // Show question 3 rows above gate
625 if (g.activeQuiz && row > 5) {
626 const q = g.activeQuiz
627 sw(buf, roadL + 1, row - 3, cols, rows, 'QUIZ ' + q.q.slice(0, Math.min(q.q.length, roadR - roadL - 8)), c.quizHdr, c.dimRoad)
628 sw(buf, roadL + 1, row - 2, cols, rows, ' A ' + q.opts[0] + ' B ' + q.opts[1] + ' C ' + q.opts[2], c.hud, c.dimRoad)
629 }
630 }
631 }
632
633 // ── Car ────────────────────────────────────────────────────────────────────
634 const carDef = CARS.find(cd => cd.id === g.profile.currentCar) ?? CARS[0]!
635 const carCol = Math.round(g.carX) - 2
636 const carRow = rows - 3
637 const shakeX = g.shakeTicks > 0 ? (g.tick % 2 === 0 ? 1 : -1) : 0
638 const carFg = g.nitroTicks > 0 ? c.nitro : c.car
639 for (let r = 0; r < 3; r++) {
640 sw(buf, carCol + shakeX, carRow + r, cols, rows, carDef.art[r]!, carFg, c.road)
641 }
642 // Shield indicator
643 if (g.shieldActive) {
644 sc(buf, carCol - 1, carRow + 1, cols, rows, 0x25C6, c.shield, c.road) // ◆
645
646 }
647
648 // ── HUD ────────────────────────────────────────────────────────────────────
649 fr(buf, 0, 0, 1, cols - 1, cols, rows, 0x20, T, c.overlay)
650 const { level, frac } = xpProgress(g.profile.xp)
651 const dist = Math.floor(g.distance)
652 const best = g.profile.bestDistance
653 const bank = g.profile.bank
654
655 // Row 0: title, distance, best, bank
656 const r0 = ' ROAD TRIP' + ' ' + dist + ' m · Best ' + best + ' m · ' + String.fromCodePoint(0x25C9) + ' ' + bank
657 sw(buf, 0, 0, cols, rows, r0.slice(0, cols), c.hud, c.overlay)
658 // Nitro indicator
659 if (g.nitroTicks > 0) sw(buf, cols - 8, 0, cols, rows, ' NITRO! ', c.nitro, c.overlay)
660
661 // Row 1: level, XP bar, perk, car name
662 const xpBar = Array.from({ length: 10 }, (_, i) =>
663 i < Math.floor(frac * 10) ? String.fromCodePoint(0x25B0) : String.fromCodePoint(0x25B1)
664 ).join('') // ▰▱
665 const carPerkStr = carDef.perk === 'none' ? '' : ' ' + String.fromCodePoint(0x25C6) + ' ' + capPerk(carDef.perk)
666 const r1 = ' LV ' + level + ' ' + xpBar + carPerkStr + ' ' + carDef.name
667 sw(buf, 0, 1, cols, rows, r1.slice(0, cols), c.hud, c.overlay)
668
669 // ── Overlays ───────────────────────────────────────────────────────────────
670 if (g.phase === 'countdown' && g.countdown > 0) {
671 drawCentered(buf, Math.floor(rows / 2), g.countdown === 3 ? 'Get ready...' : g.countdown === 2 ? 'Get ready. 2' : 'Get ready. 1', cols, rows, c.nitro, c.overlay)
672 }
673
674 if (g.phase === 'flattire') {
675 drawCentered(buf, Math.floor(rows / 2), 'Flat tire! Respawning...', cols, rows, c.wrong, c.overlay)
676 }
677
678 if (g.phase === 'paused') {
679 drawCentered(buf, Math.floor(rows / 2) - 1, ' PAUSED ', cols, rows, c.hud, c.overlay)
680 drawCentered(buf, Math.floor(rows / 2), ' P resume G garage ', cols, rows, c.hud, c.overlay)
681 }
682
683 if (g.phase === 'arrived' && g.arrivedData) {
684 const ad = g.arrivedData
685 const mid = Math.floor(rows / 2)
686 fr(buf, mid - 4, Math.floor(cols * 0.1), mid + 4, Math.floor(cols * 0.9), cols, rows, 0x20, T, c.overlay)
687 drawCentered(buf, mid - 3, 'Arrived! Your AI employee finished the job', cols, rows, c.right, c.overlay)
688 drawCentered(buf, mid - 1, dist + ' m ' + String.fromCodePoint(0x25C9) + ' ' + ad.coins + ' coins ' + ad.quizRight + ' quiz', cols, rows, c.hud, c.overlay)
689 drawCentered(buf, mid, 'LV ' + level + ' Bank: ' + String.fromCodePoint(0x25C9) + ' ' + bank, cols, rows, c.hud, c.overlay)
690 drawCentered(buf, mid + 2, ' A Keep Driving G Garage B Close ', cols, rows, c.coin, c.overlay)
691 }
692
693 if (g.phase === 'garage') {
694 drawGarage(buf, g, c, cols, rows)
695 }
696
697 return encodeB64(new Uint8Array(buf.buffer))
698}
699
700function drawCentered(buf: Uint32Array, row: number, text: string, cols: number, rows: number, fg: number, bg: number): void {
701 const col = Math.max(0, Math.floor((cols - text.length) / 2))
702 fr(buf, row, Math.max(0, col - 1), row, Math.min(cols - 1, col + text.length), cols, rows, 0x20, T, bg)
703 sw(buf, col, row, cols, rows, text, fg, bg)
704}
705
706function drawGarage(buf: Uint32Array, g: GameState, c: typeof D | typeof R, cols: number, rows: number): void {
707 const car = CARS[g.garagePage]!
708 const mid = Math.floor(rows / 2)
709 const level = levelFromXp(g.profile.xp)
710 const owned = g.profile.ownedCars.includes(car.id)
711 const driving = g.profile.currentCar === car.id
712 const locked = level < car.levelReq
713 const canAfford = g.profile.bank >= car.price
714
715 fr(buf, mid - 5, 2, mid + 5, cols - 3, cols, rows, 0x20, T, c.overlay)
716 drawCentered(buf, mid - 5, ' GARAGE ' + (g.garagePage + 1) + '/' + CARS.length, cols, rows, c.coin, c.overlay)
717
718 // Car art centered
719 const artCol = Math.floor((cols - 5) / 2)
720 for (let r = 0; r < 3; r++) {
721 sw(buf, artCol, mid - 3 + r, cols, rows, car.art[r]!, c.car, c.overlay)
722 }
723
724 drawCentered(buf, mid, car.name, cols, rows, c.hud, c.overlay)
725 drawCentered(buf, mid + 1, car.flavor.slice(0, cols - 4), cols, rows, c.hud, c.overlay)
726
727 let status: string
728 if (driving) status = 'Driving this one'
729 else if (owned) status = 'Owned B to drive'
730 else if (locked) status = 'Locked reach LV ' + car.levelReq
731 else if (!canAfford) status = String.fromCodePoint(0x25C9) + ' ' + car.price + ' you have ' + g.profile.bank
732 else status = 'B to buy for ' + String.fromCodePoint(0x25C9) + ' ' + car.price
733 drawCentered(buf, mid + 2, status, cols, rows, locked ? c.wrong : (owned || driving) ? c.right : c.coin, c.overlay)
734 drawCentered(buf, mid + 4, ' A prev D next B select P/G back ', cols, rows, c.hud, c.overlay)
735}
736
737function capPerk(p: Perk): string {
738 const map: Record<Perk, string> = { none: '', magnet: 'Magnet', shield: 'Shield', doublecoin: '2x Coins', all: 'All' }
739 return map[p]
740}
741hooks/road-trip-art.ts 104 lines1// Cars, quiz questions, XP levels. No $ references.
2
3export type Perk = 'none' | 'magnet' | 'shield' | 'doublecoin' | 'all'
4
5export interface CarDef {
6 id: string
7 name: string
8 flavor: string
9 price: number
10 levelReq: number
11 perk: Perk
12 art: readonly [string, string, string] // 5-char wide, 3 rows
13}
14
15export const CARS: readonly CarDef[] = [
16 {
17 id: 'hatch', name: 'Starter Hatch',
18 flavor: 'Every empire starts somewhere',
19 price: 0, levelReq: 1, perk: 'none',
20 art: [' ,_, ', '[ ]', "'o o'"],
21 },
22 {
23 id: 'coupe', name: 'Side Hustle Coupe',
24 flavor: 'Coin magnet: grabs coins in adjacent lane',
25 price: 60, levelReq: 2, perk: 'magnet',
26 art: ['▗▄▄▄▖', '▐▀▀▀▌', '▝o o▘'],
27 },
28 {
29 id: 'wagon', name: 'Agency Wagon',
30 flavor: 'Shield: survive one hit per run',
31 price: 180, levelReq: 3, perk: 'shield',
32 art: ['▗▟█▙▖', '▐█▀█▌', '▝▀ ▀▘'],
33 },
34 {
35 id: 'gt', name: 'Founder GT',
36 flavor: 'Double coins on everything',
37 price: 450, levelReq: 5, perk: 'doublecoin',
38 art: ['▗▃▃▃▖', '▐███▌', '▝▀▀▀▘'],
39 },
40 {
41 id: 'hyper', name: 'Unicorn Hyper',
42 flavor: 'All perks + nitro start',
43 price: 1000, levelReq: 8, perk: 'all',
44 art: ['+▀█▀+', '▐███▌', '+▄█▄+'],
45 },
46]
47
48// Cumulative XP needed to reach each level boundary (index 0 = XP for level 2)
49export const LEVEL_XP = [500, 1500, 3000, 5000, 8000, 12000, 18000, 25000, 35000] as const
50
51export function levelFromXp(xp: number): number {
52 let lv = 1
53 for (const t of LEVEL_XP) { if (xp >= t) lv++; else break }
54 return lv
55}
56
57export function xpProgress(xp: number): { level: number; frac: number } {
58 let level = 1
59 let prev = 0
60 for (const t of LEVEL_XP) {
61 if (xp >= t) { level++; prev = t } else {
62 return { level, frac: (xp - prev) / (t - prev) }
63 }
64 }
65 return { level, frac: 1 }
66}
67
68export interface QuizQ {
69 q: string // question, max ~36 chars
70 opts: readonly [string, string, string] // A/B/C, each max ~12 chars
71 correct: 0 | 1 | 2
72}
73
74export const QUIZ: readonly QuizQ[] = [
75 { q: 'Stop Claude mid-task?', opts: ['Esc', 'F5', 'Tab'], correct: 0 },
76 { q: '/compact does what?', opts: ['Delete files', 'Compress context', 'Exit'], correct: 1 },
77 { q: 'That meeting should be...', opts: ['A slide deck', 'An email', 'A call'], correct: 1 },
78 { q: '/clear resets?', opts: ['Files', 'Context window', 'Plugins'], correct: 1 },
79 { q: 'MCP stands for?', opts: ['Multi-Client', 'Model Context', 'Managed'], correct: 1 },
80 { q: 'Best deploy day?', opts: ['Friday 5pm', 'Monday AM', 'Wednesday AM'], correct: 2 },
81 { q: 'Haiku vs Sonnet: speed?', opts: ['Same', 'Haiku faster', 'Sonnet faster'], correct: 1 },
82 { q: 'Debug or rewrite?', opts: ['Always debug', 'Always rewrite', 'Depends'], correct: 2 },
83 { q: 'Biggest startup risk?', opts: ['Office space', 'Not starting', 'The logo'], correct: 1 },
84 { q: 'Spec or code first?', opts: ['Code', 'Spec', 'Both at once'], correct: 1 },
85 { q: 'Force push main branch?', opts: ['Sure', 'Never on Friday', 'Ask first'], correct: 2 },
86 { q: 'Fix scope creep how?', opts: ['Say no', 'Sprint faster', 'Add devs'], correct: 0 },
87 { q: 'Context window full?', opts: ['/compact', '/clear', 'Restart'], correct: 0 },
88 { q: 'YAGNI stands for?', opts: ["You Ain't Gonna", 'Yet Another Go', 'Your Agent'], correct: 0 },
89 { q: 'Claude API style?', opts: ['REST only', 'Messages API', 'GraphQL'], correct: 1 },
90 { q: 'Prompt caching saves?', opts: ['Speed only', 'Cost only', 'Both'], correct: 2 },
91 { q: 'Best code comment?', opts: ['Why not what', 'What not why', 'No comments'], correct: 0 },
92 { q: 'Ship at 80% or wait?', opts: ['Wait for 100', 'Ship at 80', 'Ship at 60'], correct: 1 },
93 { q: 'Cancel running tool?', opts: ['Esc', 'Ctrl+C', 'q'], correct: 0 },
94 { q: 'Fastest bug fix?', opts: ['Add logs', 'Step debugger', 'Ask Claude'], correct: 2 },
95 { q: 'Subagents run how?', opts: ['In parallel', 'In sequence', 'Either'], correct: 2 },
96 { q: 'Daily standup duration?', opts: ['15 minutes', '1 hour', '30 min'], correct: 0 },
97 { q: 'Tech debt interest rate?', opts: ['Compound', 'Fixed 5%', 'No interest'], correct: 0 },
98 { q: 'Zero inbox: real or myth?', opts: ['Real goal', 'Productivity myth', 'App feature'], correct: 1 },
99 { q: 'Best first hire?', opts: ['Generalist', 'Specialist', 'No hire yet'], correct: 0 },
100 { q: 'Three similar lines need?', opts: ['An abstraction', 'A helper', 'Nothing yet'], correct: 2 },
101 { q: 'Async/await vs callbacks?', opts: ['Always async', 'Depends', 'Always callbacks'], correct: 1 },
102 { q: 'One-liner or helper?', opts: ['One-liner always', 'Helper always', 'One-liner first'], correct: 2 },
103]
104types/index.d.ts 18 lines1export type RoadTripSave = {
2 bank: number
3 ownedCars: string[]
4 currentCar: string
5 xp: number
6 bestDistance: number
7 retro: boolean
8 disabled: boolean
9}
10
11declare module 'claude-code' {
12 interface PluginState {
13 'road-trip': {
14 tick: number
15 }
16 }
17}
18