Programming mini-games to play while Claude works: Spot the Bug, What Does It Print?, Two Truths and a Lie, Real or Hallucinated?, Code Review Court, Big-O…

A Claude Code plugin marketplace with my mods and plugins.
/plugin marketplace add CatraMyBeloved/catras-claude-code-collection
/plugin install agent-comic@catras-claude-code-collection
/plugin install cache-keepalive@catras-claude-code-collection
/plugin install claudle@catras-claude-code-collection
The repo is private, so git must be authenticated (e.g. gh auth login). If the SSH attempt fails, set CLAUDE_CODE_PLUGIN_PREFER_HTTPS=1.
A pixel comic in the band above the prompt: a little Claude acts out what the agent is doing.
hub_add, deferred, so it costs no context until used); the hub keeps six. /comic-hub lists them./comic-pet: he lights up, and a headpat is sent as your message ("Here, have a headpat. You are doing amazing!"), which Claude answers.hybrid (default) has the model set up the session's world and stage each turn's opening and wrap-up in it, and plays canned scenes in between; full stages everything with the model; off uses no tokens at all. Set it with /config, along with the model, pace and whether the comic stays up between turns.$.comic (a garden, a pond, a minigame). Between turns, wooden signs in the hub's top corners lead to them (a/d once the band has focus); while Claude works the comic takes the band back. With no place installed nothing changes. How to build one: EXTENDING.md; a complete example is pond-place./comic svg image (default) draws it as a picture, /comic svg frame in a sandboxed frame, in case a surface does not animate the picture.Commands: /comic (on/off, or /comic svg image|frame), /comic-pet, /comic-hub, /comic-stats, /comic-feel <mood>, /comic-demo.
Keeps Claude Code's prompt cache warm while you think, so the next prompt reads the conversation from the cache instead of paying to write it again.
cache ▰▰▰▰▰▰▱▱ 41:12 (1h), in calm tones: sage green, sand yellow for the last 10 minutes, dusty rose for the last 3 (a 5-minute cache keeps the same proportions). The timer restarts with every request of the main conversation.auto follows Claude Code's own rules: FORCE_PROMPT_CACHING_5M, then CLAUDE_CODE_PROMPT_CACHE_TTL, the promptCacheTtl setting and ENABLE_PROMPT_CACHING_1H; otherwise 1 hour on a subscription within plan limits and 5 minutes on an API key, a cloud provider or usage credits. The mod cannot always tell when a subscription draws on credits; set the lifetime to 5m in /config then./keepalive opens a pane with the bar, the expiry time, the cached prefix size, a timeline of hits, misses and keep-alives, and buttons to ping now (p) or pause (k).Commands: /keepalive (gauge), /keepalive status, /keepalive on|off, /keepalive now. Set the mode (prompt or off), lifetime, lead and idle cutoff with /config.
Why a message and not an invisible side request: a side request ($.model.fork) re-sends the conversation, but Claude Code caches it as a separate entry, so it never kept the main conversation's cache warm in testing.
Programming mini-games in a side pane, to play while the agent works. Claude writes every round, pitches it at your level and grades free-text answers.
In the terminal, click a card in fullscreen mode, or press ctrl+x tab and use the keys the pane lists; in the desktop app and VS Code, click, or press the key marked on a button once the pane has focus. Commands: /claudle, /claudle play <game>, /claudle answer <text>, /claudle topics a, b, c, /claudle stats, /claudle reset. Set the quizmaster model (sonnet, opus or haiku) with /config.
claude plugin validate .
claude plugin test plugins/cache-keepalive
claude plugin test plugins/claudle
claude plugin test examples/pond-place
examples/ holds example plugins that are not in the marketplace; copy one as a starting point.
CACHE_KEEPALIVE_HEADLESS=1 lets cache-keepalive run under claude -p --plugin-dir plugins/cache-keepalive, for checking its cache behaviour without an interactive session.
GPL-3.0. Copyright (c) 2026 Ole Stein.
hooks/register.tsx 1106 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Chat, GameId, Outcome, Profile, Puzzle, View } from '../types'
5import { GAME_COLOR, header, headerRows, hex, ICON_COLUMNS, ICON_ROWS, iconCells, P } from './art'
6import type { Mood, Scene } from './art'
7import { headerPixels, iconSvg, stableHeaderSvg } from './svg'
8import {
9 emptyProfile,
10 gameOf,
11 GAMES,
12 generationPrompt,
13 gradingPrompt,
14 levelFor,
15 LOADING_LINES,
16 parseJson,
17 pointsFor,
18 progressFor,
19 ASK_AFTER,
20 ASK_BEFORE,
21 TUTOR_SYSTEM,
22 tutorPrompt,
23 TOPIC_PRESETS,
24 ratingDelta,
25 START_RATING,
26 SYSTEM,
27 titleFor,
28 toPuzzle,
29} from './games'
30
31const PANE = 'claudle'
32const TITLE = 'Claudle'
33export const DEFAULT_TOPICS = ['JavaScript', 'TypeScript', 'Python', 'SQL', 'Git', 'Shell']
34
35const view = atom({ plugin: 'claudle', key: 'view' } as const, { kind: 'menu' })
36const profile = atom({ plugin: 'claudle', key: 'profile' } as const, emptyProfile())
37const topics = atom({ plugin: 'claudle', key: 'topics' } as const, DEFAULT_TOPICS)
38const agentDone = atom({ plugin: 'claudle', key: 'agentDone' } as const, false)
39const chat = atom({ plugin: 'claudle', key: 'chat' } as const, { round: '', turns: [], isPending: false, isOpen: false })
40
41const HEADER = 'header'
42const ORANGE = 0xd97757
43const { ink: INK, slate: SLATE, moss: MOSS, blood: BLOOD } = P
44
45/** Where the animated header is drawn, while the pane shows it. */
46let mount: { requestId: string; columns: number } | null = null
47let isBlitting = false
48/** What the header shows; the render hook keeps it in step with the view. */
49let scene: Scene = { mood: 'idle', since: 0, title: '', xp: 0, progress: 0, nextAt: undefined, streak: 0 }
50let sceneKey = ''
51
52function setScene(now: View, p: Profile, isTutoring: boolean) {
53 let mood: Mood =
54 now.kind === 'menu' || now.kind === 'stats' || now.kind === 'topics'
55 ? 'idle'
56 : now.kind === 'loading'
57 ? 'thinking'
58 : now.kind === 'question'
59 ? 'watching'
60 : now.kind === 'grading'
61 ? 'grading'
62 : now.kind === 'error'
63 ? 'confused'
64 : now.outcome.score >= 50
65 ? 'correct'
66 : 'wrong'
67 if (isTutoring) mood = 'grading'
68 const key = `${mood}:${'puzzle' in now ? now.puzzle.title : ''}`
69 const since = key === sceneKey ? scene.since : Date.now()
70 sceneKey = key
71 scene = { mood, since, title: titleFor(p.xp), xp: p.xp, streak: p.streak, ...progressFor(p.xp) }
72}
73
74function drawFrame($: EngineInterface) {
75 if (!mount || isBlitting) return
76 const { requestId, columns } = mount
77 isBlitting = true
78 $.ui
79 .blit({ requestId, key: HEADER, cells: header(columns, scene, Date.now()) })
80 .then(r => {
81 if ('deny' in r && r.deny) mount = null
82 })
83 .catch(() => {
84 mount = null
85 })
86 .finally(() => {
87 isBlitting = false
88 })
89}
90
91/** The quizmaster model, from the mod's settings. */
92let model = 'sonnet'
93/** The next round of each game, generated while the current one is played. */
94const prefetched = new Map<GameId, Promise<Puzzle | undefined>>()
95/** Bumped by every new round or trip to the menu, so a late reply is dropped. */
96let roundId = 0
97
98function pick<T>(list: readonly T[]): T {
99 return list[Math.floor(Math.random() * list.length)]!
100}
101
102async function ask($: EngineInterface, prompt: string, maxTokens: number) {
103 const reply = await $.model.complete({ model, system: SYSTEM, prompt, maxTokens, timeoutMs: 90_000 })
104
105 return reply.isAnswered ? parseJson(reply.text) : undefined
106}
107
108async function generate($: EngineInterface, id: GameId): Promise<Puzzle | undefined> {
109 const game = gameOf(id)
110 const { ratings, recent } = await read($, profile)
111 const topic = pick(await read($, topics))
112 const level = levelFor(ratings[topic] ?? START_RATING)
113 const prompt = generationPrompt(game, topic, level, recent[id] ?? [])
114 for (let attempt = 0; attempt < 2; attempt++) {
115 const raw = await ask($, prompt, 1500)
116 const puzzle = raw && toPuzzle(game, topic, level, raw)
117 if (puzzle) return { ...puzzle, id: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}` }
118 }
119
120 return undefined
121}
122
123/** The next round of a game: the one prefetched or still being written, else a new one. */
124function nextRound($: EngineInterface, id: GameId): Promise<Puzzle | undefined> {
125 let ready = prefetched.get(id)
126 if (!ready) {
127 ready = generate($, id).catch(() => undefined)
128 prefetched.set(id, ready)
129 }
130
131 return ready
132}
133
134/** Forgets `ready` as the next round of `id`, unless something newer took its place. */
135function consume(id: GameId, ready: Promise<Puzzle | undefined>) {
136 if (prefetched.get(id) === ready) prefetched.delete(id)
137}
138
139async function startRound($: EngineInterface, id: GameId) {
140 const mine = ++roundId
141 await update($, view, (): View => ({ kind: 'loading', game: id, line: pick(LOADING_LINES) }))
142 let ready = nextRound($, id)
143 let puzzle = await ready
144 if (!puzzle && mine === roundId) {
145 // the prefetch failed: one fresh try
146 consume(id, ready)
147 ready = nextRound($, id)
148 puzzle = await ready
149 }
150 // left for the menu meanwhile (or pressed twice): the round stays ready for next time
151 if (mine !== roundId) return
152 consume(id, ready)
153 if (!puzzle) {
154 await update($, view, (): View => ({
155 kind: 'error',
156 game: id,
157 message: `The ${model} quizmaster didn't produce a usable round. Try again?`,
158 }))
159
160 return
161 }
162 await update($, view, (): View => ({ kind: 'question', puzzle }))
163 void nextRound($, id)
164}
165
166/** Rounds already answered, given up or sent to the judge. */
167const claimed = new Set<string>()
168
169/**
170 * Claims the round for one answer: two presses before the redraw (a double
171 * click, a then b) would otherwise both score it.
172 */
173function claim(puzzle: Puzzle): boolean {
174 const id = roundOf(puzzle)
175 if (claimed.has(id)) return false
176 claimed.add(id)
177 if (claimed.size > 100) claimed.delete(claimed.values().next().value!)
178
179 return true
180}
181
182async function toMenu($: EngineInterface) {
183 roundId++
184 await update($, view, (): View => ({ kind: 'menu' }))
185}
186
187async function finish($: EngineInterface, puzzle: Puzzle, score: number, verdict: string, extra: Partial<Outcome>) {
188 const before = await read($, profile)
189 const rating = before.ratings[puzzle.topic] ?? START_RATING
190 const delta = ratingDelta(rating, puzzle.level, score)
191 const isWin = score >= 50
192 const streak = isWin ? before.streak + 1 : 0
193 const points = pointsFor(puzzle.level, score, before.streak)
194 const after: Profile = {
195 xp: before.xp + points,
196 played: before.played + 1,
197 correct: before.correct + (isWin ? 1 : 0),
198 streak,
199 bestStreak: Math.max(before.bestStreak, streak),
200 games: {
201 ...before.games,
202 [puzzle.game]: {
203 played: (before.games[puzzle.game]?.played ?? 0) + 1,
204 correct: (before.games[puzzle.game]?.correct ?? 0) + (isWin ? 1 : 0),
205 },
206 },
207 ratings: { ...before.ratings, [puzzle.topic]: rating + delta },
208 recent: { ...before.recent, [puzzle.game]: [...(before.recent[puzzle.game] ?? []), puzzle.title].slice(-15) },
209 }
210 await update($, profile, () => after)
211 await $.store.set('profile', after)
212 if (titleFor(after.xp) !== titleFor(before.xp)) $.ui.toast(`Claudle: promoted to ${titleFor(after.xp)}!`)
213 await update($, view, (): View => ({
214 kind: 'feedback',
215 puzzle,
216 outcome: { score, verdict, points, ratingDelta: delta, ...extra },
217 }))
218}
219
220async function answerChoice($: EngineInterface, picked: number) {
221 const now = await read($, view)
222 if (now.kind !== 'question' || now.puzzle.answer === undefined || !claim(now.puzzle)) return
223 const isRight = picked === now.puzzle.answer
224 await finish($, now.puzzle, isRight ? 100 : 0, isRight ? 'Correct!' : 'Nope.', { picked })
225}
226
227async function answerText($: EngineInterface, typed: string) {
228 const now = await read($, view)
229 if (now.kind !== 'question' || now.puzzle.game !== 'spot-the-bug' || typed.trim() === '' || !claim(now.puzzle)) return
230 const mine = roundId
231 const { puzzle } = now
232 await update($, view, (): View => ({ kind: 'grading', puzzle, typed }))
233 const raw = await ask($, gradingPrompt(puzzle, typed), 400).catch(() => undefined)
234 if (mine !== roundId) return
235 const graded = Number(raw?.score)
236 const score = Number.isFinite(graded) ? Math.min(100, Math.max(0, Math.round(graded))) : fallbackScore(puzzle, typed)
237 const verdict =
238 typeof raw?.verdict === 'string' ? raw.verdict : 'The judge fell asleep; scored on the line number alone.'
239 await finish($, puzzle, score, verdict, { typed })
240}
241
242/** Reveals the answer; the round counts as a miss. */
243async function giveUp($: EngineInterface) {
244 const now = await read($, view)
245 if (now.kind !== 'question' || !claim(now.puzzle)) return
246 roundId++
247 await finish($, now.puzzle, 0, 'You gave up. Here is what was going on.', {})
248}
249
250async function toggleTopic($: EngineInterface, topic: string) {
251 const list = await read($, topics)
252 const next = list.includes(topic) ? list.filter(t => t !== topic) : [...list, topic]
253 if (next.length === 0) {
254 $.ui.toast('Claudle needs at least one topic.')
255
256 return
257 }
258 await update($, topics, () => next)
259 await $.store.set('topics', next)
260 prefetched.clear()
261}
262
263async function show($: EngineInterface, kind: 'stats' | 'topics') {
264 roundId++
265 await update($, view, (): View => ({ kind }))
266}
267
268/** Which round a chat belongs to. */
269function roundOf(puzzle: Puzzle): string {
270 return puzzle.id ?? `${puzzle.game}:${puzzle.topic}:${puzzle.title}`
271}
272
273/** The chat for the round on screen: the stored one, or a fresh one when it was about another round. */
274function chatFor(stored: Chat, puzzle: Puzzle): Chat {
275 return stored.round === roundOf(puzzle) ? stored : { round: roundOf(puzzle), turns: [], isPending: false, isOpen: false }
276}
277
278async function openTutor($: EngineInterface) {
279 const now = await read($, view)
280 if (!('puzzle' in now)) return
281 const { puzzle } = now
282 await update($, chat, c => ({ ...chatFor(c, puzzle), isOpen: true }))
283 await $.ui.focus({ requestId: PANE, key: askKey(chatFor(await read($, chat), puzzle)) }).catch(() => undefined)
284}
285
286/** The ask field's key changes with every question, so it comes back empty. */
287function askKey(c: Chat): string {
288 return `ask:${c.turns.length}`
289}
290
291async function askTutor($: EngineInterface, question: string) {
292 const now = await read($, view)
293 if (question.trim() === '' || !('puzzle' in now)) return
294 const { puzzle } = now
295 const before = chatFor(await read($, chat), puzzle)
296 if (before.isPending) return
297 const outcome = now.kind === 'feedback' ? now.outcome : undefined
298 await update($, chat, (): Chat => ({
299 ...before,
300 isOpen: true,
301 isPending: true,
302 turns: [...before.turns, { who: 'you', text: question.trim() }],
303 }))
304 const reply = await $.model
305 .complete({
306 model,
307 system: TUTOR_SYSTEM,
308 prompt: tutorPrompt(puzzle, outcome, before.turns, question),
309 maxTokens: 900,
310 timeoutMs: 90_000,
311 })
312 .catch(() => undefined)
313 const text = reply?.isAnswered
314 ? reply.text.trim()
315 : `I couldn't reach the tutor just now${reply && !reply.isAnswered ? ` (${reply.reason})` : ''}. Try asking again?`
316 const after = await read($, chat)
317 if (after.round !== roundOf(puzzle)) return
318 await update($, chat, (c): Chat => ({ ...c, isPending: false, turns: [...c.turns, { who: 'claude', text }] }))
319 await $.ui.focus({ requestId: PANE, key: askKey(await read($, chat)) }).catch(() => undefined)
320}
321
322/** When grading fails: the right line counts as a pass, nothing else does. */
323export function fallbackScore(puzzle: Puzzle, typed: string): number {
324 const line = typed.match(/\d+/)?.[0]
325
326 return line !== undefined && Number(line) === puzzle.bugLine ? 60 : 0
327}
328
329const HELP = [
330 '/claudle open the game pane',
331 `/claudle play <game> jump into a game (${GAMES.map(g => g.id).join(', ')})`,
332 '/claudle answer <text> answer a Spot the Bug round from the prompt',
333 '/claudle topics a, b, c set the topics rounds are drawn from',
334 '/claudle stats XP, accuracy, rating per topic',
335 '/claudle reset start over',
336].join('\n')
337
338export const register: Register = (on, options) => {
339 model = typeof options.model === 'string' ? options.model : 'sonnet'
340
341 on('session.start', async ($, e, next) => {
342 await $.command.register({
343 name: 'claudle',
344 description: 'Programming mini-games to play while Claude works',
345 argumentHint: '[play <game> | answer <text> | topics <a, b, ...> | stats | reset]',
346 })
347 const storedProfile = (await $.store.get('profile')) as Profile | undefined
348 const storedTopics = (await $.store.get('topics')) as string[] | undefined
349 if (storedProfile) await update($, profile, () => ({ ...emptyProfile(), ...storedProfile }))
350 if (storedTopics?.length) await update($, topics, () => storedTopics)
351 $.clock.every(120, () => drawFrame($))
352
353 return next(e)
354 })
355
356 on('prompt.submit', ($, e, next) => {
357 // a write redraws the pane and hands its buttons new handles, so only write a change;
358 // beside the prompt, never in its way
359 void read($, agentDone)
360 .then(isDone => (isDone ? update($, agentDone, () => false) : undefined))
361 .catch(() => undefined)
362
363 return next(e)
364 })
365
366 on('turn.complete', async ($, e, next) => {
367 const now = await read($, view)
368 if (now.kind !== 'menu' && now.kind !== 'stats' && now.kind !== 'topics' && !(await read($, agentDone))) {
369 await update($, agentDone, () => true)
370 $.ui.toast('Claude finished its turn. Your Claudle round will wait.')
371 }
372
373 return next(e)
374 })
375
376 on('command.run', { command: 'claudle' }, async ($, e) => {
377 const [verb = '', ...rest] = e.args.trim().split(/\s+/).filter(Boolean)
378 const p = await read($, profile)
379
380 if (verb === '' || verb === 'play') {
381 const wanted = rest.join(' ').toLowerCase()
382 const game = wanted ? GAMES.find(g => g.id.startsWith(wanted) || g.name.toLowerCase().startsWith(wanted)) : undefined
383 if (wanted && !game) return { text: `No such game. Games: ${GAMES.map(g => g.id).join(', ')}` }
384 await $.ui.open({ id: PANE, title: TITLE, focus: true })
385 if (game) void startRound($, game.id)
386
387 return { text: game ? `Claudle: ${game.name}.` : 'Claudle opened.' }
388 }
389
390 if (verb === 'answer') {
391 const now = await read($, view)
392 if (now.kind !== 'question' || now.puzzle.game !== 'spot-the-bug') return { text: 'No Spot the Bug round is waiting.' }
393 void answerText($, rest.join(' '))
394
395 return { text: 'Answer sent to the judge.' }
396 }
397
398 if (verb === 'topics') {
399 const list = rest.join(' ').split(',').map(t => t.trim()).filter(Boolean)
400 if (list.length === 0) return { text: `Topics: ${(await read($, topics)).join(', ')}` }
401 await update($, topics, () => list)
402 await $.store.set('topics', list)
403 prefetched.clear()
404
405 return { text: `Claudle topics: ${list.join(', ')}` }
406 }
407
408 if (verb === 'stats') {
409 const ratings = Object.entries(p.ratings)
410 .sort(([, a], [, b]) => b - a)
411 .map(([t, r]) => ` ${t.padEnd(14)} ${r} (level ${levelFor(r)})`)
412 const accuracy = p.played ? Math.round((100 * p.correct) / p.played) : 0
413
414 return {
415 text: [
416 `${titleFor(p.xp)} · ${p.xp} XP · ${p.played} played · ${accuracy}% right · best streak ${p.bestStreak}`,
417 ...(ratings.length ? ratings : [' no rated topics yet']),
418 ].join('\n'),
419 }
420 }
421
422 if (verb === 'reset') {
423 await update($, profile, () => emptyProfile())
424 await $.store.set('profile', emptyProfile())
425
426 return { text: 'Claudle progress reset.' }
427 }
428
429 return { text: HELP }
430 })
431
432 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
433 const elements = $.ui.resolve(e)
434 const { Box, Text, Button, Code } = elements
435 const Input = 'Input' in elements ? elements.Input : undefined
436 // only the terminal paints pixels; other surfaces draw a Raster as an empty box
437 const Raster = e.surface === 'terminal' && 'Raster' in elements ? elements.Raster : undefined
438 const Svg = !Raster && 'Svg' in elements ? elements.Svg : undefined
439 const isTerminal = e.surface === 'terminal'
440 // read together: a redraw hands every button a new handle, and a press that
441 // lands on the old drawing is dropped, so the faster a redraw, the fewer lost
442 const [now, p, isAgentDone, storedChat] = await Promise.all([read($, view), read($, profile), read($, agentDone), read($, chat)])
443 const width = Math.max(24, e.props.bodyColumns)
444 const isFocused = e.props.isFocused
445 const talk = 'puzzle' in now ? chatFor(storedChat, now.puzzle) : undefined
446 setScene(now, p, talk?.isPending ?? false)
447 const Markdown = 'Markdown' in elements ? elements.Markdown : undefined
448
449 /** The Ask Claude box: the conversation, quick questions, and a field to type in. */
450 const tutor = (puzzle: Puzzle, isAnswered: boolean) => {
451 const c = talk ?? chatFor(storedChat, puzzle)
452 const quick = isAnswered
453 ? [...(now.kind === 'feedback' && now.outcome.score < 50 ? ['Why was my answer wrong?'] : []), ...ASK_AFTER]
454 : ASK_BEFORE
455
456 return (
457 <Box borderStyle="round" borderColor={hex(ORANGE)} paddingX={1} flexDirection="column" gap={1}>
458 <Text>
459 <Text bold color={hex(ORANGE)}>
460 Ask Claude
461 </Text>
462 <Text dimColor>{isAnswered ? ' learn the why behind it' : ' hints only until you answer'}</Text>
463 </Text>
464 {c.turns.map((t, i) =>
465 t.who === 'you' ? (
466 <Text key={`you:${i}`} color={hex(P.sand)} wrap="wrap">
467 {`› ${t.text}`}
468 </Text>
469 ) : Markdown ? (
470 <Markdown key={`claude:${i}`} text={t.text} />
471 ) : (
472 <Text key={`claude:${i}`} wrap="wrap">
473 {t.text}
474 </Text>
475 ),
476 )}
477 {c.isPending ? (
478 <Text dimColor italic>
479 Claude is thinking it through…
480 </Text>
481 ) : (
482 <Box columnGap={2} flexWrap="wrap">
483 {quick.map(q => (
484 <Button key={`quick:${q}`} plain dimColor label={`› ${q}`} onPress={() => void askTutor($, q)} />
485 ))}
486 </Box>
487 )}
488 {Input && !c.isPending ? (
489 <Input
490 key={askKey(c)}
491 label="Ask"
492 placeholder={isAnswered ? 'Anything about this round' : 'Where should I look?'}
493 submitLabel="Ask"
494 onSubmit={(value: string) => void askTutor($, value)}
495 />
496 ) : null}
497 </Box>
498 )
499 }
500
501 // ── pieces every view shares ──
502 let headerEl
503 if (Raster) {
504 const columns = Math.min(512, width)
505 mount = { requestId: e.requestId, columns }
506 headerEl = (
507 <Raster key={HEADER} columns={columns} rows={headerRows(columns)} cells={header(columns, scene, Date.now())} />
508 )
509 } else if (Svg) {
510 // the desktop: the same pixel art as an SVG that animates itself
511 mount = null
512 // an SVG scales to the pane: always the wide layout (big logo), shrunk to fit.
513 // Sized explicitly: unsized, the desktop's frame is 300 by 150 and letterboxes it.
514 const columns = 88
515 const own = headerPixels(columns)
516 // a column of the pane is at least 7 px wide, so this never overflows it
517 const pixels = Math.min(own.width, Math.floor(width * 7))
518 headerEl = (
519 <Svg
520 source={stableHeaderSvg(columns, scene, Date.now())}
521 alt={`Claudle: ${titleFor(p.xp)}, ${p.xp} XP${p.streak > 1 ? `, streak ${p.streak}` : ''}`}
522 width={pixels}
523 height={Math.round((pixels * own.height) / own.width)}
524 isInteractive
525 />
526 )
527 } else {
528 mount = null
529 headerEl = (
530 <Box justifyContent="space-between">
531 <Text bold color={hex(ORANGE)}>
532 CLAUDLE <Text dimColor>{titleFor(p.xp)}</Text>
533 </Text>
534 <Text dimColor>
535 {p.xp} XP{p.streak > 1 ? ` · x${p.streak} streak` : ''}
536 </Text>
537 </Box>
538 )
539 }
540 const iconWidth = Raster || Svg ? ICON_COLUMNS : 1
541 const icon = (id: GameId, key: string) =>
542 Raster ? (
543 <Raster key={key} columns={ICON_COLUMNS} rows={ICON_ROWS} cells={iconCells(id)} />
544 ) : Svg ? (
545 <Svg source={iconSvg(id)} alt={gameOf(id).name} width={48} height={36} />
546 ) : (
547 <Text color={hex(GAME_COLOR[id])}>■</Text>
548 )
549 const agentLine = isAgentDone ? (
550 <Box paddingX={1}>
551 <Text color="success">● Claude is done and waiting for you</Text>
552 </Box>
553 ) : null
554 /**
555 * The keys that work here, or how to get them working while the pane lacks the keyboard.
556 * The terminal's alone: the other surfaces mark each button with its key, and ctrl+x tab
557 * is a terminal chord. Not drawn there, the drawing also stays the same when the pane
558 * gains or loses focus.
559 */
560 const hint = (keys: string) =>
561 isTerminal ? (
562 <Box paddingX={1}>
563 <Text dimColor wrap="wrap">
564 {isFocused ? keys : `Click to play, or ctrl+x tab to give this pane the keyboard (${keys})`}
565 </Text>
566 </Box>
567 ) : null
568 /**
569 * Every view the desktop draws starts its focus on a button. The pane counts as focused
570 * only while the focus is inside it, and a new view replaces the button that held it;
571 * with nothing to start on, the focus drops out, and the next click first focuses the
572 * pane again. That redraws it (its isFocused prop changed) and hands out new handles
573 * before the click's press arrives, and the engine drops the press: the click is lost.
574 * The terminal keeps its focus ring where it was and needs none of this.
575 */
576 const keepFocus = isTerminal ? {} : ({ autoFocus: true } as const)
577 const menuButton = (isFocusStart = false) => (
578 <Button key="menu" plain hotkey="m" dimColor label="menu" {...(isFocusStart ? keepFocus : {})} onPress={() => toMenu($)} />
579 )
580
581 // ── menu: a card per game; every text row of a card starts it ──
582 if (now.kind === 'menu') {
583 const isTwoUp = width >= 70
584 const cardWidth = isTwoUp ? Math.floor(width / 2) : width
585 const inner = Math.max(8, cardWidth - 4 - iconWidth - 1)
586 const accuracy = p.played ? Math.round((100 * p.correct) / p.played) : 0
587
588 return (
589 <Box flexDirection="column" gap={1}>
590 {headerEl}
591 {agentLine}
592 <Box flexDirection="row" flexWrap="wrap">
593 {GAMES.map(game => {
594 const color = hex(GAME_COLOR[game.id])
595 const stats = p.games[game.id]
596 const meta = stats
597 ? `${stats.played} played · ${Math.round((100 * stats.correct) / stats.played)}% right`
598 : 'new · give it a go'
599 const play = () => void startRound($, game.id)
600
601 return (
602 <Box
603 key={`card:${game.id}`}
604 width={cardWidth}
605 borderStyle="round"
606 borderColor={color}
607 hover={{ borderStyle: 'bold' }}
608 paddingX={1}
609 gap={1}
610 >
611 {icon(game.id, `icon:${game.id}`)}
612 <Box flexDirection="column">
613 <Button
614 key={`game:${game.id}`}
615 plain
616 hotkey={game.hotkey}
617 label={fit(game.name, inner - 3)}
618 hover={{ color, bold: true }}
619 onPress={play}
620 />
621 <Button key={`game:${game.id}:about`} plain dimColor label={fit(game.blurb, inner)} onPress={play} />
622 <Button key={`game:${game.id}:meta`} plain dimColor label={fit(meta, inner)} onPress={play} />
623 </Box>
624 </Box>
625 )
626 })}
627 </Box>
628 <Box paddingX={1} gap={2} flexWrap="wrap">
629 <Button
630 key="surprise"
631 variant="primary"
632 hotkey="s"
633 label="Surprise me"
634 {...keepFocus}
635 onPress={() => void startRound($, pick(GAMES).id)}
636 />
637 <Button key="topics" plain hotkey="t" label="Topics" onPress={() => show($, 'topics')} />
638 <Button key="stats" plain hotkey="p" label="Progress" onPress={() => show($, 'stats')} />
639 </Box>
640 <Box paddingX={1}>
641 <Text dimColor>
642 {p.played === 0
643 ? 'No rounds yet. Pick a card!'
644 : `${p.played} played · ${accuracy}% right · best streak ${p.bestStreak}`}
645 </Text>
646 </Box>
647 {hint('1-6 play a game · s surprise · t topics · p progress')}
648 </Box>
649 )
650 }
651
652 // ── topics: toggles ──
653 if (now.kind === 'topics') {
654 const chosen = await read($, topics)
655 const all = [...TOPIC_PRESETS, ...chosen.filter(t => !TOPIC_PRESETS.includes(t))]
656
657 return (
658 <Box flexDirection="column" gap={1}>
659 {headerEl}
660 <Box borderStyle="round" borderColor={hex(SLATE)} paddingX={1} flexDirection="column" gap={1}>
661 <Text bold>Topics rounds are drawn from</Text>
662 <Box flexWrap="wrap" columnGap={2}>
663 {all.map(topic => {
664 const isOn = chosen.includes(topic)
665
666 return (
667 <Button
668 key={`topic:${topic}`}
669 plain
670 label={`${isOn ? '◉' : '○'} ${topic}`}
671 dimColor={!isOn}
672 onPress={() => toggleTopic($, topic)}
673 />
674 )
675 })}
676 </Box>
677 <Text dimColor wrap="wrap">
678 Anything else: /claudle topics Elixir, Kubernetes, ...
679 </Text>
680 </Box>
681 <Box paddingX={1}>
682 <Button key="done" variant="primary" hotkey="m" label="Done" autoFocus onPress={() => toMenu($)} />
683 </Box>
684 {hint('Tab moves between topics · Enter toggles · m done')}
685 </Box>
686 )
687 }
688
689 // ── progress: per game and per topic ──
690 if (now.kind === 'stats') {
691 const bar = (fraction: number, cells: number) => {
692 const filled = Math.round(Math.min(1, Math.max(0, fraction)) * cells)
693
694 return '█'.repeat(filled) + '░'.repeat(cells - filled)
695 }
696 const ratings = Object.entries(p.ratings).sort(([, a], [, b]) => b - a)
697 const progress = progressFor(p.xp)
698
699 return (
700 <Box flexDirection="column" gap={1}>
701 {headerEl}
702 <Box borderStyle="round" borderColor={hex(SLATE)} paddingX={1} flexDirection="column">
703 <Text bold>{titleFor(p.xp)}</Text>
704 <Text>
705 <Text color={hex(P.amber)}>{bar(progress.progress, 20)}</Text>
706 <Text dimColor>
707 {progress.nextAt === undefined ? ` ${p.xp} XP, top title` : ` ${p.xp} / ${progress.nextAt} XP to the next title`}
708 </Text>
709 </Text>
710 <Text dimColor>
711 {p.played} played · {p.correct} right · streak {p.streak} · best {p.bestStreak}
712 </Text>
713 </Box>
714 <Box borderStyle="round" borderColor={hex(SLATE)} paddingX={1} flexDirection="column">
715 <Text bold>Games</Text>
716 {GAMES.map(game => {
717 const stats = p.games[game.id]
718 const rate = stats ? stats.correct / stats.played : 0
719
720 // a column of its own, not padding: spaces only line up in a monospace font
721 return (
722 <Box key={`g:${game.id}`}>
723 <Box width={22} flexShrink={0}>
724 <Text color={hex(GAME_COLOR[game.id])} wrap="truncate-end">
725 {game.name}
726 </Text>
727 </Box>
728 {stats ? (
729 <Text>
730 {bar(rate, 10)} <Text dimColor>{`${Math.round(rate * 100)}% of ${stats.played}`}</Text>
731 </Text>
732 ) : (
733 <Text dimColor>not played yet</Text>
734 )}
735 </Box>
736 )
737 })}
738 </Box>
739 <Box borderStyle="round" borderColor={hex(SLATE)} paddingX={1} flexDirection="column">
740 <Text bold>Topics</Text>
741 {ratings.length === 0 && <Text dimColor>Play a round to get rated.</Text>}
742 {ratings.map(([topic, rating]) => (
743 <Box key={`t:${topic}`}>
744 <Box width={22} flexShrink={0}>
745 <Text wrap="truncate-end">{topic}</Text>
746 </Box>
747 <Text>
748 <Text color={hex(P.leaf)}>{'●'.repeat(levelFor(rating))}</Text>
749 <Text dimColor>{'○'.repeat(10 - levelFor(rating))}</Text>
750 <Text dimColor>{` level ${levelFor(rating)} · rating ${rating}`}</Text>
751 </Text>
752 </Box>
753 ))}
754 </Box>
755 <Box paddingX={1}>
756 <Button key="done" variant="primary" hotkey="m" label="Back" autoFocus onPress={() => toMenu($)} />
757 </Box>
758 </Box>
759 )
760 }
761
762 if (now.kind === 'loading') {
763 const color = hex(GAME_COLOR[now.game])
764
765 return (
766 <Box flexDirection="column" gap={1}>
767 {headerEl}
768 {agentLine}
769 <Box borderStyle="round" borderColor={color} paddingX={1} gap={1}>
770 {icon(now.game, 'icon:round')}
771 <Box flexDirection="column">
772 <Text bold color={color}>
773 {gameOf(now.game).name}
774 </Text>
775 <Text dimColor italic wrap="wrap">
776 {now.line}…
777 </Text>
778 <Text dimColor>Usually 5 to 20 seconds; the next round is prepared while you play.</Text>
779 </Box>
780 </Box>
781 <Box paddingX={1}>{menuButton(true)}</Box>
782 </Box>
783 )
784 }
785
786 if (now.kind === 'error') {
787 const { game } = now
788
789 return (
790 <Box flexDirection="column" gap={1}>
791 {headerEl}
792 <Box borderStyle="round" borderColor="error" paddingX={1}>
793 <Text color="error" wrap="wrap">
794 {now.message}
795 </Text>
796 </Box>
797 <Box paddingX={1} gap={3}>
798 <Button key="retry" variant="primary" hotkey="r" label="Retry" autoFocus onPress={() => void startRound($, game)} />
799 {menuButton()}
800 </Box>
801 </Box>
802 )
803 }
804
805 // ── a round: its card, the code, the answers ──
806 const { puzzle } = now
807 const game = gameOf(puzzle.game)
808 const color = hex(GAME_COLOR[puzzle.game])
809 const isBug = puzzle.game === 'spot-the-bug'
810 const pips = '●'.repeat(puzzle.level) + '○'.repeat(10 - puzzle.level)
811 const card = (
812 <Box borderStyle="round" borderColor={color} paddingX={1} gap={1}>
813 {icon(puzzle.game, 'icon:round')}
814 <Box flexDirection="column" flexShrink={1}>
815 <Text>
816 <Text backgroundColor={color} color={hex(INK)} bold>
817 {` ${game.name.toUpperCase()} `}
818 </Text>
819 <Text dimColor>{` ${puzzle.topic}`}</Text>
820 </Text>
821 <Text bold wrap="wrap">
822 {puzzle.title}
823 </Text>
824 <Text color={color}>
825 {pips} <Text dimColor>level {puzzle.level}</Text>
826 </Text>
827 </Box>
828 </Box>
829 )
830 const code = puzzle.code ? (
831 <Box borderStyle="single" borderColor={hex(SLATE)} paddingX={1}>
832 <Code
833 source={puzzle.code}
834 {...(puzzle.language ? { language: puzzle.language } : {})}
835 {...(isBug ? { startLine: 1 } : {})}
836 wrap="wrap"
837 />
838 </Box>
839 ) : null
840 const letters = ['a', 'b', 'c', 'd']
841
842 if (now.kind === 'question' || now.kind === 'grading') {
843 let answers
844 let keys = 'q ask for a hint · k skip · g give up · m menu'
845 if (now.kind === 'grading') {
846 answers = (
847 <Box borderStyle="round" borderColor={hex(SLATE)} paddingX={1} flexDirection="column">
848 <Text dimColor>You said: {now.typed}</Text>
849 <Text dimColor italic>
850 The judge is reading your answer…
851 </Text>
852 <Box marginTop={1}>{menuButton(true)}</Box>
853 </Box>
854 )
855 } else if (isBug) {
856 keys = 'type your answer, Enter submits · Esc leaves the field, then q hint · k skip · g give up'
857 answers = Input ? (
858 <Box borderStyle="round" borderColor={color} paddingX={1} flexDirection="column">
859 <Input
860 key="answer"
861 label="Bug"
862 placeholder="line 7: the loop runs one past the end"
863 submitLabel="Submit"
864 autoFocus
865 onSubmit={(value: string) => void answerText($, value)}
866 />
867 </Box>
868 ) : (
869 <Text dimColor>Answer with /claudle answer <line and what is wrong></Text>
870 )
871 } else if (game.fixedOptions && !isTerminal) {
872 // native buttons: the whole button answers, not just the word inside a box
873 answers = (
874 <Box gap={1} flexWrap="wrap">
875 {game.fixedOptions.map((option, i) => (
876 <Button key={`opt:${i}`} hotkey={letters[i]!} label={option} onPress={() => answerChoice($, i)} />
877 ))}
878 </Box>
879 )
880 } else if (game.fixedOptions) {
881 const cell = Math.max(...game.fixedOptions.map(o => o.length)) + 2
882 keys = `${game.fixedOptions.map((o, i) => `${letters[i]} ${o}`).join(' · ')} · q hint · k skip · g give up`
883 answers = (
884 <Box gap={1} flexWrap="wrap">
885 {game.fixedOptions.map((option, i) => (
886 <Box key={`box:${i}`} borderStyle="round" borderColor={color} hover={{ borderStyle: 'bold' }} paddingX={1}>
887 <Button
888 key={`opt:${i}`}
889 plain
890 hotkey={letters[i]!}
891 label={fit(option, cell)}
892 hover={{ bold: true }}
893 onPress={() => answerChoice($, i)}
894 />
895 </Box>
896 ))}
897 </Box>
898 )
899 } else {
900 const inner = width - 4
901 // the desktop draws a button's label on one line and cuts the rest, so a long
902 // answer is one button per line, each answering; the first carries the key.
903 // A column is a digit wide and prose runs narrower, so lines this long fit;
904 // less the frame, its padding and the key's mark (about 3 columns)
905 const lineWidth = Math.max(12, width - 7)
906 keys = 'a-d answer · q hint · k skip · g give up · m menu'
907 answers = (
908 <Box flexDirection="column" gap={isTerminal ? 0 : 1}>
909 {(puzzle.options ?? []).map((option, i) => {
910 const isShort = option.length <= inner - 3
911 const press = () => answerChoice($, i)
912 const [first = '', ...rest] = wrapLines(option, lineWidth)
913
914 return (
915 <Box
916 key={`box:${i}`}
917 borderStyle="round"
918 borderColor={hex(SLATE)}
919 hover={{ borderColor: color }}
920 paddingX={1}
921 flexDirection="column"
922 >
923 {!isTerminal ? (
924 [
925 <Button key={`opt:${i}`} plain hotkey={letters[i]!} label={first} hover={{ color }} onPress={press} />,
926 ...(rest.length > 0
927 ? [
928 // unkeyed, so the option's box stays the hover scope of every line
929 <Box flexDirection="column" paddingLeft={3}>
930 {rest.map((line, j) => (
931 <Button key={`opt:${i}:${j + 1}`} plain label={line} hover={{ color }} onPress={press} />
932 ))}
933 </Box>,
934 ]
935 : []),
936 ]
937 ) : isShort ? (
938 <Button
939 key={`opt:${i}`}
940 plain
941 hotkey={letters[i]!}
942 label={fit(option, inner - 3)}
943 hover={{ bold: true }}
944 onPress={() => answerChoice($, i)}
945 />
946 ) : (
947 <Button key={`opt:${i}`} plain hotkey={letters[i]!} label={option} onPress={() => answerChoice($, i)} />
948 )}
949 </Box>
950 )
951 })}
952 </Box>
953 )
954 }
955
956 return (
957 <Box flexDirection="column" gap={1}>
958 {headerEl}
959 {agentLine}
960 {card}
961 <Box paddingX={1}>
962 <Text wrap="wrap" bold={puzzle.game === 'review-court'}>
963 {puzzle.prompt}
964 </Text>
965 </Box>
966 {code}
967 {answers}
968 {/* above the chat, which grows: reachable without scrolling to the bottom */}
969 {now.kind === 'question' && (
970 <Box paddingX={1} columnGap={3} flexWrap="wrap">
971 <Button key="hint" plain hotkey="q" label="ask for a hint" {...keepFocus} onPress={() => openTutor($)} />
972 <Button key="skip" plain hotkey="k" dimColor label="skip" onPress={() => void startRound($, puzzle.game)} />
973 <Button key="giveup" plain hotkey="g" dimColor label="give up" onPress={() => giveUp($)} />
974 {menuButton()}
975 </Box>
976 )}
977 {talk && (talk.isOpen || talk.turns.length > 0) ? tutor(puzzle, false) : null}
978 {now.kind === 'question' && hint(keys)}
979 </Box>
980 )
981 }
982
983 // ── feedback ──
984 const { outcome } = now
985 const isWin = outcome.score >= 50
986 let reveal
987 if (isBug) {
988 reveal = (
989 <Box flexDirection="column" paddingX={1}>
990 {outcome.typed !== undefined && <Text dimColor wrap="wrap">You said: {outcome.typed}</Text>}
991 <Text wrap="wrap">{outcome.verdict}</Text>
992 <Text wrap="wrap">
993 <Text bold color={color}>
994 Line {puzzle.bugLine}:
995 </Text>{' '}
996 {puzzle.bug}
997 </Text>
998 </Box>
999 )
1000 } else {
1001 reveal = (
1002 <Box flexDirection="column" paddingX={1}>
1003 {outcome.picked === undefined && <Text wrap="wrap">{outcome.verdict}</Text>}
1004 {(puzzle.options ?? []).map((option, i) =>
1005 i === puzzle.answer ? (
1006 <Text key={`r:${i}`} color="success" bold wrap="wrap">
1007 ✓ {option}
1008 </Text>
1009 ) : i === outcome.picked ? (
1010 <Text key={`r:${i}`} color="error" strikethrough wrap="wrap">
1011 ✗ {option}
1012 </Text>
1013 ) : (
1014 <Text key={`r:${i}`} dimColor wrap="wrap">
1015 {` ${option}`}
1016 </Text>
1017 ),
1018 )}
1019 </Box>
1020 )
1021 }
1022 const ratingText = `${outcome.ratingDelta >= 0 ? '+' : ''}${outcome.ratingDelta}`
1023
1024 return (
1025 <Box flexDirection="column" gap={1}>
1026 {headerEl}
1027 {agentLine}
1028 <Box backgroundColor={hex(isWin ? MOSS : BLOOD)} paddingX={1} justifyContent="space-between">
1029 <Text bold color="#ffffff">
1030 {isWin ? '✓ CORRECT' : '✗ NOT QUITE'}
1031 {isBug && outcome.typed !== undefined ? ` ${outcome.score}/100` : ''}
1032 </Text>
1033 <Text bold color="#ffffff">
1034 +{outcome.points} XP
1035 </Text>
1036 </Box>
1037 {card}
1038 {code}
1039 {reveal}
1040 <Box borderStyle="round" borderColor={hex(SLATE)} paddingX={1} flexDirection="column">
1041 <Text bold color={color}>
1042 Why
1043 </Text>
1044 <Text wrap="wrap">{puzzle.explanation}</Text>
1045 </Box>
1046 {puzzle.quip !== '' && (
1047 <Box paddingX={1}>
1048 <Text italic color={hex(ORANGE)} wrap="wrap">
1049 “{puzzle.quip}”
1050 </Text>
1051 </Box>
1052 )}
1053 <Box paddingX={1}>
1054 <Text dimColor>
1055 {puzzle.topic} rating {ratingText} · streak {p.streak}
1056 </Text>
1057 </Box>
1058 <Box paddingX={1} columnGap={3} flexWrap="wrap">
1059 <Button
1060 key="next"
1061 variant="primary"
1062 hotkey="n"
1063 label="Next round"
1064 autoFocus
1065 onPress={() => void startRound($, puzzle.game)}
1066 />
1067 <Button key="ask" plain hotkey="q" label="ask Claude" onPress={() => openTutor($)} />
1068 <Button key="other" plain hotkey="o" label="other game" onPress={() => toMenu($)} />
1069 </Box>
1070 {tutor(puzzle, true)}
1071 {hint('Enter or n next round · q ask Claude about it · o pick another game')}
1072 </Box>
1073 )
1074 })
1075}
1076
1077/** `text` broken into lines of at most `width` characters, between words where it can. */
1078export function wrapLines(text: string, width: number): string[] {
1079 const lines: string[] = []
1080 let line: string[] = []
1081 for (const word of text.split(/\s+/).filter(Boolean)) {
1082 const chars = [...word]
1083 if (line.length > 0 && line.length + 1 + chars.length <= width) {
1084 line.push(' ', ...chars)
1085 continue
1086 }
1087 if (line.length > 0) lines.push(line.join(''))
1088 line = chars
1089 // a word longer than a line is cut where the line ends
1090 while (line.length > width) {
1091 lines.push(line.slice(0, width).join(''))
1092 line = line.slice(width)
1093 }
1094 }
1095 if (line.length > 0 || lines.length === 0) lines.push(line.join(''))
1096
1097 return lines
1098}
1099
1100/** `text` cut or padded to exactly `n` columns, so a Button fills its row. */
1101function fit(text: string, n: number): string {
1102 const chars = [...text]
1103
1104 return chars.length > n ? chars.slice(0, Math.max(0, n - 1)).join('') + '…' : text + ' '.repeat(n - chars.length)
1105}
1106hooks/art.ts 321 lines1// Pixel art for Claudle: the quiz-show header (logo, quizmaster Claude, XP bar)
2// and the game icons. Same palette and figure as the agent-comic mod.
3
4import type { GameId } from '../types'
5import { Canvas, DEFAULT } from './canvas'
6
7export const P = {
8 ink: 0x181425, night: 0x262b44, slate: 0x3a4466, steel: 0x5a6988, mist: 0x8b9bb4, silver: 0xc0cbdc, white: 0xffffff,
9 wine: 0x3e2731, bark: 0x733e39, wood: 0xb86f50, tan: 0xe4a672, sand: 0xead4aa,
10 blood: 0xa22633, red: 0xe43b44, coral: 0xf6757a, amber: 0xfeae34, yellow: 0xfee761,
11 leaf: 0x63c74d, moss: 0x3e8948, pine: 0x265c42, navy: 0x124e89, sky: 0x0099db, plum: 0x68386c, pink: 0xb55088,
12}
13
14export const hex = (c: number) => `#${c.toString(16).padStart(6, '0')}`
15
16/** Each game's color, used for its icon, card border and badge. */
17export const GAME_COLOR: Record<GameId, number> = {
18 'spot-the-bug': P.red,
19 'what-prints': P.leaf,
20 'two-truths': P.amber,
21 'real-or-fake': P.sky,
22 'review-court': P.pink,
23 'big-o': P.coral,
24}
25
26// ── Icons: 8 px wide, 6 px tall (8 columns by 3 rows) ─────────────────
27
28type Art = { rows: readonly string[]; palette: Record<string, number> }
29
30export const ICONS: Record<GameId, Art> = {
31 'spot-the-bug': {
32 rows: ['.K....K.', '..K.hK..', '.KhrrdK.', 'K.rKrd.K', '..rKdd..', '.K.dd.K.'],
33 palette: { K: P.ink, h: P.coral, r: P.red, d: P.blood },
34 },
35 'what-prints': {
36 rows: ['ssssssss', 'sNNNNNNs', 'sNgNNNNs', 'sNNgNNNs', 'sNgNyyNs', 'ssssssss'],
37 palette: { s: P.steel, N: P.ink, g: P.leaf, y: P.silver },
38 },
39 'two-truths': {
40 rows: ['hh.hh.cc', 'GG.GG.RR', 'GW.GW.WR', 'WG.WG.RW', 'GG.GG.RR', 'gg.gg.dd'],
41 palette: { h: P.leaf, G: P.moss, g: P.pine, W: P.white, c: P.coral, R: P.red, d: P.blood },
42 },
43 'real-or-fake': {
44 rows: ['..wwww..', '.wwwwwm.', 'wwkwwkwm', 'wwwwwwwm', 'wwwwwwmm', 'w.wm.w.m'],
45 palette: { w: P.white, m: P.silver, k: P.ink },
46 },
47 'review-court': {
48 rows: ['.hhh....', 'BBBBh...', 'BBBB.h..', '.bbb..h.', '.......h', 'sSSSSSSs'],
49 palette: { h: P.tan, B: P.wood, b: P.bark, S: P.pink, s: P.plum },
50 },
51 'big-o': {
52 rows: ['s......r', 's......r', 's.....r.', 's....r..', 's.rrr...', 'ssssssss'],
53 palette: { s: P.steel, r: P.coral },
54 },
55}
56
57export const ICON_COLUMNS = 8
58export const ICON_ROWS = 3
59
60export function iconCells(id: GameId): string {
61 return iconCanvas(id).encode()
62}
63
64export function iconCanvas(id: GameId): Canvas {
65 const c = new Canvas(ICON_COLUMNS, ICON_ROWS)
66 c.sprite(0, 0, ICONS[id].rows, ICONS[id].palette)
67
68 return c
69}
70
71// ── Logo font: 3 by 5, scaled ──────────────────────────────────────────
72
73const FONT: Record<string, string[]> = {
74 C: ['.##', '#..', '#..', '#..', '.##'],
75 L: ['#..', '#..', '#..', '#..', '###'],
76 A: ['.#.', '#.#', '###', '#.#', '#.#'],
77 U: ['#.#', '#.#', '#.#', '#.#', '.#.'],
78 D: ['##.', '#.#', '#.#', '#.#', '##.'],
79 E: ['###', '#..', '##.', '#..', '###'],
80}
81const LOGO = 'CLAUDLE'
82const LOGO_RAMP = [0xf4c09f, 0xeea27d, 0xd97757, 0xd97757, 0xb4533f]
83
84export function logoWidth(scale: number): number {
85 return LOGO.length * 4 * scale - scale
86}
87
88function drawLogo(c: Canvas, x: number, y: number, scale: number, t: number) {
89 // a highlight sweeps across the letters every few seconds
90 const sweep = ((t % 4800) / 4800) * (logoWidth(scale) + 80) - 20
91 ;[...LOGO].forEach((letter, i) => {
92 const glyph = FONT[letter]!
93 for (let r = 0; r < 5; r++) {
94 for (let col = 0; col < 3; col++) {
95 if (glyph[r]![col] !== '#') continue
96 for (let dy = 0; dy < scale; dy++) {
97 for (let dx = 0; dx < scale; dx++) {
98 const px = x + (i * 4 + col) * scale + dx
99 const py = y + r * scale + dy
100 c.set(px + 1, py + 1, P.wine)
101 const shine = Math.abs(px - sweep - (py - y)) < 2
102 c.set(px, py, shine ? 0xffe2cf : LOGO_RAMP[Math.min(4, Math.floor(((r * scale + dy) / (5 * scale)) * 5))]!)
103 }
104 }
105 }
106 }
107 })
108}
109
110// ── Quizmaster Claude ──────────────────────────────────────────────────
111
112type Face = 'ahead' | 'blink' | 'happy' | 'sad' | 'focused' | 'confused' | 'squint'
113type ArmPose = 'down' | 'mid' | 'up'
114const TINT = { h: 0xeea27d, O: 0xd97757, s: 0xb4533f, E: P.ink, w: P.white, b: P.ink, r: P.red }
115
116const FACES: Record<Face, [number, number, 'E' | 's'][]> = {
117 ahead: [[1, 3, 'E'], [2, 3, 'E'], [1, 7, 'E'], [2, 7, 'E']],
118 blink: [[2, 3, 's'], [2, 7, 's']],
119 happy: [[1, 3, 'E'], [2, 2, 'E'], [2, 4, 'E'], [1, 7, 'E'], [2, 6, 'E'], [2, 8, 'E']],
120 sad: [[2, 3, 'E'], [3, 3, 'E'], [2, 7, 'E'], [3, 7, 'E'], [1, 4, 's'], [1, 6, 's']],
121 focused: [[2, 3, 'E'], [2, 4, 'E'], [2, 6, 'E'], [2, 7, 'E']],
122 confused: [[1, 3, 'E'], [2, 3, 'E'], [2, 7, 'E'], [2, 8, 'E'], [0, 8, 's']],
123 squint: [[2, 3, 'E'], [2, 4, 'E'], [2, 7, 'E'], [2, 8, 'E']],
124}
125
126/** Claude, 11 by 7: the comic's figure. */
127function claude(face: Face, back: ArmPose, front: ArmPose, step: number): string[] {
128 const grid = ['..OOOOOOO..', '..OOOOOOO..', '..OOOOOOO..', '..OOOOOOO..', '..sssssss..'].map(r => r.split(''))
129 const arm = (state: ArmPose, [inner, outer]: [number, number]) => {
130 const row = state === 'down' ? 2 : 1
131 grid[row]![inner] = 'O'
132 grid[state === 'up' ? 0 : row]![outer] = 'O'
133 }
134 arm(back, [1, 0])
135 arm(front, [9, 10])
136 for (const [r, col, ch] of FACES[face]) grid[r]![col] = ch
137 for (let col = 0; col <= 7; col++) if (grid[0]![col] === 'O') grid[0]![col] = 'h'
138 for (let r = 1; r <= 2; r++) if (grid[r]![2] === 'O') grid[r]![2] = 'h'
139 for (let r = 1; r <= 3; r++) if (grid[r]![8] === 'O') grid[r]![8] = 's'
140 const legs = step % 2 ? ['..s.s.s.s..', '....s...s..'] : ['..s.s.s.s..', '..s.s.s.s..']
141
142 return [...grid.map(r => r.join('')), ...legs]
143}
144
145function scaled(c: Canvas, x: number, y: number, rows: readonly string[], palette: Record<string, number>, scale: number) {
146 rows.forEach((row, j) =>
147 [...row].forEach((ch, i) => {
148 const color = palette[ch]
149 if (color !== undefined) c.rect(x + i * scale, y + j * scale, scale, scale, color)
150 }),
151 )
152}
153
154// ── The header scene ───────────────────────────────────────────────────
155
156export type Mood = 'idle' | 'thinking' | 'watching' | 'grading' | 'correct' | 'wrong' | 'confused'
157
158export type Scene = {
159 mood: Mood
160 /** When the mood began (ms), so a reaction plays once and settles. */
161 since: number
162 title: string
163 xp: number
164 /** 0..1 toward the next title, and the XP that title needs (undefined at the top). */
165 progress: number
166 nextAt: number | undefined
167 streak: number
168}
169
170const SKY = [0x1b1830, 0x1e1b36, 0x221f3d, 0x262b44]
171
172function hash(n: number): number {
173 const x = Math.sin(n * 127.1 + 311.7) * 43758.5453
174
175 return x - Math.floor(x)
176}
177
178export function headerRows(columns: number): number {
179 return columns >= 78 ? 10 : 6
180}
181
182/**
183 * Every looping motion of the header repeats within this many ms, so a
184 * recorded loop of it (the desktop's SVG) has no seam.
185 */
186export const PERIOD = 9600
187/** How long a reaction (the jump and confetti of a right answer) plays before it settles. */
188export const FRESH_MS = 2600
189
190/** One frame of the header at time `t` (ms), as Raster cells. */
191export function header(columns: number, scene: Scene, t: number): string {
192 return headerCanvas(columns, scene, t).encode()
193}
194
195/** One frame of the header at time `t` (ms). */
196export function headerCanvas(columns: number, scene: Scene, t: number): Canvas {
197 const rows = headerRows(columns)
198 const isBig = rows === 10
199 const c = new Canvas(columns, rows)
200 const h = rows * 2
201 const age = t - scene.since
202
203 // night sky, banded, with twinkling stars
204 for (let y = 0; y < h; y++) c.rect(0, y, columns, 1, SKY[Math.min(SKY.length - 1, Math.floor((y / h) * SKY.length))]!)
205 for (let i = 0; i < Math.floor(columns / 5); i++) {
206 const sx = Math.floor(hash(i) * columns)
207 const sy = Math.floor(hash(i + 99) * (h - 4))
208 const tw = Math.sin((2 * Math.PI * t) / 2400 + i * 1.7)
209 if (tw > 0.3) c.set(sx, sy, tw > 0.85 ? P.white : P.steel)
210 }
211
212 // the figure on the right, standing on a little stage
213 const scale = isBig ? 2 : 1
214 const figW = 11 * scale
215 const figH = 7 * scale
216 const floor = h - (isBig ? 2 : 1)
217 const figX = columns - figW - 3
218 // a spotlight from above, widening onto the stage
219 for (let y = 0; y < floor; y++) {
220 const half = Math.round(figW / 2 + 2 + (y / floor) * figW * 0.4)
221 const mid = figX + Math.floor(figW / 2)
222 c.rect(mid - half, y, half * 2, 1, y % 2 ? 0x2c2850 : 0x2a274c)
223 }
224 c.rect(figX - 3, floor, figW + 6, h - floor, P.slate)
225 c.rect(figX - 3, floor, figW + 6, 1, P.steel)
226
227 let face: Face = 'ahead'
228 let back: ArmPose = 'down'
229 let front: ArmPose = 'down'
230 let lift = 0
231 let step = 0
232 const isBlink = t % 3200 < 140
233 const fresh = age < FRESH_MS
234 switch (scene.mood) {
235 case 'idle':
236 face = isBlink ? 'blink' : 'ahead'
237 front = t % PERIOD < 1200 ? 'up' : 'down' // a wave now and then
238 break
239 case 'watching':
240 face = isBlink ? 'blink' : 'focused'
241 back = 'mid'
242 break
243 case 'thinking':
244 face = 'squint'
245 front = 'mid'
246 step = Math.floor(t / 250)
247 break
248 case 'grading':
249 face = 'focused'
250 back = 'mid'
251 front = 'mid'
252 break
253 case 'correct':
254 face = 'happy'
255 back = front = fresh ? 'up' : 'mid'
256 lift = fresh ? Math.round(Math.abs(Math.sin(age / 160)) * 3 * scale) : 0
257 break
258 case 'wrong':
259 face = 'sad'
260 break
261 case 'confused':
262 face = 'confused'
263 front = 'mid'
264 break
265 }
266 const figY = floor - figH - lift
267 scaled(c, figX, figY, claude(face, back, front, step), TINT, scale)
268
269 // what he gives off: thought dots, a question mark, confetti, a rain cloud
270 const bx = figX - 5 * scale
271 const by = Math.max(0, figY - 2)
272 if (scene.mood === 'thinking') {
273 const n = Math.floor(t / 300) % 4
274 for (let i = 0; i < n; i++) c.rect(bx + i * 2 * scale, by + 2 - i, scale, scale, P.silver)
275 } else if (scene.mood === 'grading' || scene.mood === 'confused') {
276 const q = ['.ww.', 'w..w', '..w.', '....', '..w.']
277 if (Math.floor(t / 500) % 2 === 0) scaled(c, bx, by, q, { w: P.yellow }, 1)
278 } else if (scene.mood === 'correct' && fresh) {
279 const colors = [P.red, P.amber, P.yellow, P.leaf, P.sky, P.pink, P.white]
280 for (let i = 0; i < Math.floor(columns / 2); i++) {
281 const x = Math.floor(hash(i * 3) * columns + Math.sin(age / 200 + i) * 2)
282 const y = Math.floor(hash(i * 7) * -h + (age / 1000) * (h + 6) * (0.7 + hash(i) * 0.6))
283 if (y >= 0 && y < h) c.set(x, y, colors[i % colors.length]!)
284 }
285 } else if (scene.mood === 'wrong') {
286 const cloudX = figX + Math.floor(figW / 2) - 4
287 const cloudY = Math.max(0, figY - 5)
288 scaled(c, cloudX, cloudY, ['..mmmm..', '.mmmmmm.', 'mmmmmmmm', '.ssssss.'], { m: P.steel, s: P.slate }, 1)
289 for (let i = 0; i < 4; i++) {
290 const dropY = cloudY + 4 + ((Math.floor(t / 100) + i * 3) % 6)
291 if (dropY < figY + 1) c.set(cloudX + 1 + i * 2, dropY, P.sky)
292 }
293 }
294
295 // the logo, and under it the title, XP bar and streak
296 const textBg = SKY[SKY.length - 1]!
297 const room = figX - 8 * scale
298 const logoRoom = figX - 10
299 const logoScale = isBig && logoWidth(2) + 2 <= logoRoom ? 2 : 1
300 if (logoWidth(logoScale) + 2 <= logoRoom) drawLogo(c, 2, 2, logoScale, t)
301 const textRow = rows - 1
302 const barCells = Math.max(6, Math.min(18, room - 30))
303 const filled = Math.round(scene.progress * barCells)
304 const bar = '█'.repeat(filled) + '░'.repeat(barCells - filled)
305 const xpText = scene.nextAt === undefined ? `${scene.xp} XP` : `${scene.xp}/${scene.nextAt} XP`
306 let col = 1
307 const put = (s: string, fg: number) => {
308 if (col + [...s].length > room) return
309 c.text(col, textRow, s, fg, textBg)
310 col += [...s].length
311 }
312 put(scene.title + ' ', P.sand)
313 put(bar, P.amber)
314 put(' ' + xpText, P.mist)
315 if (scene.streak > 1) put(` x${scene.streak} streak`, P.coral)
316
317 return c
318}
319
320export { DEFAULT }
321hooks/svg.ts 165 lines1// The pixel art as SVG, for surfaces that draw no Raster (the desktop app).
2// The same Canvas the terminal draws: one SVG unit per pixel (a terminal cell is
3// one unit wide and two tall), the text layer on top. The header's animation is
4// recorded as frames that differ from the first and flipped through with SMIL.
5
6import type { GameId } from '../types'
7import { FRESH_MS, headerCanvas, headerRows, iconCanvas, PERIOD } from './art'
8import type { Scene } from './art'
9import { Canvas, DEFAULT } from './canvas'
10
11/** CSS pixels per terminal column at the markup's own size. */
12const UNIT = 8
13/** The Svg element takes at most this much markup; stay clear of it. */
14const BUDGET = 120_000
15
16const color = (c: number) => `#${c.toString(16).padStart(6, '0')}`
17
18const escape = (s: string) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
19
20/** Rects for every pixel `pick` keeps, merged into horizontal runs of one color. */
21function runs(c: Canvas, pick: (x: number, y: number, color: number) => boolean): string {
22 let out = ''
23 for (let y = 0; y < c.height; y++) {
24 let x = 0
25 while (x < c.columns) {
26 const v = c.px[y * c.columns + x]!
27 if (v === DEFAULT || !pick(x, y, v)) {
28 x++
29 continue
30 }
31 let end = x + 1
32 while (end < c.columns && c.px[y * c.columns + end] === v && pick(end, y, v)) end++
33 out += `<rect x="${x}" y="${y}" width="${end - x}" height="1" fill="${color(v)}"/>`
34 x = end
35 }
36 }
37
38 return out
39}
40
41/** The text layer: each run of cells with one style as one <text>, its box behind it. */
42function texts(c: Canvas): string {
43 let out = ''
44 for (let r = 0; r < c.rows; r++) {
45 let col = 0
46 while (col < c.columns) {
47 const at = r * c.columns + col
48 if (!c.glyph[at]) {
49 col++
50 continue
51 }
52 const fg = c.fg[at]!
53 const bg = c.bg[at]!
54 let end = col + 1
55 while (end < c.columns && c.glyph[r * c.columns + end] && c.fg[r * c.columns + end] === fg && c.bg[r * c.columns + end] === bg) end++
56 const chars = Array.from(c.glyph.slice(at, r * c.columns + end), cp => String.fromCodePoint(cp)).join('')
57 if (bg !== DEFAULT) out += `<rect x="${col}" y="${r * 2}" width="${end - col}" height="2" fill="${color(bg)}"/>`
58 if (chars.trim() !== '') {
59 out +=
60 `<text x="${col}" y="${r * 2 + 1.55}" textLength="${end - col}" lengthAdjust="spacingAndGlyphs"` +
61 ` fill="${color(fg === DEFAULT ? 0xdddddd : fg)}">${escape(chars)}</text>`
62 }
63 col = end
64 }
65 }
66
67 return out
68}
69
70function wrap(c: Canvas, body: string): string {
71 return (
72 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${c.columns} ${c.height}"` +
73 ` width="${c.columns * UNIT}" height="${c.height * UNIT}" shape-rendering="crispEdges">` +
74 `<style>text{font-family:ui-monospace,'Cascadia Mono',Consolas,monospace;font-size:1.6px;white-space:pre}</style>` +
75 body +
76 '</svg>'
77 )
78}
79
80/** A still canvas (a game icon) as SVG. */
81export function canvasSvg(c: Canvas): string {
82 return wrap(c, runs(c, () => true) + texts(c))
83}
84
85export function iconSvg(id: GameId): string {
86 return canvasSvg(iconCanvas(id))
87}
88
89/** What differs in `frame` from `base`, as rects in the frame's colors. */
90function diff(base: Canvas, frame: Canvas): string {
91 return runs(frame, (x, y, v) => base.px[y * base.columns + x] !== v)
92}
93
94/** A group shown from `begin` for `dur` ms, once. */
95const once = (body: string, begin: number, dur: number) =>
96 `<g visibility="hidden">${body}<set attributeName="visibility" to="visible" begin="${begin}ms" dur="${dur}ms"/></g>`
97
98/** A group shown for the `i`th of `n` slots of a loop of `loop` ms, forever from `begin`. */
99function looped(body: string, i: number, n: number, loop: number, begin: number): string {
100 const values: string[] = []
101 const times: number[] = []
102 const add = (v: string, t: number) => {
103 if (values.at(-1) === v) return
104 values.push(v)
105 times.push(t)
106 }
107 add(i === 0 ? 'visible' : 'hidden', 0)
108 if (i > 0) add('visible', i / n)
109 if (i + 1 < n) add('hidden', (i + 1) / n)
110 const anim =
111 `<animate attributeName="visibility" values="${values.join(';')}" keyTimes="${times.map(t => +t.toFixed(5)).join(';')}"` +
112 ` calcMode="discrete" dur="${loop}ms" begin="${begin}ms" repeatCount="indefinite"/>`
113
114 return `<g visibility="hidden">${body}${anim}</g>`
115}
116
117/**
118 * The animated header as of `now`: a reaction still under way plays its rest
119 * once, then the settled mood loops. The frame rate drops until it fits.
120 */
121export function headerSvg(columns: number, scene: Scene, now: number): string {
122 const age = now - scene.since
123 const introLeft = scene.mood === 'correct' ? Math.max(0, FRESH_MS - age) : 0
124 for (const step of [100, 150, 200, 300, 400, 600, 800]) {
125 const base = headerCanvas(columns, scene, now)
126 let body = runs(base, () => true)
127 for (let t = step; t < introLeft; t += step) body += once(diff(base, headerCanvas(columns, scene, now + t)), t, step)
128 // the loop starts where the reaction ends; its own time keeps the periodic motions in phase
129 const loopStart = now + introLeft
130 const n = Math.round(PERIOD / step)
131 for (let i = 0; i < n; i++) {
132 const frame = headerCanvas(columns, scene, loopStart + i * step)
133 const changed = diff(base, frame)
134 if (changed !== '') body += looped(changed, i, n, PERIOD, introLeft)
135 }
136 const svg = wrap(base, body + texts(base))
137 if (svg.length <= BUDGET) return svg
138 }
139
140 return canvasSvg(headerCanvas(columns, scene, now))
141}
142
143let settledSvg: { key: string; svg: string } | undefined
144
145/**
146 * `headerSvg`, but once no reaction is under way the markup no longer
147 * depends on the clock: every redraw sends the same SVG, so the desktop
148 * keeps its frame (no reload, no restarted animation) and nothing is redrawn
149 * from scratch. The loop's phase is the frame's own, which nobody can see.
150 */
151export function stableHeaderSvg(columns: number, scene: Scene, now: number): string {
152 if (scene.mood === 'correct' && now - scene.since < FRESH_MS) return headerSvg(columns, scene, now)
153 // as of time 0, a reaction that began FRESH_MS earlier is already over
154 const settled = { ...scene, since: -FRESH_MS }
155 const key = JSON.stringify([columns, settled])
156 if (settledSvg?.key !== key) settledSvg = { key, svg: headerSvg(columns, settled, 0) }
157
158 return settledSvg.svg
159}
160
161/** The markup's own size in CSS pixels. */
162export function headerPixels(columns: number): { width: number; height: number } {
163 return { width: columns * UNIT, height: headerRows(columns) * 2 * UNIT }
164}
165hooks/games.ts 328 lines1import type { GameId, Profile, Puzzle } from '../types'
2
3export type Game = {
4 id: GameId
5 name: string
6 blurb: string
7 hotkey: string
8 /** Options the pane shows in this fixed order (never shuffled). */
9 fixedOptions?: string[]
10 /** What the model must produce for this game, appended to the shared rules. */
11 brief: (topic: string, level: number) => string
12}
13
14export const GAMES: Game[] = [
15 {
16 id: 'spot-the-bug',
17 name: 'Spot the Bug',
18 blurb: 'one planted bug, explain it',
19 hotkey: '1',
20 brief: (topic, level) => `GAME: Spot the Bug.
21Write a realistic ${topic} snippet of 8 to ${10 + level * 2} lines with exactly ONE planted bug that a careful reviewer could find by reading.
22Level ${level}/10: 1-3 obvious (wrong operator, off-by-one), 4-6 needs thought (mutation, async ordering, edge cases), 7-10 subtle (concurrency, numeric precision, unicode, security).
23The code must look plausible, use descriptive names, and contain NO comments that hint at the bug.
24JSON fields: title, prompt (one line of context: what the code is meant to do), code, language, bugLine (1-based line number of the bug), bug (one sentence: what is wrong and the fix), explanation (2-3 sentences teaching the lesson), quip.`,
25 },
26 {
27 id: 'what-prints',
28 name: 'What Does It Print?',
29 blurb: 'predict the output',
30 hotkey: '2',
31 brief: (topic, level) => `GAME: What Does It Print?
32Write a short ${topic} snippet (3 to ${6 + level} lines) whose output is surprising but fully deterministic and well documented (coercion, scoping, mutability, operator precedence, evaluation order, integer/float behaviour, etc.).
33Level ${level}/10: low levels are classic gotchas, high levels combine several.
34TRACE THE PROGRAM STEP BY STEP before answering; the correct option must be exactly what it prints. If ${topic} is not a programming language (e.g. Git, SQL, Shell), use its command or query output instead.
35JSON fields: title, prompt (e.g. "What does this print?"), code, language, options (exactly 4 distinct outputs, all plausible), answer (0-based index of the correct one), explanation (why, 2-3 sentences), quip.`,
36 },
37 {
38 id: 'two-truths',
39 name: 'Two Truths and a Lie',
40 blurb: 'find the false claim',
41 hotkey: '3',
42 brief: (topic, level) => `GAME: Two Truths and a Lie.
43Write three short factual-sounding claims about ${topic} (behaviour, defaults, history, standard library, tooling). Exactly one is false.
44Level ${level}/10: at high levels the lie should be a common misconception and the truths should sound surprising.
45Only use facts you are certain about.
46JSON fields: title, prompt ("Which one is the lie?"), options (the 3 claims), answer (0-based index of the LIE), explanation (correct the lie and add one surprising detail about a truth), quip.`,
47 },
48 {
49 id: 'real-or-fake',
50 name: 'Real or Hallucinated?',
51 blurb: 'does this API exist?',
52 hotkey: '4',
53 fixedOptions: ['Real', 'Hallucinated'],
54 brief: (topic, level) => `GAME: Real or Hallucinated?
55Name ONE function, method, flag, option or config key in ${topic} or its common ecosystem, shown as a call or usage line, with a one-sentence description of what it supposedly does.
56Flip a coin: half the time it must REALLY exist exactly as written, half the time it must be a convincing fabrication (a name an AI might invent).
57Level ${level}/10: high levels use obscure real APIs and fakes that are one detail off from real ones.
58Only claim "real" for things you are certain exist in current stable versions.
59JSON fields: title, prompt (the description of what it supposedly does), code (the usage line), language, options (["Real", "Hallucinated"]), answer (0 if real, 1 if hallucinated), explanation (what actually exists and where), quip.`,
60 },
61 {
62 id: 'review-court',
63 name: 'Code Review Court',
64 blurb: 'rule on a review comment',
65 hotkey: '5',
66 fixedOptions: ['Valid', 'Nitpick', 'Wrong'],
67 brief: (topic, level) => `GAME: Code Review Court.
68Show a small ${topic} diff or snippet (4 to 14 lines) from a pull request, and one reviewer comment on it.
69The comment is either Valid (points at a real problem worth fixing), a Nitpick (true but style or taste, not worth blocking), or Wrong (the reviewer is mistaken). Pick one of the three roughly at random.
70Level ${level}/10: high levels make the distinction genuinely debatable but still with one best ruling.
71JSON fields: title, prompt (the reviewer comment, quoted, prefixed by a made-up reviewer handle like "@grumpy-dev:"), code, language, options (["Valid", "Nitpick", "Wrong"]), answer (0, 1 or 2), explanation (the ruling, as a judge would deliver it, 2-3 sentences), quip.`,
72 },
73 {
74 id: 'big-o',
75 name: 'Big-O Bingo',
76 blurb: 'guess the complexity',
77 hotkey: '6',
78 brief: (topic, level) => `GAME: Big-O Bingo.
79Write a ${topic} function of 4 to ${8 + level} lines and ask for its worst-case time complexity in terms of the named input size(s).
80Level ${level}/10: high levels hide the cost in library calls (list.remove, string concatenation, includes inside loops, sorting, hash collisions are NOT a trick to use).
81If ${topic} is not a programming language, use SQL query plans or shell pipelines over n lines instead.
82JSON fields: title, prompt (e.g. "Worst-case time complexity? n = len(items)"), code, language, options (exactly 4 distinct complexities like "O(n log n)"), answer (0-based index of the correct one), explanation (where the cost comes from), quip.`,
83 },
84]
85
86export function gameOf(id: GameId): Game {
87 return GAMES.find(g => g.id === id) ?? GAMES[0]!
88}
89
90export const SYSTEM = `You are the quizmaster of Claudle, a set of quick programming games played in a terminal while a coding agent works.
91Personality: a witty, slightly smug but fair senior engineer. Puzzles must be technically correct above all; humour lives in the "title" and "quip" fields only.
92Reply with ONE JSON object and nothing else: no markdown fences, no prose before or after.
93Common JSON fields: "title" (a punny 2-6 word round name), "quip" (one short funny line shown after the answer).
94Code goes in "code" as plain text with real newlines escaped as \\n, at most 70 characters per line.`
95
96export function generationPrompt(game: Game, topic: string, level: number, avoid: string[]): string {
97 const avoidLine = avoid.length > 0 ? `\nDo not reuse these recent rounds or their ideas: ${avoid.join('; ')}.` : ''
98
99 return `${game.brief(topic, level)}${avoidLine}\nReply with the JSON object only.`
100}
101
102export function gradingPrompt(puzzle: Puzzle, typed: string): string {
103 return `GRADE an answer to a Spot the Bug round.
104Code (${puzzle.language ?? puzzle.topic}), lines numbered:
105${numbered(puzzle.code ?? '')}
106The planted bug is on line ${puzzle.bugLine}: ${puzzle.bug}
107The player answered: """${typed.slice(0, 600)}"""
108Score 0-100: 100 = right line and right reason; 60-90 = right reason, line missing or slightly off; 30-50 = right line, vague or wrong reason; 0-20 = wrong. If the player found a DIFFERENT genuine bug, give 70 and say so.
109JSON fields: score (integer), verdict (one or two sentences addressed to the player, in character).`
110}
111
112export function numbered(code: string): string {
113 return code
114 .split('\n')
115 .map((line, i) => `${String(i + 1).padStart(2)} ${line}`)
116 .join('\n')
117}
118
119/** The first JSON object in a reply, tolerating fences or prose around it. */
120export function parseJson(text: string): Record<string, unknown> | undefined {
121 const start = text.indexOf('{')
122 const end = text.lastIndexOf('}')
123 if (start < 0 || end <= start) return undefined
124 try {
125 const value: unknown = JSON.parse(text.slice(start, end + 1))
126
127 return value !== null && typeof value === 'object' && !Array.isArray(value)
128 ? (value as Record<string, unknown>)
129 : undefined
130 } catch {
131 return undefined
132 }
133}
134
135const str = (v: unknown) => (typeof v === 'string' && v.trim() !== '' ? v.trim() : undefined)
136
137/** Checks a generated round and shapes it for the pane; undefined when it is unusable. */
138export function toPuzzle(
139 game: Game,
140 topic: string,
141 level: number,
142 raw: Record<string, unknown>,
143 random: () => number = Math.random,
144): Puzzle | undefined {
145 const title = str(raw.title)
146 const prompt = str(raw.prompt)
147 const explanation = str(raw.explanation)
148 if (!title || !prompt || !explanation) return undefined
149 const code = str(raw.code)?.replace(/\t/g, ' ')
150 const base: Puzzle = {
151 game: game.id,
152 topic,
153 level,
154 title,
155 prompt,
156 explanation,
157 quip: str(raw.quip) ?? '',
158 ...(code ? { code } : {}),
159 ...(str(raw.language) ? { language: str(raw.language)!.toLowerCase() } : {}),
160 }
161
162 if (game.id === 'spot-the-bug') {
163 const bugLine = Number(raw.bugLine)
164 const bug = str(raw.bug)
165 const lines = code?.split('\n').length ?? 0
166 if (!code || !bug || !Number.isInteger(bugLine) || bugLine < 1 || bugLine > lines) return undefined
167
168 return { ...base, bugLine, bug }
169 }
170
171 const answer = Number(raw.answer)
172 if (game.fixedOptions) {
173 if (!Number.isInteger(answer) || answer < 0 || answer >= game.fixedOptions.length) return undefined
174
175 return { ...base, options: game.fixedOptions, answer }
176 }
177
178 const options = Array.isArray(raw.options) ? raw.options.map(o => String(o).trim()) : []
179 const wanted = game.id === 'two-truths' ? 3 : 4
180 if (options.length !== wanted || new Set(options).size !== wanted) return undefined
181 if (!Number.isInteger(answer) || answer < 0 || answer >= wanted) return undefined
182 if (game.id !== 'two-truths' && !code) return undefined
183
184 const order = options.map((_, i) => i)
185 for (let i = order.length - 1; i > 0; i--) {
186 const j = Math.floor(random() * (i + 1))
187 ;[order[i], order[j]] = [order[j]!, order[i]!]
188 }
189
190 return { ...base, options: order.map(i => options[i]!), answer: order.indexOf(answer) }
191}
192
193// ── Rating and scoring ────────────────────────────────────────────────
194
195export const START_RATING = 1000
196
197export function levelFor(rating: number): number {
198 return Math.min(10, Math.max(1, Math.round((rating - 700) / 100)))
199}
200
201/** Elo against a round of the given level; `score` is 0 to 100. */
202export function ratingDelta(rating: number, level: number, score: number): number {
203 const expected = 1 / (1 + 10 ** ((700 + level * 100 - rating) / 400))
204
205 return Math.round(40 * (score / 100 - expected))
206}
207
208export function pointsFor(level: number, score: number, streak: number): number {
209 if (score < 50) return 0
210 const multiplier = 1 + Math.min(streak, 10) * 0.1
211
212 return Math.round(level * 10 * (score / 100) * multiplier)
213}
214
215export const TITLES: [number, string][] = [
216 [0, 'Fresh Intern'],
217 [100, 'Rubber Duck Whisperer'],
218 [300, 'Off-By-One Survivor'],
219 [700, 'Stack Overflow Regular'],
220 [1500, 'Merge Conflict Mediator'],
221 [3000, 'Senior Stack-Trace Reader'],
222 [6000, '10x Debugger'],
223 [12000, 'Principal Pedant'],
224]
225
226export function titleFor(xp: number): string {
227 return TITLES.filter(([min]) => xp >= min).at(-1)![1]
228}
229
230/** How far `xp` is toward the next title: 0..1, and the XP that title needs. */
231export function progressFor(xp: number): { progress: number; nextAt: number | undefined } {
232 const i = TITLES.findIndex(([min]) => xp < min)
233 if (i < 0) return { progress: 1, nextAt: undefined }
234 const from = TITLES[i - 1]![0]
235 const to = TITLES[i]![0]
236
237 return { progress: (xp - from) / (to - from), nextAt: to }
238}
239
240export function emptyProfile(): Profile {
241 return { xp: 0, played: 0, correct: 0, streak: 0, bestStreak: 0, games: {}, ratings: {}, recent: {} }
242}
243
244export const LOADING_LINES = [
245 'Claude is hiding a bug where you least expect it',
246 'Consulting the ancient scrolls of Stack Overflow',
247 'Sharpening the off-by-one',
248 'Asking the rubber duck for a hard one',
249 'Generating plausible-looking nonsense (some of it true)',
250 'Reticulating splines',
251 'Bribing the type checker to look away',
252 'Writing a commit message that says "small fix"',
253]
254
255/** Topics the pane offers as toggles; any other can be added with /claudle topics. */
256export const TOPIC_PRESETS = [
257 'JavaScript',
258 'TypeScript',
259 'Python',
260 'SQL',
261 'Git',
262 'Shell',
263 'Rust',
264 'Go',
265 'Java',
266 'C#',
267 'C++',
268 'Regex',
269 'HTTP',
270 'Docker',
271 'React',
272 'CSS',
273]
274
275// ── Ask Claude: the tutor ─────────────────────────────────────────────
276
277export const TUTOR_SYSTEM = `You are Claude, a patient and encouraging programming tutor inside Claudle, a quiz game played in a terminal pane.
278Teach, don't just tell: name the underlying concept, give the mental model, and show how to recognise the pattern next time in real code.
279Keep replies short and skimmable: at most about 180 words, plain markdown (short paragraphs, bullets, code blocks of at most 10 lines). No headings.
280Correct misconceptions gently and specifically. A little humour is welcome, but teaching comes first.
281When it helps, end with ONE short check-your-understanding question or tiny exercise.
282The rounds are generated by a model and can be wrong. If the round, its answer or your earlier reply contains a mistake, say so plainly and give the correct version.`
283
284export const HINT_RULES = `THE PLAYER HAS NOT ANSWERED YET. Never reveal, confirm or rule out the answer or any option, and never name the buggy line.
285Give Socratic hints instead: point at what to look at, which rule applies, or a question to ask themselves. Each new hint may be a bit stronger than the last.
286If they ask for the answer outright, decline playfully and give a stronger hint.`
287
288/** Everything the tutor should know about the round, the player's answer, and the chat so far. */
289export function tutorPrompt(
290 puzzle: Puzzle,
291 outcome: { score: number; picked?: number; typed?: string } | undefined,
292 turns: readonly { who: 'you' | 'claude'; text: string }[],
293 question: string,
294): string {
295 const lines = [
296 `ROUND: ${gameOf(puzzle.game).name} · ${puzzle.topic} · level ${puzzle.level}/10 · "${puzzle.title}"`,
297 `Shown to the player: ${puzzle.prompt}`,
298 ]
299 if (puzzle.code) lines.push(`Code (${puzzle.language ?? puzzle.topic}):\n${numbered(puzzle.code)}`)
300 if (puzzle.options) lines.push(`Options:\n${puzzle.options.map((o, i) => ` ${'abcd'[i]}) ${o}`).join('\n')}`)
301 const answer =
302 puzzle.game === 'spot-the-bug'
303 ? `The planted bug is on line ${puzzle.bugLine}: ${puzzle.bug}`
304 : `Correct answer: ${'abcd'[puzzle.answer ?? 0]}) ${puzzle.options?.[puzzle.answer ?? 0]}`
305 lines.push(`${answer}\nExplanation given to the player afterwards: ${puzzle.explanation}`)
306 if (outcome) {
307 const said =
308 outcome.typed !== undefined
309 ? `The player answered: "${outcome.typed}"`
310 : outcome.picked !== undefined
311 ? `The player picked ${'abcd'[outcome.picked]}) ${puzzle.options?.[outcome.picked]}`
312 : 'The player gave up without answering.'
313 lines.push(`${said} (score ${outcome.score}/100).`)
314 } else {
315 lines.push(HINT_RULES)
316 }
317 if (turns.length > 0) {
318 lines.push('CONVERSATION SO FAR:')
319 for (const t of turns.slice(-8)) lines.push(`${t.who === 'you' ? 'Player' : 'You'}: ${t.text}`)
320 }
321 lines.push(`PLAYER'S QUESTION: ${question.slice(0, 1000)}`)
322
323 return lines.join('\n\n')
324}
325
326export const ASK_BEFORE = ['Give me a hint', 'What concept is this testing?']
327export const ASK_AFTER = ['Explain it step by step', 'Show me a similar example', 'How do I spot this in real code?']
328hooks/canvas.ts 162 lines1// Copied from the agent-comic mod (~/.claude/mods/agent-comic/hooks/canvas.ts).
2// A pixel canvas two pixels tall per terminal cell (half blocks), with a text
3// layer on top, encoded into a Raster's cells.
4
5export const DEFAULT = 0x01000000
6
7const UPPER = 0x2580
8const LOWER = 0x2584
9
10export class Canvas {
11 readonly px: Uint32Array
12 readonly glyph: Uint32Array
13 readonly fg: Uint32Array
14 readonly bg: Uint32Array
15 readonly height: number
16
17 constructor(readonly columns: number, readonly rows: number) {
18 this.height = rows * 2
19 this.px = new Uint32Array(columns * this.height).fill(DEFAULT)
20 this.glyph = new Uint32Array(columns * rows)
21 this.fg = new Uint32Array(columns * rows)
22 this.bg = new Uint32Array(columns * rows)
23 }
24
25 set(x: number, y: number, color: number) {
26 x = Math.round(x)
27 y = Math.round(y)
28 if (x < 0 || y < 0 || x >= this.columns || y >= this.height) return
29 // painter's order: a pixel drawn later covers any text drawn into its cell before.
30 // It covers only its half, though: the other half keeps the text's background
31 // (a sign's board), or the board would show a gap above a figure walking past it.
32 const cell = (y >> 1) * this.columns + x
33 if (this.glyph[cell]) {
34 if (this.bg[cell] !== DEFAULT) this.px[(y ^ 1) * this.columns + x] = this.bg[cell]!
35 this.glyph[cell] = 0
36 }
37 this.px[y * this.columns + x] = color
38 }
39
40 rect(x: number, y: number, w: number, h: number, color: number) {
41 for (let j = 0; j < h; j++) for (let i = 0; i < w; i++) this.set(x + i, y + j, color)
42 }
43
44 /** Draws rows of palette letters, '.' transparent; `flip` mirrors it. */
45 sprite(x: number, y: number, rows: readonly string[], palette: Record<string, number>, flip = false) {
46 for (let j = 0; j < rows.length; j++) {
47 const row = rows[j]!
48 for (let i = 0; i < row.length; i++) {
49 const ch = row[flip ? row.length - 1 - i : i]!
50 const color = palette[ch]
51 if (color !== undefined) this.set(x + i, y + j, color)
52 }
53 }
54 }
55
56 /** Writes text into the cell layer; non-printable or wide characters become '?'. */
57 text(col: number, row: number, s: string, fg: number, bg = DEFAULT) {
58 col = Math.round(col)
59 if (row < 0 || row >= this.rows) return
60 let i = 0
61 for (const ch of s) {
62 const c = col + i++
63 if (c < 0 || c >= this.columns) continue
64 const at = row * this.columns + c
65 this.glyph[at] = safe(ch)
66 this.fg[at] = fg
67 this.bg[at] = bg
68 }
69 }
70
71 /** A rounded box around `lines`, top-left at (col, row). */
72 box(col: number, row: number, lines: readonly string[], fg: number, bg: number, border: number) {
73 if (!lines.length) return
74 const inner = Math.max(...lines.map(l => [...l].length))
75 this.text(col, row, '╭' + '─'.repeat(inner + 2) + '╮', border, bg)
76 lines.forEach((l, i) => {
77 this.text(col, row + 1 + i, '│', border, bg)
78 this.text(col + 1, row + 1 + i, ' ' + l + ' '.repeat(inner - [...l].length + 1), fg, bg)
79 this.text(col + inner + 3, row + 1 + i, '│', border, bg)
80 })
81 this.text(col, row + lines.length + 1, '╰' + '─'.repeat(inner + 2) + '╯', border, bg)
82 }
83
84 /** The Raster `cells`: base64 of [codePoint, fg, bg] u32 triplets, row-major. */
85 encode(): string {
86 const { columns, rows } = this
87 const out = new Uint32Array(columns * rows * 3)
88 for (let r = 0; r < rows; r++) {
89 for (let c = 0; c < columns; c++) {
90 const cell = r * columns + c
91 const o = cell * 3
92 if (this.glyph[cell]) {
93 out[o] = this.glyph[cell]!
94 out[o + 1] = this.fg[cell]!
95 out[o + 2] = this.bg[cell] === DEFAULT ? this.px[(2 * r + 1) * columns + c]! : this.bg[cell]!
96 continue
97 }
98 const top = this.px[2 * r * columns + c]!
99 const bottom = this.px[(2 * r + 1) * columns + c]!
100 if (top === DEFAULT && bottom === DEFAULT) {
101 out[o] = 0x20; out[o + 1] = DEFAULT; out[o + 2] = DEFAULT
102 } else if (top === bottom) {
103 // a solid cell is a coloured space, not a full block: no glyph for a terminal's
104 // minimum-contrast setting (VS Code's is on by default) to brighten against its own colour
105 out[o] = 0x20; out[o + 1] = top; out[o + 2] = top
106 } else if (top === DEFAULT) {
107 out[o] = LOWER; out[o + 1] = bottom; out[o + 2] = DEFAULT
108 } else {
109 out[o] = UPPER; out[o + 1] = top; out[o + 2] = bottom
110 }
111 }
112 }
113 return base64(new Uint8Array(out.buffer))
114 }
115}
116
117function safe(ch: string): number {
118 const cp = ch.codePointAt(0) ?? 0x3f
119 if (cp < 0x20 || (cp >= 0x7f && cp < 0xa0) || cp > 0xffff) return 0x3f
120 // East Asian wide ranges and emoji draw two cells wide: refuse them here.
121 if ((cp >= 0x1100 && cp <= 0x115f) || (cp >= 0x2e80 && cp <= 0xa4cf) || (cp >= 0xac00 && cp <= 0xd7a3) ||
122 (cp >= 0xf900 && cp <= 0xfaff) || (cp >= 0xfe30 && cp <= 0xfe4f) || (cp >= 0xff00 && cp <= 0xff60) ||
123 (cp >= 0xffe0 && cp <= 0xffe6) || (cp >= 0xd800 && cp <= 0xdfff)) return 0x3f
124 // emoji (✅ ⚡ ⭐) draw two cells wide, marks and format characters none: the host refuses both
125 if (UNFIT.test(ch)) return 0x3f
126 return cp
127}
128
129const UNFIT = /[\p{Emoji_Presentation}\p{Mn}\p{Me}\p{Cf}]/u
130
131/**
132 * Text as the canvas can draw it, one code point to a cell: accents composed onto
133 * their letters, emoji and invisible marks dropped (anything else unfit becomes '?').
134 */
135export function cellText(s: string): string {
136 return s
137 .normalize('NFC')
138 .replace(/\p{Emoji_Presentation}|\p{Extended_Pictographic}️|[\p{Mn}\p{Me}\p{Cf}]/gu, '')
139 .replace(/\s+/g, ' ')
140 .trim()
141}
142
143const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
144
145export function base64(bytes: Uint8Array): string {
146 let out = ''
147 let i = 0
148 for (; i + 2 < bytes.length; i += 3) {
149 const n = (bytes[i]! << 16) | (bytes[i + 1]! << 8) | bytes[i + 2]!
150 out += ALPHABET[(n >> 18) & 63]! + ALPHABET[(n >> 12) & 63]! + ALPHABET[(n >> 6) & 63]! + ALPHABET[n & 63]!
151 }
152 const rest = bytes.length - i
153 if (rest === 1) {
154 const n = bytes[i]! << 16
155 out += ALPHABET[(n >> 18) & 63]! + ALPHABET[(n >> 12) & 63]! + '=='
156 } else if (rest === 2) {
157 const n = (bytes[i]! << 16) | (bytes[i + 1]! << 8)
158 out += ALPHABET[(n >> 18) & 63]! + ALPHABET[(n >> 12) & 63]! + ALPHABET[(n >> 6) & 63]! + '='
159 }
160 return out
161}
162types/index.d.ts 77 lines1export type GameId = 'spot-the-bug' | 'what-prints' | 'two-truths' | 'real-or-fake' | 'review-court' | 'big-o'
2
3/** One generated round, as the pane shows it. */
4export type Puzzle = {
5 /** Unique per generated round; the Ask Claude chat is keyed on it. */
6 id?: string
7 game: GameId
8 topic: string
9 level: number
10 title: string
11 prompt: string
12 code?: string
13 language?: string
14 /** Choice games: the options in display order, and which one is right. */
15 options?: string[]
16 answer?: number
17 /** Spot the Bug: the planted bug, kept hidden until graded. */
18 bugLine?: number
19 bug?: string
20 explanation: string
21 quip: string
22}
23
24export type Outcome = {
25 /** 0 to 100: a choice is 0 or 100, a free answer is graded. */
26 score: number
27 verdict: string
28 /** The option picked, for a choice game. */
29 picked?: number
30 /** What the person typed, for a free answer. */
31 typed?: string
32 points: number
33 ratingDelta: number
34}
35
36export type View =
37 | { kind: 'menu' }
38 | { kind: 'loading'; game: GameId; line: string }
39 | { kind: 'question'; puzzle: Puzzle }
40 | { kind: 'grading'; puzzle: Puzzle; typed: string }
41 | { kind: 'feedback'; puzzle: Puzzle; outcome: Outcome }
42 | { kind: 'error'; game: GameId; message: string }
43 | { kind: 'stats' }
44 | { kind: 'topics' }
45
46export type Profile = {
47 xp: number
48 played: number
49 correct: number
50 streak: number
51 bestStreak: number
52 /** Rounds played and won per game. */
53 games: Partial<Record<GameId, { played: number; correct: number }>>
54 /** Elo-style rating per topic; the level of the next round follows it. */
55 ratings: Record<string, number>
56 /** Recent round titles per game, so the model does not repeat itself. */
57 recent: Record<string, string[]>
58}
59
60export type ChatTurn = { who: 'you' | 'claude'; text: string }
61
62/** The Ask Claude conversation about the round on screen. */
63export type Chat = {
64 /** Which round it belongs to; a new round starts a new chat. */
65 round: string
66 turns: ChatTurn[]
67 isPending: boolean
68 /** Shown while a round is still open (it always shows once answered). */
69 isOpen: boolean
70}
71
72declare module 'claude-code' {
73 interface PluginState {
74 claudle: { view: View; profile: Profile; topics: string[]; agentDone: boolean; chat: Chat }
75 }
76}
77