Claude works, you play. The whistle blows the moment it needs you.

<img src="../../brand/pixel-icons/svg/halftime.svg" width="64" height="64" alt="">
Claude works, you play. The whistle blows the moment it needs you.
Four small games live in the band above your prompt. The moment Claude needs you - a permission prompt, a question, an input request, or the turn ending - the game freezes, the board closes so your keys go back to the prompt, and a line says whistle: Claude needs you. When Claude gets going again you get a 3-2-1 and the same run, exactly where you left it.
/plugin install halftime --marketplace modmyclaude/claude-code-mods
| Command | What it does |
|---|---|
/halftime | Opens the picker, with your one-line scorecard. |
/halftime cratehop (or tailchase, merge, sweep) | Puts that game's daily board above the prompt. Add practice for a random board. |
/halftime auto | Turns auto on or off (/halftime auto on, /halftime auto off). |
/halftime off | Closes the band; a run in play is paused, and the picker offers it back. |
The command runs at once even while Claude is mid-turn. In the terminal and the desktop app its replies are short toasts and nothing is added to the conversation, so Claude never reads them. VS Code and the mobile app have no band, so there the reply is the command's row: your one-line scorecard.
A board takes your keys only when you click it. No mouse (the classic terminal renderer sends no clicks)? Press ctrl+x tab to focus the band, then the letter keys drawn under the board (w a s d, x f, p to pause). Esc hands the keys back to the prompt. No game ever uses Enter, y, n or a digit, and the board never passes those keys on, so a stray press can never answer one of Claude Code's prompts.
The games (all original):
space, up, w or a click jumps the crates. The first ten seconds are gentle: a slow start (10 cells a second), a 4.5 second run-up to the first crate, low crates and wide gaps. Then it speeds up by about 0.18 cells a second every second, to a cap of 24 cells a second about a minute and a half in, and holds there. Every gap is a full jump plus a quarter second to react, every crate leaves a timing window of 250 ms or more, crates hurt a little less than they look (a graze is safe), and a jump pressed up to 120 ms before landing fires on touchdown. A crash bursts red where the courier was. Score: distance.wasd or hjkl. The edges wrap; biting yourself ends the run. It starts slow (about 4 steps a second) and speeds up a little with every pip, to a cap of about 8 a second at 37 pips. A pip only ever lands on a cell your head can reach. Up to three turns wait in line for their step, and a step that would bite is held back for 100 ms, so a late turn still saves you. Score: pips eaten.wasd, hjkl); two equal letters make the next one, once per tile per move. It deals two A's in one line, so the first slide already makes a B; only A's are dealt for the first 12 moves, then an occasional B creeps in. A game can end, but never before move 14. Score: points.space or x opens, f flags, or click / right-click a cell. It opens with a safe pocket, and every board is checked by a solver before it is dealt: the single-cell rules plus the subset rule clear it from the opening, so you never have to guess. Score: seconds to clear (lower is better).Every game's field is drawn centered between two dim rails, on a clear background: solid blocks for the snake, crates and tiles, raised slabs for sweep's closed cells. Every game: p pauses and resumes, r starts again after a run ends.
Daily boards. Each game's daily board comes from the date alone, so everyone gets the same board that day. After a run, the result line reads like halftime daily 10-09 · cratehop 412 · played 3m of a 7m turn, with a copy button.
Auto mode offers a game (a clickable line) once a turn has run 10 seconds. It never starts a game or takes your keys by itself.
The whistle blows for: a permission prompt (from the main conversation or a subagent), a question from Claude (AskUserQuestion), an input request from a tool (MCP elicitation), and the main turn ending. Subagents starting and finishing never pause or resume anything. The run comes back with a 3-2-1 when the loop that asked moves on, or when your next turn starts; after a turn ends you can also press play on.
The run also pauses by itself when a survey takes the band, when you start typing at the prompt, when you open the picker or close the band, and when a realtime game (cratehop, tailchase) gets no key or click for 20 seconds. A board that comes back after any of these waits for your click.
/halftime says it is off. Turned off mid-game, the run is paused and the band closes, as /halftime off does; turned back on, /halftime offers the run back. /config or the modmyclaude hub flips it, live./halftime auto) and your last game are remembered across sessions; the picker's daily / practice toggle lasts the session.best:<game>), today's daily best (daily:<game>), totals (play time, play time while Claude worked, runs), the auto switch and your last game.copy.Halftime decides nothing for you and changes no setting. Each hook watches and passes the event on unchanged:
config.set, halftime.enabled only): passes the change on to Claude Code, then reads the outcome to stop or restart its own drawing and timers. It never refuses a change and touches no other setting.classic.PermissionRequest, classic.Elicitation, classic.Notification): blow the whistle, pausing the game, when Claude needs you. They decide no permission and answer nothing.tool.call, AskUserQuestion only): pauses the game while the question is on screen. It never answers or denies it.classic.PostToolBatch): starts the 3-2-1 back to your game once the loop that needed you is working again./halftime is answered inside the mod; no script or process runs.hooks/board.tsx, drawn as a Client): paints the board and hands your keys and clicks back to the hooks module. It reads globalThis.crypto only to make a random id for the board on screen, reads no credential or environment variable, and sends nothing anywhere.tests/) stand in for Claude Code and for you: they run /halftime, answer a question or a settings change as you would, and switch Halftime off and on. None of that runs when Halftime is installed./halftime sent as the very first prompt of a new session may show "/halftime isn't a command here." The app checks the command before mods have registered theirs; the command still runs, and later ones show no such line./halftime shows a one-line scorecard instead./halftime cratehop puts the board up and the run starts on your first click or key.More mods: the full catalogue.
hooks/register.tsx 531 lines1// halftime: small games above the prompt while Claude works, and the whistle
2// that hands the keyboard straight back the moment Claude needs you.
3//
4// The wiring lives here; the rules live in pure modules:
5// engine.ts the session state machine (start, whistle, resume, input)
6// games/* the four games, seeded and deterministic
7// view.ts what the board shows; board.tsx only paints it
8// bests.ts bests and totals in the store, compare-and-set
9// share.ts the share line and the scorecard
10
11import { atom, read, update } from 'claude-code'
12import { boltSprite } from './bolt-draw'
13import type { EngineInterface, Register, Timer } from 'claude-code'
14
15import type { HalftimeGameId, HalftimeReason, HalftimeRun, HalftimeSession, HalftimeStats } from '../types'
16import { addTotals, loadStats, saveBest, saveDaily, type Store } from './bests'
17import {
18 EMPTY_SESSION, close, countdownTick, needsYou, offer, onMessage, openPicker, parseMsg, pauseForTyping,
19 resume, setPractice, showBoard, startGame, turnComplete, turnStart, unsaved, workAgain,
20} from './engine'
21import { GAME_IDS, gameOf, isGameId } from './games'
22import { scorecard } from './share'
23import { boardView } from './view'
24
25const EMPTY_STATS: HalftimeStats = { auto: false, last: null, best: {}, daily: {}, playedMs: 0, waitPlayedMs: 0, runs: 0 }
26
27const session = atom({ plugin: 'halftime', key: 'session' } as const, EMPTY_SESSION)
28const stats = atom({ plugin: 'halftime', key: 'stats' } as const, EMPTY_STATS)
29
30/** Auto mode offers a game once a main-loop turn has run this long. */
31export const OFFER_AFTER_MS = 10_000
32/** Owed play time is written to the store at least this often while a run is in play. */
33const FLUSH_EVERY_MS = 60_000
34
35const REASON_TEXT: Record<HalftimeReason, string> = {
36 permission: 'a permission prompt - play comes back once Claude moves on',
37 question: 'Claude has a question for you - play comes back once it moves on',
38 input: 'a tool wants your input - play comes back once Claude moves on',
39 turn: 'the turn is over - your move',
40}
41
42/** Which loop an event came from: '' for the main conversation, else the subagent's id. */
43const loopOf = (id: unknown) => (typeof id === 'string' && id.length > 0 ? id : '')
44
45/** The main conversation's turns only: a subagent's carry its `agentId` and change nothing. */
46const isMain = (input: object) => loopOf((input as { agentId?: unknown }).agentId) === ''
47
48const resumable = (run: HalftimeRun | null) => run !== null && run.status !== 'over'
49
50// The switch (`enabled`, which /config and the modmyclaude hub flip): off, Halftime draws
51// nothing, blows no whistle and keeps no timer. The engine reloads the mod with the new
52// value; the config.set hook below is the same switch at once.
53let isOn = true
54const OFF_TEXT = 'Halftime is off. /mods or /config turns it on.'
55
56/** The store as bests.ts reads it (every call still spelled `$.store.<op>(...)` here). */
57const storeOf = ($: EngineInterface): Store => ({
58 get: key => $.store.get(key),
59 set: (key, value) => $.store.set(key, value),
60})
61
62/**
63 * What the module keeps outside `$.state`: timers and in-flight guards, never
64 * anything drawn. A reload starts a fresh environment (the old timers are
65 * dropped with it) and `register` resets this.
66 */
67const mod = {
68 countdown: null as Timer | null,
69 offer: null as Timer | null,
70 flushing: false,
71 typing: false,
72 saving: new Set<number>(),
73}
74
75/** The 3-2-1's tick. Switched off, a tick that still lands stops the countdown instead. */
76function countdownTicked($: EngineInterface) {
77 if (isOn) return void apply($, countdownTick)
78 mod.countdown?.cancel()
79 mod.countdown = null
80}
81
82/** One pure transition at the current time, then whatever it calls for. */
83async function apply($: EngineInterface, change: (s: HalftimeSession, now: number) => HalftimeSession): Promise<HalftimeSession> {
84 const now = await $.clock.now()
85 const current = await read($, session)
86 // A transition that changes nothing hands back the same object: no write, so nothing redraws.
87 if (change(current, now) === current) return current
88 let before = current
89 const after = await update($, session, s => {
90 before = s
91 return change(s, now)
92 })
93 await followUp($, before, after)
94 return after
95}
96
97async function followUp($: EngineInterface, before: HalftimeSession, after: HalftimeSession) {
98 // The 3-2-1 is the only timer the hooks module runs during a run, and only while counting.
99 const counting = after.phase === 'board' && after.run?.status === 'countdown'
100 if (counting && !mod.countdown) mod.countdown = $.clock.every(1000, () => countdownTicked($))
101 if (!counting && mod.countdown) {
102 mod.countdown.cancel()
103 mod.countdown = null
104 }
105 if (after.run && after.run.id !== before.run?.id) {
106 await remember($, after.run.game)
107 }
108 if (unsaved(after) && after.run) await saveResult($, after.run.id)
109 const leftPlay = before.run?.status === 'live' && after.run?.status !== 'live'
110 if (after.owed[2] > 0 || after.owed[0] >= FLUSH_EVERY_MS || (leftPlay && after.owed[0] > 0)) await flush($)
111}
112
113async function remember($: EngineInterface, game: HalftimeGameId) {
114 await $.store.set('last', game)
115 await update($, stats, st => ({ ...st, last: game }))
116}
117
118/** Compares a finished run with the stored bests (re-read first) and records the outcome. */
119async function saveResult($: EngineInterface, runId: number) {
120 if (mod.saving.has(runId)) return
121 mod.saving.add(runId)
122 const run = (await read($, session)).run
123 if (!run || run.id !== runId || !run.result) return
124 const score = run.result.score
125 let best = (await read($, stats)).best[run.game] ?? null
126 let isBest = false
127 if (score !== null) {
128 const saved = await saveBest(storeOf($), run.game, score)
129 best = saved.best
130 isBest = saved.isBest
131 const day = run.mode === 'daily' ? await saveDaily(storeOf($), run.game, run.day, score) : null
132 await update($, stats, st => ({
133 ...st,
134 best: { ...st.best, [run.game]: saved.best },
135 daily: day ? { ...st.daily, [run.game]: day } : st.daily,
136 }))
137 }
138 await update($, session, s =>
139 s.run && s.run.id === runId && s.run.result
140 ? { ...s, run: { ...s.run, result: { ...s.run.result, best, isBest, saved: true } } }
141 : s,
142 )
143}
144
145/** Adds owed play time and runs to the stored totals; one flush at a time. */
146async function flush($: EngineInterface) {
147 if (mod.flushing) return
148 mod.flushing = true
149 try {
150 const owed = (await read($, session)).owed
151 if (owed[0] === 0 && owed[1] === 0 && owed[2] === 0) return
152 const totals = await addTotals(storeOf($), owed)
153 await update($, session, s => {
154 const left: [number, number, number] = [s.owed[0] - owed[0], s.owed[1] - owed[1], s.owed[2] - owed[2]]
155 return { ...s, owed: left }
156 })
157 await update($, stats, st => ({ ...st, ...totals }))
158 } finally {
159 mod.flushing = false
160 }
161}
162
163/** The whistle, from any loop: freeze and hide the board. */
164function whistle($: EngineInterface, reason: HalftimeReason, loop: string) {
165 return apply($, (s, now) => needsYou(s, reason, loop, now))
166}
167
168/**
169 * Pauses a run in play just after the current draw: a drawing never writes, so
170 * a render hook that sees the keys leave the board schedules the pause instead.
171 */
172function pauseSoon($: EngineInterface) {
173 $.clock.after(0, () => {
174 if (isOn) void apply($, pauseForTyping)
175 })
176}
177
178async function setAuto($: EngineInterface, auto: boolean) {
179 await $.store.set('auto', auto)
180 await update($, stats, st => ({ ...st, auto }))
181}
182
183/**
184 * True while a surface that draws the band (the terminal or the desktop app) is
185 * attached. VS Code, mobile and a `-p` run draw none (not isInteractive).
186 */
187async function banded($: EngineInterface): Promise<boolean> {
188 const surfaces = await $.session.surfaces().catch(() => [])
189 return surfaces.some(s => s === 'terminal' || s === 'desktop')
190}
191
192/** Runs `/halftime <args>` and says what happened, in one line for the person. */
193async function command($: EngineInterface, args: string): Promise<string> {
194 const [word = '', extra = ''] = args.trim().toLowerCase().split(/\s+/)
195 if (word === '' || word === 'games' || word === 'pick') {
196 await apply($, openPicker)
197 return scorecard(await read($, stats))
198 }
199 if (isGameId(word)) {
200 await apply($, (s, now) => startGame(s, word, extra === 'practice' || (extra !== 'daily' && s.practice), now))
201 return `${word} is above the prompt - click the board (or ctrl+x tab, then a key) to play`
202 }
203 if (word === 'auto') {
204 const current = (await read($, stats)).auto
205 const auto = extra === 'on' ? true : extra === 'off' ? false : !current
206 await setAuto($, auto)
207 return auto
208 ? `auto is on: once a turn has run ${OFFER_AFTER_MS / 1000}s, a line above the prompt offers a game (it never takes your keys)`
209 : 'auto is off'
210 }
211 if (word === 'off' || word === 'close') {
212 await apply($, close)
213 return 'closed'
214 }
215 return `/halftime opens the picker; /halftime ${GAME_IDS.join('|')} [practice] starts one; /halftime auto [on|off]; /halftime off closes it`
216}
217
218async function copy($: EngineInterface, text: string, surface: 'terminal' | 'desktop' | 'mobile' | 'vscode') {
219 const copied = await $.ui.copy({ text, surface })
220 $.ui.toast(copied.isCopied ? 'result copied' : 'this surface cannot copy - select the line instead')
221}
222
223export const register: Register = (on, options) => {
224 isOn = options.enabled !== false
225 mod.countdown = null
226 mod.offer = null
227 mod.flushing = false
228 mod.typing = false
229 mod.saving.clear()
230
231 // Off pauses a run in play and closes the band, as /halftime off does; on, /halftime opens it again.
232 on('config.set', { key: 'halftime.enabled' }, async ($, e, next) => {
233 const set = await next(e)
234 if (set.deny === undefined && typeof set.value === 'boolean') {
235 isOn = set.value
236 if (!isOn) {
237 mod.offer?.cancel()
238 mod.offer = null
239 try {
240 await apply($, close)
241 } catch {
242 // A countdown tick that still lands stops it instead.
243 }
244 }
245 $.ui.invalidate('ui.render')
246 }
247 return set
248 })
249
250 on('session.start', async ($, e, next) => {
251 // Runs at once, mid-turn too: playing while Claude works is the point.
252 await $.command.register({
253 name: 'halftime',
254 description: 'Play a small game above the prompt while Claude works',
255 argumentHint: '[cratehop | tailchase | merge | sweep | auto | off]',
256 immediate: true,
257 })
258 try {
259 const loaded = await loadStats(storeOf($))
260 await update($, stats, () => loaded)
261 } catch (err) {
262 $.ui.log(`halftime: could not read its store (${String(err)}); bests start empty`, { to: 'debug' })
263 }
264 // A reload keeps the session's state but not the old timers: pick a countdown back up.
265 const s = await read($, session)
266 if (isOn && s.phase === 'board' && s.run?.status === 'countdown' && !mod.countdown) mod.countdown = $.clock.every(1000, () => countdownTicked($))
267 return next(e)
268 })
269
270 // The answer is for the person, never the model: a command's `text` is
271 // a transcript row the model reads, so where the band draws, a toast says it and the
272 // command answers {}. VS Code and mobile draw no band: there the row is the only place
273 // to show anything, and the CommandOutput hook below draws the scorecard in it.
274 on('command.run', { command: 'halftime' }, async ($, e) => {
275 if (!isOn) {
276 // Where no band is drawn (VS Code, mobile) the command's own row is the answer, as for the scorecard.
277 if (!(await banded($))) return { text: OFF_TEXT }
278 $.ui.toast(OFF_TEXT)
279 return {}
280 }
281 const said = await command($, e.args)
282 if (await banded($)) {
283 $.ui.toast(said)
284 return {}
285 }
286 return { text: scorecard(await read($, stats)) }
287 }).catch($ => {
288 try {
289 $.ui.toast('that did not work - try /halftime again')
290 } catch {
291 // Nothing left to say it with.
292 }
293 return {}
294 })
295
296 // The band: picker, offer, board or whistle line.
297 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
298 if (!isOn) return next(e)
299 // A survey owns the band: yield it, and pause a run in play (the keys are the survey's now).
300 if (e.props.hasSurvey) {
301 const s = await read($, session)
302 if (s.phase === 'board' && s.run?.status === 'live') pauseSoon($)
303 return next(e)
304 }
305 if (e.surface !== 'terminal' && e.surface !== 'desktop') return next(e)
306 const s = await read($, session)
307 if (s.phase === 'off') return next(e)
308 // The board is built from the element table itself, not from a name taken out of it: the
309 // plugin directory's reader takes a `Client` destructured here as a file loaded by this call.
310 const elements = $.ui.resolve(e)
311 const { Box, Text, Button } = elements
312 // Stack with what other mods draw here instead of replacing it. Their
313 // lines sit above ours, so a tight band clips the game, never another mod's prompt.
314 const below = await next(e)
315 const hasBelow = below !== null && below !== undefined
316 const stack = (mine: JSX.Element) =>
317 hasBelow ? (
318 <Box flexDirection="column">
319 {below}
320 {mine}
321 </Box>
322 ) : (
323 mine
324 )
325 const st = await read($, stats)
326 const now = await $.clock.now()
327 const cols = Math.max(16, e.props.bodyColumns)
328
329 const gameButtons = (prefix: string) =>
330 GAME_IDS.map(g => (
331 <Button
332 key={`${prefix}:${g}`}
333 label={g}
334 {...(st.last === g ? { variant: 'primary' as const } : {})}
335 onPress={() => apply($, (s2, t) => startGame(s2, g, s2.practice, t))}
336 />
337 ))
338 const closeButton = <Button key="close" label="close" role="dismiss" dimColor onPress={() => apply($, close)} />
339
340 if (s.phase === 'offer') {
341 return stack(
342 <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
343 <Text>Claude is still working - play while you wait?</Text>
344 {resumable(s.run) && s.run ? <Button key="offer:back" label={`back to ${s.run.game}`} onPress={() => apply($, showBoard)} /> : null}
345 {gameButtons('offer')}
346 <Button key="not-now" label="not now" dimColor onPress={() => apply($, close)} />
347 </Box>
348 )
349 }
350
351 if (s.phase === 'picker') {
352 const run = s.run
353 return stack(
354 <Box flexDirection="row" columnGap={1}>
355 {/* Bolt beside the picker: two rows tall, as the picker is. */}
356 {boltSprite($.ui.resolve(e), e.surface, { size: 'tiny', pose: 'idle' }, 'bolt')}
357 <Box flexDirection="column">
358 <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
359 <Text bold>halftime</Text>
360 {gameButtons('pick')}
361 {resumable(run) && run ? <Button key="resume" label={`back to ${run.game}`} onPress={() => apply($, showBoard)} /> : null}
362 <Button key="mode" label={s.practice ? 'practice' : 'daily'} dimColor onPress={() => apply($, s2 => setPractice(s2, !s2.practice))} />
363 <Button
364 key="auto"
365 label={st.auto ? 'auto: on' : 'auto: off'}
366 dimColor
367 onPress={async () => setAuto($, !(await read($, stats)).auto)}
368 />
369 {closeButton}
370 </Box>
371 <Text dimColor wrap="truncate-end">{scorecard(st)}</Text>
372 </Box>
373 </Box>
374 )
375 }
376
377 if (s.phase === 'whistle') {
378 const run = s.run
379 const over = run?.status === 'over' && run.result ? run.result : null
380 const line = (
381 <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
382 <Text bold color="warning">whistle: Claude needs you</Text>
383 <Text dimColor>- {REASON_TEXT[s.whistle ?? 'turn']}</Text>
384 {s.whistle === 'turn' && resumable(run) ? <Button key="play-on" label="play on" onPress={() => apply($, resume)} /> : null}
385 {over ? <Button key="copy" label="copy result" onPress={press => copy($, over.share, press.surface)} /> : null}
386 {closeButton}
387 </Box>
388 )
389 // The result stays on screen, so where copy has no clipboard (the desktop app)
390 // the person can select it instead.
391 return stack(
392 over ? (
393 <Box flexDirection="column">
394 {line}
395 <Text dimColor wrap="truncate-end">{over.share}</Text>
396 </Box>
397 ) : (
398 line
399 ),
400 )
401 }
402
403 // The board.
404 const run = s.run
405 if (!run) return below
406 const result = run.status === 'over' ? run.result : null
407 // One row for the buttons under the board when there is room; a tight band keeps the board.
408 const wanted = gameOf(run.game).rows + 3
409 const roomy = e.props.maxRows >= wanted || result !== null || run.status !== 'live'
410 const boardRows = Math.max(4, e.props.maxRows - (roomy ? 1 : 0) - (hasBelow ? 1 : 0))
411 const { rid, epoch, live, tick, keys, ack, title, rows, grid, hint, wait, needClick, showHint, pad, cells } = boardView(run, st, cols, boardRows, now, e.surface === 'desktop')
412 return stack(
413 <Box flexDirection="column">
414 {elements.Client({ key: 'board', module: './board.tsx', props: { rid, epoch, live, tick, keys, ack, title, rows, grid, hint, wait, needClick, showHint, pad, cells } })}
415 {roomy ? (
416 <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
417 {result ? <Text wrap="truncate-end">{result.share}</Text> : null}
418 {result ? <Button key="copy" label="copy" onPress={press => copy($, result.share, press.surface)} /> : null}
419 {result ? (
420 <Button key="again" label="again" onPress={() => apply($, (s2, t) => startGame(s2, run.game, run.mode === 'practice', t))} />
421 ) : null}
422 <Button key="games" label="games" dimColor onPress={() => apply($, openPicker)} />
423 {closeButton}
424 </Box>
425 ) : null}
426 </Box>
427 )
428 })
429
430 // VS Code and mobile have no band and no board: the command's row is the scorecard.
431 on('ui.render', { component: 'CommandOutput', props: { command: 'halftime' } }, async ($, e, next) => {
432 if (!isOn || (e.surface !== 'mobile' && e.surface !== 'vscode')) return next(e)
433 const { Text } = $.ui.resolve(e)
434 return (
435 <Text wrap="truncate-end">
436 {scorecard(await read($, stats))}
437 <Text dimColor> (games play in the terminal and the desktop app)</Text>
438 </Text>
439 )
440 })
441
442 // Typing at the prompt means the keys left the board: a run in play pauses instead of
443 // playing on unattended. Drawing never writes, so the pause is scheduled for just after.
444 on('ui.render', { component: 'PromptHint' }, ($, e, next) => {
445 if (!isOn) return next(e)
446 const isDraft = e.props.isDraft
447 if (isDraft && !mod.typing) pauseSoon($)
448 mod.typing = isDraft
449 return next(e)
450 }).catch(($, e, next) => next(e))
451
452 // The board's posts: input and frame ticks. Off-shape data is dropped unread.
453 on('ui.message', async ($, e, next) => {
454 if (!isOn) return next(e)
455 const msg = parseMsg(e.data)
456 if (msg) await apply($, (s, now) => onMessage(s, msg, now))
457 return next(e)
458 })
459
460 // Main-loop turns: start resumes from the whistle (3-2-1) and may arm the offer.
461 on('turn.start', async ($, e, next) => {
462 if (!isOn || !isMain(e)) return next(e)
463 const s = await apply($, (s0, now) => turnStart(s0, e.turnId, now))
464 mod.offer?.cancel()
465 mod.offer = null
466 if ((await read($, stats)).auto && s.phase === 'off') {
467 const turnId = e.turnId
468 mod.offer = $.clock.after(OFFER_AFTER_MS, () => void apply($, s2 => offer(s2, turnId)))
469 }
470 return next(e)
471 })
472
473 // ... and its end is the whistle: your move.
474 on('turn.complete', async ($, e, next) => {
475 if (!isOn || !isMain(e)) return next(e)
476 mod.offer?.cancel()
477 mod.offer = null
478 await apply($, turnComplete)
479 return next(e)
480 })
481
482 // Needs-you signals from any loop: a subagent's permission prompt needs you too. The
483 // answer passes on unread, so a prompt another hook settles still blows the whistle;
484 // play comes back when that step's tools finish.
485 on('classic.PermissionRequest', async ($, e, next) => {
486 if (!isOn) return next(e)
487 await whistle($, 'permission', loopOf(e.agent_id))
488 return next(e)
489 }).catch(($, e, next) => next(e))
490
491 on('classic.Elicitation', async ($, e, next) => {
492 if (!isOn) return next(e)
493 await whistle($, 'input', loopOf(e.agent_id))
494 return next(e)
495 }).catch(($, e, next) => next(e))
496
497 on('classic.Notification', async ($, e, next) => {
498 if (!isOn) return next(e)
499 const kind = String(e.notification_type)
500 if (/permission/.test(kind)) await whistle($, 'permission', loopOf(e.agent_id))
501 else if (/elicitation/.test(kind)) await whistle($, 'input', loopOf(e.agent_id))
502 return next(e)
503 }).catch(($, e, next) => next(e))
504
505 // A question for the person: observe it, never answer or deny it.
506 on('tool.call', { tool: 'AskUserQuestion' }, async ($, e, next) => {
507 if (!isOn) return next(e)
508 await whistle($, 'question', loopOf(e.agentId))
509 return next(e)
510 }).catch(($, e, next) => next(e))
511
512 // The loop that asked has its answer and moved on: Claude is working again.
513 on('classic.PostToolBatch', async ($, e, next) => {
514 if (!isOn) return next(e)
515 const s = await read($, session)
516 if (s.asking.includes(loopOf(e.agent_id))) await apply($, s0 => workAgain(s0, loopOf(e.agent_id)))
517 return next(e)
518 }).catch(($, e, next) => next(e))
519
520 on('session.end', async ($, e, next) => {
521 if (!isOn) return next(e)
522 try {
523 await apply($, close)
524 } catch {
525 // Exits stay fast: the totals catch up next session.
526 }
527 return next(e)
528 })
529}
530
531hooks/bolt-draw.tsx 86 lines1// Draws Bolt, the modmyclaude mascot (brand/bolt-sprites), on the surface a hook draws for.
2// Same file in every mod that shows Bolt: mods install on their own, so they cannot share code.
3// Terminal: one Raster, a cell grid. Desktop: one code-font cell per character, since its Text is
4// proportional and a row of half-blocks would drift. Elsewhere: coloured Text runs.
5// Bolt is static here: no timer, no redraw of its own. Never draw it over a safety hold or in a toast.
6
7import type { BoxProps, ElementConstructor, RasterProps, RenderElement, TextProps } from 'claude-code'
8
9import { boltCells, boltRuns } from './bolt'
10import type { BoltCell, BoltSmallPose, BoltTinyPose } from './bolt'
11
12type Elements = {
13 Box: ElementConstructor<BoxProps>
14 Text: ElementConstructor<TextProps>
15 Raster?: ElementConstructor<RasterProps>
16}
17
18export type BoltPick = { size: 'small'; pose: BoltSmallPose } | { size: 'tiny'; pose: BoltTinyPose }
19
20/** Columns a pose takes: small is 10, tiny is 3. */
21export const BOLT_COLUMNS = { small: 10, tiny: 3 } as const
22
23/** The cells of a pose: half-blocks, two pixel rows per cell. */
24export function boltOf(pick: BoltPick): BoltCell[][] {
25 return pick.size === 'small' ? boltCells('small', pick.pose) : boltCells('tiny', pick.pose)
26}
27
28/** Bolt as one element under `key`, drawn the way `surface` lays out cells. */
29export function boltSprite(elements: Elements, surface: string, pick: BoltPick, key = 'bolt'): RenderElement {
30 const { Box, Text, Raster } = elements
31 const cells = boltOf(pick)
32 const columns = cells[0]?.length ?? 0
33 if (surface === 'terminal' && Raster !== undefined) {
34 return <Raster key={key} columns={columns} rows={cells.length} cells={rasterCells(cells)} />
35 }
36 const paint = (cell: BoltCell): TextProps => ({
37 ...(cell.fg !== undefined ? { color: cell.fg } : {}),
38 ...(cell.bg !== undefined ? { backgroundColor: cell.bg } : {}),
39 })
40 if (surface === 'desktop') {
41 return (
42 <Box key={key} flexDirection="column" flexShrink={0}>
43 {cells.map(line => (
44 <Box flexDirection="row" flexWrap="nowrap">
45 {line.map(cell =>
46 cell.fg === undefined && cell.bg === undefined ? <Box width={1} /> : <Box width={1}><Text {...paint(cell)}>{cell.ch}</Text></Box>,
47 )}
48 </Box>
49 ))}
50 </Box>
51 )
52 }
53 return (
54 <Box key={key} flexDirection="column" flexShrink={0}>
55 {cells.map(line => (
56 <Text>
57 {boltRuns(line).map(run => {
58 const text = run.ch.replace(/ /g, ' ')
59 return run.fg === undefined && run.bg === undefined ? text : <Text {...paint(run)}>{text}</Text>
60 })}
61 </Text>
62 ))}
63 </Box>
64 )
65}
66
67/** RasterProps.cells: standard base64 of little-endian u32 [codePoint, fg, bg] per cell, row-major. */
68export function rasterCells(cells: readonly (readonly BoltCell[])[]): string {
69 const DEFAULT = 0x01000000
70 const count = cells.reduce((sum, line) => sum + line.length, 0)
71 const view = new DataView(new ArrayBuffer(count * 12))
72 let at = 0
73 for (const line of cells) {
74 for (const cell of line) {
75 view.setUint32(at, cell.ch.codePointAt(0) ?? 32, true)
76 view.setUint32(at + 4, cell.fg === undefined ? DEFAULT : parseInt(cell.fg.slice(1), 16), true)
77 view.setUint32(at + 8, cell.bg === undefined ? DEFAULT : parseInt(cell.bg.slice(1), 16), true)
78 at += 12
79 }
80 }
81 let binary = ''
82 const bytes = new Uint8Array(view.buffer)
83 for (let i = 0; i < bytes.length; i += 1) binary += String.fromCharCode(bytes[i] ?? 0)
84 return btoa(binary)
85}
86hooks/bests.ts 91 lines1// Personal bests, daily scores and totals in the mod's own store, which every
2// open session shares. A store write is last-write-wins, so a best is
3// compare-and-set by hand: re-read right before writing, never write over a
4// better stored score, and read back after writing to repair a race.
5
6import type { HalftimeGameId, HalftimeStats } from '../types'
7import { GAME_IDS, beats, isGameId } from './games'
8
9export type Store = {
10 get: (key: string) => Promise<unknown>
11 set: (key: string, value: unknown) => Promise<void>
12}
13
14const num = (v: unknown): number | undefined => (typeof v === 'number' && Number.isFinite(v) ? v : undefined)
15
16type Daily = { day: string; score: number }
17const daily = (v: unknown): Daily | undefined => {
18 if (typeof v !== 'object' || v === null) return undefined
19 const d = v as Record<string, unknown>
20 const score = num(d.score)
21 return typeof d.day === 'string' && score !== undefined ? { day: d.day, score } : undefined
22}
23
24export type Totals = { playedMs: number; waitPlayedMs: number; runs: number }
25const totals = (v: unknown): Totals => {
26 const d = (typeof v === 'object' && v !== null ? v : {}) as Record<string, unknown>
27 return { playedMs: num(d.playedMs) ?? 0, waitPlayedMs: num(d.waitPlayedMs) ?? 0, runs: num(d.runs) ?? 0 }
28}
29
30export async function loadStats(store: Store): Promise<HalftimeStats> {
31 const best: HalftimeStats['best'] = {}
32 const dailies: HalftimeStats['daily'] = {}
33 for (const g of GAME_IDS) {
34 const b = num(await store.get(`best:${g}`))
35 if (b !== undefined) best[g] = b
36 const d = daily(await store.get(`daily:${g}`))
37 if (d) dailies[g] = d
38 }
39 const t = totals(await store.get('totals'))
40 const last = await store.get('last')
41 return {
42 auto: (await store.get('auto')) === true,
43 last: isGameId(last) ? last : null,
44 best,
45 daily: dailies,
46 playedMs: t.playedMs,
47 waitPlayedMs: t.waitPlayedMs,
48 runs: t.runs,
49 }
50}
51
52/**
53 * Saves `score` as the best for `game` unless the store already holds one at
54 * least as good. Returns the best as it stands after the save.
55 */
56export async function saveBest(store: Store, game: HalftimeGameId, score: number): Promise<{ best: number; isBest: boolean }> {
57 const key = `best:${game}`
58 for (let attempt = 0; attempt < 3; attempt += 1) {
59 const stored = num(await store.get(key))
60 if (stored !== undefined && !beats(game, score, stored)) return { best: stored, isBest: false }
61 await store.set(key, score)
62 const after = num(await store.get(key))
63 if (after === score) return { best: score, isBest: true }
64 // Another session wrote in between: a better score stands, a worse one is written over again.
65 if (after !== undefined && beats(game, after, score)) return { best: after, isBest: false }
66 }
67 return { best: score, isBest: true }
68}
69
70/** Today's best for the daily board, the same compare-and-set; another day's entry counts as none. */
71export async function saveDaily(store: Store, game: HalftimeGameId, day: string, score: number): Promise<Daily> {
72 const key = `daily:${game}`
73 for (let attempt = 0; attempt < 3; attempt += 1) {
74 const stored = daily(await store.get(key))
75 if (stored && stored.day === day && !beats(game, score, stored.score)) return stored
76 await store.set(key, { day, score })
77 const after = daily(await store.get(key))
78 if (after && after.day === day && after.score === score) return after
79 if (after && after.day === day && beats(game, after.score, score)) return after
80 }
81 return { day, score }
82}
83
84/** Adds play time and finished runs to the stored totals (re-read, then written at once). */
85export async function addTotals(store: Store, owed: readonly [number, number, number]): Promise<Totals> {
86 const t = totals(await store.get('totals'))
87 const next = { playedMs: t.playedMs + owed[0], waitPlayedMs: t.waitPlayedMs + owed[1], runs: t.runs + owed[2] }
88 await store.set('totals', next)
89 return next
90}
91hooks/engine.ts 311 lines1// The session state machine, pure: every event the mod hears becomes one of
2// these transitions over the session in `$.state`. Nothing here waits, draws
3// or stores; register.tsx does that around them.
4//
5// The whistle in one paragraph: anything that needs the person (a permission
6// prompt, a question, an input request, the main turn ending) freezes the run
7// and hides the board, so the keys go back to the prompt. The loops that
8// asked are remembered; once each has moved on (its tool batch resolved) or a
9// new main turn starts, the run comes back with a 3-2-1. Subagent turns
10// starting and ending are not needs-you signals and change nothing here.
11
12import type { HalftimeGameId, HalftimeReason, HalftimeRun, HalftimeSession } from '../types'
13import { gameOf } from './games'
14import { normalizeKey } from './games/kit'
15import { dailySeed, dayKey, practiceSeed } from './rng'
16import { shareLine } from './share'
17
18export const COUNTDOWN = 3
19/** The longest a realtime tick may advance, so a stalled frame never jumps the game. */
20export const MAX_TICK_MS = 200
21/**
22 * A realtime run with no key or click for this long pauses itself. Esc tells
23 * the mod nothing, so this is how a run someone walked away from stops ticking.
24 */
25export const IDLE_PAUSE_MS = 20_000
26
27export const EMPTY_SESSION: HalftimeSession = {
28 phase: 'off', practice: false, run: null, whistle: null, asking: [], turn: null, nextId: 1, owed: [0, 0, 0],
29}
30
31/**
32 * What the board posts: its instance id, its arming count, its input queue, whether a frame
33 * ticked, and the run and epoch it was drawn for (a post from an earlier one is dropped).
34 */
35export type BoardMsg = { iid: string; arm: number; ev: [number, string][]; tick: boolean; rid?: unknown; ep?: unknown }
36
37/** Board posts come from code: anything off-shape is dropped, never trusted. */
38export function parseMsg(data: unknown): BoardMsg | null {
39 if (typeof data !== 'object' || data === null) return null
40 const d = data as Record<string, unknown>
41 if (typeof d.iid !== 'string' || d.iid.length === 0 || d.iid.length > 64) return null
42 if (typeof d.arm !== 'number' || !Number.isInteger(d.arm) || d.arm < 0) return null
43 if (!Array.isArray(d.ev) || d.ev.length > 64) return null
44 const ev: [number, string][] = []
45 for (const item of d.ev) {
46 if (!Array.isArray(item) || typeof item[0] !== 'number' || !Number.isInteger(item[0]) || typeof item[1] !== 'string' || item[1].length > 32) return null
47 ev.push([item[0], item[1]])
48 }
49 // The run and epoch are only ever compared with the run's own (onMessage): any other value drops the post.
50 return { iid: d.iid, arm: d.arm, ev, tick: d.tick === true, ...(d.rid === undefined ? {} : { rid: d.rid }), ...(d.ep === undefined ? {} : { ep: d.ep }) }
51}
52
53const turnActive = (s: HalftimeSession) => s.turn !== null
54
55/** Adds play time to the run and to what the stored totals are owed. */
56function credit(s: HalftimeSession, run: HalftimeRun, ms: number): [HalftimeSession, HalftimeRun] {
57 if (ms <= 0) return [s, run]
58 const wait = turnActive(s) ? ms : 0
59 return [
60 { ...s, owed: [s.owed[0] + ms, s.owed[1] + wait, s.owed[2]] },
61 { ...run, playedMs: run.playedMs + ms, waitPlayedMs: run.waitPlayedMs + wait },
62 ]
63}
64
65/**
66 * Counts a realtime run's play since the last input. Ticks only hold it back
67 * (`run.pending`) until the person shows they are still there; a run that
68 * idles out drops it instead, so time nobody played is never counted.
69 */
70function confirm(s: HalftimeSession, run: HalftimeRun): [HalftimeSession, HalftimeRun] {
71 const [ms, wait] = run.pending
72 if (ms === 0 && wait === 0) return [s, run]
73 return [
74 { ...s, owed: [s.owed[0] + ms, s.owed[1] + wait, s.owed[2]] },
75 { ...run, playedMs: run.playedMs + ms, waitPlayedMs: run.waitPlayedMs + wait, pending: [0, 0] },
76 ]
77}
78
79/** Closes a turn-based run's live stretch up to now (realtime runs are counted per tick). */
80function checkpoint(s: HalftimeSession, run: HalftimeRun, now: number): [HalftimeSession, HalftimeRun] {
81 if (run.status !== 'live' || run.liveSince === 0) return [s, run]
82 const [s2, r2] = credit(s, run, now - run.liveSince)
83 return [s2, { ...r2, liveSince: now }]
84}
85
86/**
87 * Takes a run out of play: no tick, no clock, until it goes live again. What
88 * was played up to now counts (an idle pause empties `pending` first).
89 * `keysLeft`: the keys went elsewhere, so the board comes back as a fresh one
90 * that waits for a click (only p, pressed on the board itself, keeps it).
91 */
92function leaveLive(s: HalftimeSession, run: HalftimeRun, now: number, status: HalftimeRun['status'], held: boolean, keysLeft = true): [HalftimeSession, HalftimeRun] {
93 const [s2, r2] = checkpoint(...confirm(s, run), now)
94 return [s2, { ...r2, status, held, lastAt: 0, liveSince: 0, count: 0, epoch: r2.epoch + (keysLeft ? 1 : 0) }]
95}
96
97/** In play. A turn-based run's clock starts at the first touch, a realtime one at the first tick. */
98function goLive(run: HalftimeRun, now: number, touched: boolean): HalftimeRun {
99 const turnBased = !gameOf(run.game).realtime
100 return { ...run, status: 'live', held: false, count: 0, lastAt: 0, liveSince: turnBased && touched ? now : 0, inputAt: now }
101}
102
103/** A run's share line, counting the main turn still running. */
104export function shareOf(s: HalftimeSession, run: HalftimeRun, score: number | null, now: number): { line: string; waitMs: number } {
105 const running = s.turn && run.status !== 'over' ? Math.max(0, now - Math.max(s.turn.startedAt, run.createdAt)) : 0
106 const waitMs = run.waitMs + running
107 const line = shareLine({ mode: run.mode, day: run.day, game: run.game, score, playedMs: run.playedMs, waitMs, waitPlayedMs: run.waitPlayedMs })
108 return { line, waitMs }
109}
110
111/** The run just ended: freeze its clock, write its share line, owe it to the totals. */
112function finish(s: HalftimeSession, run: HalftimeRun, now: number): HalftimeSession {
113 const [s2, r2] = checkpoint(...confirm(s, run), now)
114 const score = gameOf(run.game).score(r2.g, r2.playedMs)
115 const { line, waitMs } = shareOf(s2, r2, score, now)
116 const over: HalftimeRun = {
117 ...r2, status: 'over', held: false, lastAt: 0, liveSince: 0, waitMs,
118 result: { score, best: null, isBest: false, share: line, saved: false },
119 }
120 return { ...s2, run: over, owed: [s2.owed[0], s2.owed[1], s2.owed[2] + 1] }
121}
122
123/** The picker takes the band; a run in play pauses first, so nothing runs unseen. */
124export function openPicker(s: HalftimeSession, now: number): HalftimeSession {
125 if (s.run && (s.run.status === 'live' || s.run.status === 'countdown')) {
126 const [s2, run] = leaveLive(s, s.run, now, 'paused', false)
127 return { ...s2, run, phase: 'picker', whistle: null }
128 }
129 if (s.phase === 'picker') return s
130 // The person came back on their own: a run the whistle held just waits for their click now.
131 const run = s.run && s.run.held ? { ...s.run, held: false } : s.run
132 return { ...s, run, phase: 'picker', whistle: null }
133}
134
135export function setPractice(s: HalftimeSession, practice: boolean): HalftimeSession {
136 return { ...s, practice }
137}
138
139/** A fresh run of `game`, waiting on the board for the click that starts it. */
140export function startGame(s: HalftimeSession, game: HalftimeGameId, practice: boolean, now: number): HalftimeSession {
141 // Leaving a run mid-play still credits the time it was played.
142 let base = s
143 if (s.run && s.run.status === 'live') base = { ...leaveLive(s, s.run, now, 'paused', false)[0] }
144 const id = base.nextId
145 const day = dayKey(now)
146 const mode = practice ? 'practice' : 'daily'
147 const seed = practice ? practiceSeed(game, now, id) : dailySeed(game, day)
148 const run: HalftimeRun = {
149 id, game, mode, day, seed, g: gameOf(game).init(seed), status: 'ready', count: 0, held: false, epoch: 0,
150 createdAt: now, lastAt: 0, liveSince: 0, inputAt: 0, pending: [0, 0], playedMs: 0, waitPlayedMs: 0, waitMs: 0,
151 input: { iid: '', seq: 0, arm: 0 }, result: null,
152 }
153 return { ...base, phase: 'board', whistle: null, practice, run, nextId: id + 1 }
154}
155
156/** Shows the run's board again as it was left (a paused run waits for a click). */
157export function showBoard(s: HalftimeSession, now: number): HalftimeSession {
158 if (!s.run) return openPicker(s, now)
159 return { ...s, run: { ...s.run, held: false }, phase: 'board', whistle: null }
160}
161
162/** Hides the band. A run in play is paused first, so nothing runs unseen. */
163export function close(s: HalftimeSession, now: number): HalftimeSession {
164 if (s.run && (s.run.status === 'live' || s.run.status === 'countdown')) {
165 const [s2, run] = leaveLive(s, s.run, now, 'paused', false)
166 return { ...s2, run, phase: 'off', whistle: null }
167 }
168 return { ...s, phase: 'off', whistle: null }
169}
170
171/** The whistle: freeze, hide the board, say why. `loop` is '' for the main conversation. */
172export function needsYou(s: HalftimeSession, reason: HalftimeReason, loop: string, now: number): HalftimeSession {
173 const asking = reason === 'turn' ? [] : s.asking.includes(loop) ? s.asking : [...s.asking, loop]
174 if (s.phase === 'offer' || s.phase === 'picker') return { ...s, phase: 'off', asking }
175 if (s.phase === 'whistle') return { ...s, whistle: reason, asking }
176 if (s.phase !== 'board' || !s.run) return asking === s.asking ? s : { ...s, asking }
177 let next: HalftimeSession = s
178 let run = s.run
179 if (run.status === 'live' || run.status === 'countdown' || run.status === 'paused') [next, run] = leaveLive(s, run, now, 'paused', true)
180 else if (run.status === 'ready') run = { ...run, held: true, epoch: run.epoch + 1 }
181 return { ...next, run, phase: 'whistle', whistle: reason, asking }
182}
183
184/** Back from the whistle: a frozen run gets its 3-2-1; one not yet started just shows again. */
185export function resume(s: HalftimeSession): HalftimeSession {
186 const run = s.run
187 if (!run || run.status === 'over') return { ...s, phase: 'off', whistle: null, asking: [] }
188 if (run.status === 'ready') return { ...s, phase: 'board', whistle: null, run: { ...run, held: false } }
189 return { ...s, phase: 'board', whistle: null, run: { ...run, status: 'countdown', count: COUNTDOWN, held: false } }
190}
191
192/** A loop that asked the person has moved on (its tool batch resolved). */
193export function workAgain(s: HalftimeSession, loop: string): HalftimeSession {
194 if (!s.asking.includes(loop)) return s
195 const asking = s.asking.filter(a => a !== loop)
196 if (asking.length > 0 || s.phase !== 'whistle' || s.whistle === 'turn') return { ...s, asking }
197 return resume({ ...s, asking })
198}
199
200/** A main-loop turn began: Claude is working again. */
201export function turnStart(s: HalftimeSession, id: string, now: number): HalftimeSession {
202 let next: HalftimeSession = { ...s, turn: { id, startedAt: now }, asking: [] }
203 if (next.run && next.run.status === 'live') {
204 // Time played before the turn began was not played while waiting.
205 const [s2, run] = checkpoint(s, next.run, now)
206 next = { ...s2, turn: { id, startedAt: now }, asking: [], run }
207 }
208 if (next.phase === 'whistle') return resume(next)
209 if (next.phase === 'offer') return { ...next, phase: 'off' }
210 return next
211}
212
213/** The main-loop turn ended: whistle, then book the turn time the run overlapped. */
214export function turnComplete(s: HalftimeSession, now: number): HalftimeSession {
215 const turn = s.turn
216 const blown = needsYou(s, 'turn', '', now)
217 let run = blown.run
218 if (run && turn && run.status !== 'over') run = { ...run, waitMs: run.waitMs + Math.max(0, now - Math.max(turn.startedAt, run.createdAt)) }
219 return { ...blown, run, turn: null, asking: [], phase: blown.phase === 'offer' ? 'off' : blown.phase }
220}
221
222export function countdownTick(s: HalftimeSession, now: number): HalftimeSession {
223 const run = s.run
224 if (s.phase !== 'board' || !run || run.status !== 'countdown') return s
225 if (run.count > 1) return { ...s, run: { ...run, count: run.count - 1 } }
226 return { ...s, run: goLive(run, now, false) }
227}
228
229/** Auto mode's offer, only if this same main turn is still running and nothing is up. */
230export function offer(s: HalftimeSession, turnId: string): HalftimeSession {
231 return s.turn?.id === turnId && s.phase === 'off' ? { ...s, phase: 'offer' } : s
232}
233
234/** The person started typing at the prompt: a run in play pauses rather than play on unattended. */
235export function pauseForTyping(s: HalftimeSession, now: number): HalftimeSession {
236 if (s.phase !== 'board' || !s.run || s.run.status !== 'live') return s
237 const [s2, run] = leaveLive(s, s.run, now, 'paused', false)
238 return { ...s2, run }
239}
240
241/** Keys every game shares: p pauses and resumes, r starts over once a run has ended. */
242export const SHARED_KEYS: Readonly<Record<string, string>> = { p: 'pause', r: 'again' }
243
244/** Applies one board post: arming, then queued keys and clicks in order, then the frame's tick. */
245export function onMessage(s: HalftimeSession, msg: BoardMsg, now: number): HalftimeSession {
246 if (s.phase !== 'board' || !s.run) return s
247 // A board drawn for another run, or before the keys last left, is not the one on screen now.
248 if ((msg.rid !== undefined && msg.rid !== s.run.id) || (msg.ep !== undefined && msg.ep !== s.run.epoch)) return s
249 const game = gameOf(s.run.game)
250 const same = msg.iid === s.run.input.iid
251 const lastSeq = same ? s.run.input.seq : 0
252 const lastArm = same ? s.run.input.arm : 0
253 const seq = msg.ev.reduce((m, [n]) => Math.max(m, n), lastSeq)
254 let next: HalftimeSession = s
255 let run: HalftimeRun = { ...s.run, input: { iid: msg.iid, seq, arm: Math.max(lastArm, msg.arm) } }
256
257 // Any new key or click is the person still at the board: the play since their last one
258 // counts, and the idle clock starts over.
259 if (msg.arm > lastArm || msg.ev.some(([n]) => n > lastSeq)) [next, run] = confirm(next, { ...run, inputAt: now })
260
261 // A click on a board that was not in play is the person taking the keys: start, or go on.
262 if (msg.arm > lastArm && (run.status === 'ready' || (run.status === 'paused' && !run.held))) run = goLive(run, now, true)
263
264 for (const [n, raw] of msg.ev) {
265 if (n <= lastSeq) continue
266 if (raw.startsWith('click:')) {
267 const [cx, cy, button] = raw.slice(6).split(',')
268 if (run.status === 'live' && game.click && cx !== undefined && cy !== undefined && button !== undefined) {
269 if (!game.realtime && run.liveSince === 0) run = { ...run, liveSince: now }
270 run = { ...run, g: game.click(run.g, Number(cx), Number(cy), button) }
271 }
272 continue
273 }
274 // Only mapped keys act, and no map holds Enter, y, n or a digit (games.test.ts checks every map).
275 const key = normalizeKey(raw)
276 const shared = SHARED_KEYS[key]
277 if (shared === 'pause') {
278 if (run.status === 'live') [next, run] = leaveLive(next, run, now, 'paused', false, false)
279 else if (run.status === 'paused' && !run.held) run = goLive(run, now, true)
280 continue
281 }
282 if (shared === 'again') {
283 if (run.status === 'over') return startGame({ ...next, run }, run.game, run.mode === 'practice', now)
284 continue
285 }
286 const action = game.controls[key]
287 if (action && run.status === 'live') {
288 if (!game.realtime && run.liveSince === 0) run = { ...run, liveSince: now }
289 run = { ...run, g: game.act(run.g, action) }
290 }
291 }
292
293 if (msg.tick && run.status === 'live' && game.realtime && game.step) {
294 if (now - run.inputAt >= IDLE_PAUSE_MS) {
295 // Nobody has touched it in a while (Esc, or a walk-away): pause, which stops the
296 // board's frame clock, and drop the idle stretch so it never counts as play.
297 ;[next, run] = leaveLive(next, { ...run, pending: [0, 0] }, now, 'paused', false)
298 return { ...next, run }
299 }
300 const dt = run.lastAt > 0 ? Math.max(0, Math.min(MAX_TICK_MS, now - run.lastAt)) : 0
301 const wait = turnActive(next) ? dt : 0
302 run = { ...run, lastAt: now, pending: [run.pending[0] + dt, run.pending[1] + wait], g: game.step(run.g, dt) }
303 }
304
305 if (run.status === 'live' && game.isOver(run.g)) return finish(next, run, now)
306 return { ...next, run }
307}
308
309/** Whether a run's result still waits for its best to be compared and stored. */
310export const unsaved = (s: HalftimeSession) => s.run?.status === 'over' && s.run.result !== null && !s.run.result.saved
311hooks/games/index.ts 35 lines1// The four games by id. Each is typed on its own state; the engine handles a
2// run's state as the union and looks the game up by the run's id.
3
4import type { HalftimeGameId, HalftimeGameState } from '../../types'
5import { cratehop } from './cratehop'
6import type { Game } from './kit'
7import { merge } from './merge'
8import { tailchase } from './tailchase'
9import { sweep } from './sweep'
10
11export const GAME_IDS: readonly HalftimeGameId[] = ['cratehop', 'tailchase', 'merge', 'sweep']
12
13const GAMES = { cratehop, tailchase, merge, sweep }
14
15/** The game a run plays; its state is the matching member of the union by construction. */
16export function gameOf(id: HalftimeGameId): Game<HalftimeGameState> {
17 return GAMES[id] as unknown as Game<HalftimeGameState>
18}
19
20export function isGameId(value: unknown): value is HalftimeGameId {
21 return typeof value === 'string' && (GAME_IDS as readonly string[]).includes(value)
22}
23
24/** How a score reads: sweep is a clear time, the rest are counts. */
25export function scoreText(game: HalftimeGameId, score: number | null): string {
26 if (score === null) return '-'
27 return game === 'sweep' ? `${score}s` : String(score)
28}
29
30/** Whether `a` beats `b` for that game (sweep's lower time wins). */
31export function beats(game: HalftimeGameId, a: number, b: number | undefined): boolean {
32 if (b === undefined) return true
33 return gameOf(game).better === 'lower' ? a < b : a > b
34}
35hooks/share.ts 40 lines1// The words halftime shows: the share line after a run and the one-line
2// scorecard. Pure, so the exact wording is pinned by tests.
3
4import type { HalftimeGameId, HalftimeStats } from '../types'
5import { GAME_IDS, scoreText } from './games'
6
7/** "45s" under a minute, "3m" under an hour, then "1h 5m". */
8export function duration(ms: number): string {
9 const s = Math.max(0, Math.round(ms / 1000))
10 if (s < 60) return `${s}s`
11 const m = Math.round(s / 60)
12 if (m < 60) return `${m}m`
13 return `${Math.floor(m / 60)}h ${m % 60}m`
14}
15
16export type ShareInput = {
17 mode: 'daily' | 'practice'
18 day: string
19 game: HalftimeGameId
20 score: number | null
21 playedMs: number
22 /** Main-loop turn time the run overlapped, and the part of it spent playing. */
23 waitMs: number
24 waitPlayedMs: number
25}
26
27/** "halftime daily 10-09 · cratehop 412 · played 3m of a 7m turn" */
28export function shareLine(r: ShareInput): string {
29 const head = r.mode === 'daily' ? `halftime daily ${r.day.slice(5)}` : 'halftime practice'
30 const score = r.score === null ? 'lost' : scoreText(r.game, r.score)
31 const played = r.waitMs >= 1000 ? `played ${duration(r.waitPlayedMs)} of a ${duration(r.waitMs)} turn` : `played ${duration(r.playedMs)}`
32 return `${head} · ${r.game} ${score} · ${played}`
33}
34
35/** "halftime · cratehop 412 · tailchase 31 · merge - · sweep 47s · 37m played while waiting" */
36export function scorecard(stats: HalftimeStats): string {
37 const games = GAME_IDS.map(g => `${g} ${scoreText(g, stats.best[g] ?? null)}`).join(' · ')
38 return `halftime · ${games} · ${duration(stats.waitPlayedMs)} played while waiting`
39}
40hooks/view.ts 128 lines1// What the board shows, worked out in the hooks module: the board's surface
2// module only paints these rows and reports input, so it knows no game.
3
4import type { HalftimeRun, HalftimeStats, HalftimeSweep } from '../types'
5import { gameOf, scoreText } from './games'
6import { BOLD, DIM, paint, type Grid, type Seg } from './games/kit'
7import { SHARED_KEYS } from './engine'
8
9/** The props the board's surface module draws from (plain data). */
10export type BoardProps = {
11 rid: number
12 /** Moves on each time the keys leave the board: the board starts over as a fresh one. */
13 epoch: number
14 /** In play: the board ticks (realtime games) once it holds the keys. */
15 live: boolean
16 tick: number
17 /** The only keys the board passes on; Enter, y, n and digits are never among them. */
18 keys: string[]
19 /** The last input event applied, by board instance: [iid, seq]. */
20 ack: [string, number]
21 title: Seg[]
22 rows: Seg[][]
23 /** Field geometry for clicks; the field starts on the board's second row. */
24 grid: Grid | null
25 hint: string
26 wait: string
27 needClick: boolean
28 showHint: boolean
29 /** The keyboard pad: [letter, word], one per action, for a terminal that sends no clicks. */
30 pad: [string, string][]
31 /** The desktop app: the board draws the field one code-font cell per character. */
32 cells: boolean
33}
34
35const PAD_ORDER = 'wasdxfpr'
36const PAD_WORDS: Readonly<Record<string, string>> = { w: 'up', a: 'left', s: 'down', d: 'right', x: 'open', f: 'flag', p: 'pause', r: 'again' }
37
38/** One letter per action (wasd before hjkl), then pause, or again once the run is over; each with its word. */
39export function padFor(run: HalftimeRun): [string, string][] {
40 const byAction = new Map<string, string>()
41 for (const [key, action] of Object.entries(gameOf(run.game).controls)) {
42 if (/^[a-z]$/.test(key) && PAD_ORDER.includes(key) && !byAction.has(action)) byAction.set(action, key)
43 }
44 // A plain one-word action names itself (cratehop's jump); the rest take the letter's word.
45 const pad: [string, string][] = [...byAction].map(([action, key]) => [key, /^[a-z]+$/.test(action) && action !== 'reveal' ? action : PAD_WORDS[key] ?? key])
46 pad.push(run.status === 'over' ? ['r', 'again'] : ['p', 'pause'])
47 return pad.sort((a, b) => PAD_ORDER.indexOf(a[0]) - PAD_ORDER.indexOf(b[0]))
48}
49
50/** Keys a game's board may pass on: its controls plus pause and again (no map holds a forbidden key). */
51export function keysFor(run: HalftimeRun): string[] {
52 return [...Object.keys(gameOf(run.game).controls), ...Object.keys(SHARED_KEYS)]
53}
54
55/** Play time as of now (a turn-based run's live stretch is still open). */
56export const playedNow = (run: HalftimeRun, now: number) => run.playedMs + (run.liveSince > 0 ? Math.max(0, now - run.liveSince) : 0)
57
58function liveScore(run: HalftimeRun, now: number): string {
59 if (run.game === 'sweep') {
60 const g = run.g as HalftimeSweep
61 const flags = g.flag.reduce((n, f) => n + f, 0)
62 const mines = g.mines.reduce((n, m) => n + m, 0)
63 return `${flags}/${mines} flagged ${Math.round(playedNow(run, now) / 1000)}s`
64 }
65 return scoreText(run.game, gameOf(run.game).score(run.g, run.playedMs))
66}
67
68function overText(run: HalftimeRun): string {
69 const score = run.result?.score ?? null
70 const shown = scoreText(run.game, score)
71 if (run.game === 'cratehop') return `crashed at ${shown}`
72 if (run.game === 'tailchase') return (run.g as { won: boolean }).won ? `filled the field: ${shown}` : `bit your own tail at ${shown}`
73 if (run.game === 'merge') return `no moves left at ${shown}`
74 return score === null ? 'boom - a mine' : `cleared in ${shown}`
75}
76
77function waitText(run: HalftimeRun): string {
78 switch (run.status) {
79 case 'ready':
80 return gameOf(run.game).realtime ? 'click the board to start - the run begins on your click' : 'click the board to start'
81 case 'countdown':
82 return `back in ${run.count}...`
83 case 'live':
84 return 'click the board to take the keys'
85 case 'paused':
86 return 'paused - click the board, or press p, to go on'
87 case 'over': {
88 const r = run.result
89 const best = !r || !r.saved || r.score === null ? '' : r.isBest ? ' new personal best' : ` best ${scoreText(run.game, r.best)}`
90 return `${overText(run)}${best} · r: again`
91 }
92 }
93}
94
95/** The board for a run, sized to the band's body: `cols` across, `rows` for the whole board. */
96export function boardView(run: HalftimeRun, stats: HalftimeStats, cols: number, rows: number, now: number, cells: boolean): BoardProps {
97 const game = gameOf(run.game)
98 // The field comes first: in a tight band the hint line goes before any field row does.
99 const showHint = rows >= game.rows + 2
100 const fieldRows = showHint ? game.rows : Math.max(3, rows - 1)
101 const drawn = game.draw(run.g, cols, fieldRows)
102 const best = stats.best[run.game]
103 const title: Seg[] = paint([
104 [game.title, 'text', undefined, BOLD],
105 [' '],
106 [liveScore(run, now), 'suggestion', undefined, BOLD],
107 [` best ${scoreText(run.game, best ?? null)}`, 'subtle', undefined, DIM],
108 [run.mode === 'daily' ? ` daily ${run.day.slice(5)}` : ' practice', 'subtle', undefined, DIM],
109 ])
110 return {
111 rid: run.id,
112 epoch: run.epoch,
113 live: run.status === 'live',
114 tick: game.realtime ? game.tickMs : 0,
115 keys: keysFor(run),
116 pad: padFor(run),
117 ack: [run.input.iid, run.input.seq],
118 title,
119 rows: drawn.rows,
120 grid: drawn.grid ?? null,
121 hint: game.hint,
122 wait: waitText(run),
123 needClick: run.status === 'countdown',
124 showHint,
125 cells,
126 }
127}
128hooks/board.tsx 221 lines1// halftime's board: the one surface module (a `Client`), run by the terminal
2// or the desktop app. It paints the rows the hooks module hands it and passes
3// on the person's keys and clicks. It knows no game and keeps no run state, so
4// the hooks module can unmount it at any moment (the whistle) and lose nothing.
5//
6// Self-contained on purpose: its one import is types only (erased), so it
7// loads the same wherever the surface runs it.
8
9import type { ClientKeyEvent, ClientPointerEvent, ClientSurface } from 'claude-code'
10
11/** [text, color, background, flags]: '' and 0 for none; flags 1 bold, 2 dim, 4 inverse. */
12type Seg = [string, string, string, number]
13type Grid = { x: number; cw: number; ox: number; oy: number; width: number; height: number; vw?: number }
14type Props = {
15 rid: number
16 epoch: number
17 live: boolean
18 tick: number
19 keys: string[]
20 ack: [string, number]
21 title: Seg[]
22 rows: Seg[][]
23 grid: Grid | null
24 hint: string
25 wait: string
26 needClick: boolean
27 showHint: boolean
28 pad: [string, string][]
29 /** The desktop app: draw the field one code-font cell per character. */
30 cells?: boolean
31}
32
33/** What this instance remembers between calls; dropped with the instance. */
34type Local = {
35 /** The run and epoch this instance last drew; a change starts it over (see Board). */
36 rid: number
37 epoch: number
38 iid: string
39 seq: number
40 arm: number
41 armed: boolean
42 /** The keys were taken through the pad, so it stays drawn: its Buttons carry them. */
43 viaPad: boolean
44 /** Input not yet applied by the hooks module; resent whole until acknowledged. */
45 ev: [number, string][]
46 props: Props
47 stop: (() => void) | null
48}
49
50type Surface = ClientSurface<Local>
51type TextStyle = { color?: string; backgroundColor?: string; bold?: boolean; dimColor?: boolean; inverse?: boolean }
52
53const BOLD = 1
54const DIM = 2
55const INVERSE = 4
56const MAX_QUEUE = 32
57
58const keyName = (key: string) => (key === ' ' || key === 'space' ? 'space' : key.length === 1 ? key.toLowerCase() : key)
59
60function newId(): string {
61 const c = (globalThis as { crypto?: { randomUUID?: () => string } }).crypto
62 return c?.randomUUID ? c.randomUUID() : `b${Date.now().toString(36)}${Math.floor(Math.random() * 1e9).toString(36)}`
63}
64
65/** Every post carries the whole unacknowledged queue: a post a later one replaces loses nothing. */
66function send(surface: Surface, local: Local, tick: boolean) {
67 surface.post({ iid: local.iid, arm: local.arm, ev: local.ev, tick, rid: local.rid, ep: local.epoch })
68}
69
70/** The frame clock runs only while the run is in play and this board holds the keys. */
71function syncTicker(surface: Surface, local: Local) {
72 const want = local.props.live && local.props.tick > 0 && local.armed
73 if (want && !local.stop) local.stop = surface.every(local.props.tick, () => send(surface, local, true))
74 if (!want && local.stop) {
75 local.stop()
76 local.stop = null
77 }
78}
79
80function queue(local: Local, item: string) {
81 local.seq += 1
82 local.ev.push([local.seq, item])
83 if (local.ev.length > MAX_QUEUE) local.ev.splice(0, local.ev.length - MAX_QUEUE)
84}
85
86/** One of the run's own keys, from the board's focus or the pad: the first one takes the keys. */
87function take(surface: Surface, local: Local, key: string) {
88 if (!local.armed) {
89 local.armed = true
90 local.arm += 1
91 }
92 queue(local, key)
93 send(surface, local, false)
94 syncTicker(surface, local)
95 surface.setState(local)
96}
97
98function start(surface: Surface, props: Props): Local {
99 const local: Local = { rid: props.rid, epoch: props.epoch, iid: newId(), seq: 0, arm: 0, armed: false, viaPad: false, ev: [], props, stop: null }
100 surface.setState(local)
101 surface.onKey((event: ClientKeyEvent) => {
102 const key = keyName(event.key)
103 // Only the run's own keys travel; Enter, y, n and digits are never in that list.
104 if (event.ctrl || event.meta || !local.props.keys.includes(key)) return
105 take(surface, local, key)
106 })
107 surface.onPointer((event: ClientPointerEvent) => {
108 if (event.type !== 'down') return
109 // A click on a board not in play (or not yet holding the keys) takes the keys and starts or resumes.
110 if (!local.armed || !local.props.live) {
111 local.armed = true
112 local.arm += 1
113 send(surface, local, false)
114 syncTicker(surface, local)
115 surface.setState(local)
116 return
117 }
118 const button = event.button ?? 'left'
119 const g = local.props.grid
120 if (!g) {
121 // A game with no field to aim at (cratehop): any click is its one action.
122 queue(local, `click:0,0,${button}`)
123 } else {
124 // The field starts on the board's second row, `g.x` columns in (the centering pad and the rail);
125 // turn the cell under the pointer into a field cell. The pad, the rails and any cell the band
126 // has scrolled out of view are not the field.
127 const row = event.y - 1
128 const col = event.x - g.x
129 const cx = Math.floor(col / g.cw) + g.ox
130 const cy = row + g.oy
131 if (col < 0 || row < 0 || row >= local.props.rows.length || cx - g.ox >= (g.vw ?? g.width) || cx < 0 || cy < 0 || cx >= g.width || cy >= g.height) return
132 queue(local, `click:${cx},${cy},${button}`)
133 }
134 send(surface, local, false)
135 })
136 return local
137}
138
139/**
140 * A new run, or the keys having left (a whistle, a survey, typing): this instance starts over
141 * as a fresh board, unarmed and not ticking, with a new id, as a newly mounted one would. Its
142 * key in the band stays fixed, so the surface may hand back the same instance.
143 */
144function startOver(local: Local, props: Props) {
145 if (local.stop) {
146 local.stop()
147 local.stop = null
148 }
149 Object.assign(local, { rid: props.rid, epoch: props.epoch, iid: newId(), seq: 0, arm: 0, armed: false, viaPad: false, ev: [] })
150}
151
152export default function Board(props: Props, surface: Surface) {
153 const local = surface.state ?? start(surface, props)
154 if (local.rid !== props.rid || local.epoch !== props.epoch) startOver(local, props)
155 local.props = props
156 // Drop what the hooks module has applied; a new instance id starts its own count.
157 if (props.ack[0] === local.iid) local.ev = local.ev.filter(([n]) => n > props.ack[1])
158 syncTicker(surface, local)
159
160 const { Box, Text, Button } = surface.elements
161 const seg = (s: Seg) => {
162 const p: TextStyle = {}
163 if (s[1]) p.color = s[1]
164 if (s[2]) p.backgroundColor = s[2]
165 const flags = s[3] ?? 0
166 if (flags & BOLD) p.bold = true
167 if (flags & DIM) p.dimColor = true
168 if (flags & INVERSE) p.inverse = true
169 return <Text {...p}>{s[0]}</Text>
170 }
171 const line = (segs: Seg[]) => <Text wrap="truncate-end">{segs.map(seg)}</Text>
172 // The desktop app draws Text in a proportional font, so a row of glyphs drifts and
173 // wraps. A Box's width is in the surface's code-font cells: one cell per
174 // character keeps every column where the game put it; a run of spaces is one gap.
175 // A gap that is painted (a background, or inverse) still takes one cell per space, so its paint shows.
176 const cellRow = (segs: Seg[]) => (
177 <Box flexDirection="row" flexWrap="nowrap">
178 {segs.flatMap(s =>
179 /^ +$/.test(s[0]) && !s[2] && !(s[3] & INVERSE)
180 ? [<Box width={s[0].length} />]
181 : [...s[0]].map(ch => <Box width={1}>{seg([ch, s[1], s[2], s[3]])}</Box>),
182 )}
183 </Box>
184 )
185 const status =
186 props.live && local.armed
187 ? props.hint
188 : props.wait + (props.needClick && !local.armed ? ' - click the board to take the keys' : '')
189 return (
190 <Box flexDirection="column">
191 {line(props.showHint ? props.title : [...props.title, [` ${status}`, 'subtle', '', DIM]])}
192 {props.rows.map(props.cells ? cellRow : line)}
193 {props.showHint ? <Text dimColor wrap="truncate-end">{status}</Text> : null}
194 {
195 // The pad: a terminal with no mouse reporting never sends the click, so the
196 // band's focus chord (ctrl+x tab) and these letters take the keys instead. Letters only,
197 // never Enter, y, n or a digit; drawn until a click takes the keys, then only if it did.
198 (!local.armed || local.viaPad) && props.pad.length > 0 ? (
199 <Box flexDirection="row" columnGap={1}>
200 <Text dimColor>{local.viaPad ? 'keys:' : 'no mouse? ctrl+x tab, then'}</Text>
201 {props.pad
202 .filter(([key]) => props.keys.includes(key))
203 .map(([key, word]) => (
204 <Button
205 key={`pad:${key}`}
206 label={word}
207 hotkey={key}
208 plain
209 onPress={() => {
210 local.viaPad = true
211 take(surface, local, key)
212 }}
213 />
214 ))}
215 </Box>
216 ) : null
217 }
218 </Box>
219 )
220}
221hooks/bolt.ts 109 lines1// Bolt sprites for mods, v1.0.0. GENERATED by brand/bolt-sprites/tools/build.py: edit design.py, not this file.
2// Copy this file into your mod's hooks/ folder (mods install on their own, so they cannot import from brand/).
3// Rules (brand/BRAND.md): pink is the antenna light only, blush the cheeks only. Never recolour, rotate or stretch.
4// Never draw Bolt over a safety hold, and never in a toast (toasts are uncoloured text).
5
6export const BOLT_PALETTE = {
7 B: '#C8F53C',
8 S: '#9BC21A',
9 O: '#FF5C8A',
10 K: '#FF8FB1',
11 E: '#101114',
12 W: '#FBFBF8',
13 G: '#8E95A3',
14} as const
15
16export type BoltSize = 'small' | 'tiny'
17export type BoltSmallPose = 'idle' | 'run1' | 'run2' | 'jump' | 'crash' | 'cheer' | 'sleep' | 'sweat' | 'think' | 'egg' | 'egg-wobble-l' | 'egg-wobble-r' | 'egg-crack-1' | 'egg-crack-2' | 'egg-crack-3' | 'egg-hatch'
18export type BoltTinyPose = 'idle' | 'run1' | 'run2' | 'jump' | 'crash' | 'cheer' | 'sleep'
19export type BoltPose = BoltSmallPose | BoltTinyPose
20
21/** One text cell: a character and its colours (absent = the surface's own). */
22export type BoltCell = { ch: string; fg?: string; bg?: string }
23
24/** Pixel rows: one palette letter per pixel, '.' empty. Heights are even (two pixel rows per cell). */
25export const BOLT_SPRITES = {
26 small: {
27 'idle': ['....OO....', '....SS....', '..BBBBBB..', '.BBBBBBBB.', 'BBWEBBWEBB', 'BBEEBBEEBB', 'BKKBBBBKKB', 'BBBBEEBBBB', '.SSSSSSSS.', '..SS..SS..'],
28 'run1': ['...OO.....', '....SS....', '..BBBBBB..', '.BBBBBBBB.', 'BBBWEBBWEB', 'BBBEEBBEEB', 'BKKBBBBBKB', 'BBBBBEEBBB', '.SSSSSSSS.', 'SS......SS'],
29 'run2': ['....OO....', '....SS....', '..BBBBBB..', '.BBBBBBBB.', 'BBBWEBBWEB', 'BBBEEBBEEB', 'BKKBBBBBKB', 'BBBBBEEBBB', '.SSSSSSSS.', '...SSSS...'],
30 'jump': ['....OO....', '....SS....', '..BBBBBB..', '.BBBBBBBB.', 'BBWEBBWEBB', 'BBEEBBEEBB', 'BKKBBBBKKB', 'BBBBEEBBBB', '.SSSSSSSS.', '...S..S...'],
31 'crash': ['......OO..', '.....S....', '..BBBBBB..', '.BBBBBBBB.', 'BEBEBBEBEB', 'BBEBBBBEBB', 'BEBEBBEBEB', 'BBBBEEBBBB', '.SSSSSSSS.', 'SS......SS'],
32 'cheer': ['S...OO...S', 'S...SS...S', 'SBBBBBBBBS', '.BBBBBBBB.', 'BBWEBBWEBB', 'BBEEBBEEBB', 'BKKBBBBKKB', 'BBBEEEEBBB', '.SSSSSSSS.', '..SS..SS..'],
33 'sleep': ['....SS....', '....SS....', '..BBBBBB..', '.BBBBBBBB.', 'BBBBBBBBBB', 'BBEEBBEEBB', 'BKKBBBBKKB', 'BBBBBBBBBB', '.SSSSSSSS.', '..SS..SS..'],
34 'sweat': ['....OO....', '....SS...G', '..BBBBBB.G', '.BBBBBBBB.', 'BBWEBBWEBB', 'BBEEBBEEBB', 'BKKBBBBKKB', 'BBBBEEEBBB', '.SSSSSSSS.', '..SS..SS..'],
35 'think': ['....OO....', '....SS....', '..BBBBBB..', '.BBWEBWEB.', 'BBBEEBEEBB', 'BBBBBBBBBB', 'BKKBBBBKKB', 'BBBBBEBBBB', '.SSSSSSSS.', '..SS..SS..'],
36 'egg': ['....OO....', '....SS....', '...BWBB...', '..BWBBBB..', '..BBBBBB..', '.BBBBBBBB.', '.BBBBBBBB.', '.BBBBBBBS.', '..BBBBBS..', '...SSSS...'],
37 'egg-wobble-l': ['...OO.....', '...SS.....', '..BWBB....', '.BWBBBB...', '.BBBBBB...', '.BBBBBBBB.', '.BBBBBBBB.', '.BBBBBBBS.', '..BBBBBS..', '...SSSS...'],
38 'egg-wobble-r': ['.....OO...', '.....SS...', '....BWBB..', '...BWBBBB.', '...BBBBBB.', '.BBBBBBBB.', '.BBBBBBBB.', '.BBBBBBBS.', '..BBBBBS..', '...SSSS...'],
39 'egg-crack-1': ['....OO....', '....SS....', '...BWBB...', '..BWBBBB..', '..BBBBBB..', '.BBBBBBBB.', '.BEBBBBBB.', '.BBEBBBBS.', '..BBBBBS..', '...SSSS...'],
40 'egg-crack-2': ['....OO....', '....SS....', '...BWBB...', '..BWBBBB..', '..BBBBBB..', '.BBBBBBBB.', '.BEBEBBBB.', '.BBEBEBBS.', '..BBBBBS..', '...SSSS...'],
41 'egg-crack-3': ['....OO....', '....SS....', '...BWBB...', '..BWBBBB..', '..BBBBBB..', '.BBBBBBBB.', '.BEBEBEBB.', '.BBEBEBES.', '..BBBBBS..', '...SSSS...'],
42 'egg-hatch': ['......OO..', '......SS..', '....BWBBBB', '....B.B.B.', '..........', '..........', '.B.B.B.BB.', '.BSSSSSSB.', '..BBBBBS..', '...SSSS...'],
43 },
44 tiny: {
45 'idle': ['.O.', 'BEB', 'BBB', 'S.S'],
46 'run1': ['.O.', 'BEB', 'BBB', 'S.S'],
47 'run2': ['.O.', 'BEB', 'BBB', '.S.'],
48 'jump': ['.O.', 'BEB', 'BBB', '.S.'],
49 'crash': ['...', '.SO', 'BEB', 'SSS'],
50 'cheer': ['SOS', 'BEB', 'BBB', 'S.S'],
51 'sleep': ['.S.', 'BSB', 'BBB', 'S.S'],
52 },
53} as const
54
55/** Letters drawn over a whole cell (cell coordinates). */
56const GLYPHS: Record<string, readonly { row: number; col: number; ch: string; color: keyof typeof BOLT_PALETTE }[]> = {
57 'small/sleep': [{ row: 0, col: 8, ch: 'z', color: 'G' }],
58}
59
60/** The two run frames, in order. Swap them about every 120 ms while running. */
61export const BOLT_RUN = ['run1', 'run2'] as const
62
63function colour(key: string | undefined): string | undefined {
64 if (key === undefined || key === '.') return undefined
65 return (BOLT_PALETTE as Record<string, string>)[key]
66}
67
68/**
69 * Packs a pose into half-block text cells: rows / 2 lines of `cols` cells.
70 * Terminal: draw with Raster (see hatchling's rasterCells). Desktop: one fixed-width cell per character.
71 */
72export function boltCells(size: 'small', pose: BoltSmallPose): BoltCell[][]
73export function boltCells(size: 'tiny', pose: BoltTinyPose): BoltCell[][]
74export function boltCells(size: BoltSize, pose: BoltPose): BoltCell[][] {
75 const rows: readonly string[] = (BOLT_SPRITES[size] as Record<string, readonly string[]>)[pose] ?? []
76 const out: BoltCell[][] = []
77 for (let r = 0; r + 1 < rows.length; r += 2) {
78 const top = rows[r] ?? ''
79 const bottom = rows[r + 1] ?? ''
80 const line: BoltCell[] = []
81 for (let c = 0; c < top.length; c += 1) {
82 const t = colour(top[c])
83 const b = colour(bottom[c])
84 if (t === undefined && b === undefined) line.push({ ch: ' ' })
85 else if (b === undefined) line.push({ ch: '\u2580', fg: t })
86 else if (t === undefined) line.push({ ch: '\u2584', fg: b })
87 else if (t === b) line.push({ ch: '\u2588', fg: t })
88 else line.push({ ch: '\u2580', fg: t, bg: b })
89 }
90 out.push(line)
91 }
92 for (const glyph of GLYPHS[`${size}/${pose}`] ?? []) {
93 const line = out[glyph.row]
94 if (line !== undefined && glyph.col < line.length) line[glyph.col] = { ch: glyph.ch, fg: BOLT_PALETTE[glyph.color] }
95 }
96 return out
97}
98
99/** Merges a row of cells into runs of one style, for coloured Text spans. */
100export function boltRuns(line: readonly BoltCell[]): BoltCell[] {
101 const runs: BoltCell[] = []
102 for (const cell of line) {
103 const last = runs[runs.length - 1]
104 if (last !== undefined && last.fg === cell.fg && last.bg === cell.bg) last.ch += cell.ch
105 else runs.push({ ...cell })
106 }
107 return runs
108}
109hooks/games/kit.ts 102 lines1// What every game module provides, and the drawing vocabulary the board
2// speaks. A game is pure: (state, input) -> state, and state -> rows of text.
3
4import type { HalftimeGameId } from '../../types'
5
6/** One cell (or run) as a game writes it: [text, color, background, flags]; flags 1 bold, 2 dim, 4 inverse. */
7export type Cell = [string, string?, string?, number?]
8
9/** A run of same-styled text as the board receives it: every field present ('' and 0 for none), so it crosses as plain data. */
10export type Seg = [string, string, string, number]
11
12export const BOLD = 1
13export const DIM = 2
14export const INVERSE = 4
15
16/**
17 * Where the game's field sits inside the drawn rows, so a click can be turned
18 * back into a cell: field cell (cx, cy) is drawn at column x + (cx - ox) * cw
19 * of row (cy - oy), counting from the first field row. `x` already counts the
20 * centering pad and the side rail; `w` x `h` is the whole field, `vw` how many
21 * of its columns (cells) are drawn when a narrow band scrolls it.
22 */
23export type Grid = { x: number; cw: number; ox: number; oy: number; width: number; height: number; vw: number }
24
25export type Drawn = { rows: Seg[][]; grid?: Grid }
26
27export type Game<S> = {
28 id: HalftimeGameId
29 title: string
30 /** Realtime games tick on the board's frame clock; turn-based ones never tick. */
31 realtime: boolean
32 /** Field rows the board needs to show the whole field. */
33 rows: number
34 tickMs: number
35 better: 'higher' | 'lower'
36 /** Key (as the board reports it) to the game's action name. */
37 controls: Readonly<Record<string, string>>
38 hint: string
39 init: (seed: number) => S
40 act: (s: S, action: string) => S
41 step?: (s: S, dtMs: number) => S
42 click?: (s: S, cx: number, cy: number, button: string) => S
43 isOver: (s: S) => boolean
44 /** The run's score, or null when it earned none (a lost sweep). */
45 score: (s: S, playedMs: number) => number | null
46 draw: (s: S, cols: number, rows: number) => Drawn
47}
48
49/**
50 * Keys no game may ever use: Enter, y, n and the digits answer Claude Code's
51 * own prompts, so a stray press meant for a game must never be one of them.
52 */
53export function isForbiddenKey(key: string): boolean {
54 const k = key.toLowerCase()
55 return k === 'return' || k === 'enter' || k === '\r' || k === '\n' || k === 'y' || k === 'n' || /^[0-9]$/.test(k)
56}
57
58/** The board reports a space as ' ' or 'space' depending on the surface; one name here. */
59export function normalizeKey(key: string): string {
60 if (key === ' ' || key === 'space') return 'space'
61 return key.length === 1 ? key.toLowerCase() : key
62}
63
64/** A horizontal window of `size` over `total` cells keeping `focus` in view. */
65export function windowStart(total: number, size: number, focus: number): number {
66 if (size >= total) return 0
67 return Math.max(0, Math.min(total - size, focus - Math.floor(size / 2)))
68}
69
70export const clamp = (n: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, n))
71
72/** Joins neighbouring cells of the same style into one Seg each. */
73export function paint(cells: readonly Cell[]): Seg[] {
74 const out: Seg[] = []
75 for (const [text, fg = '', bg = '', flags = 0] of cells) {
76 const last = out[out.length - 1]
77 if (last && last[1] === fg && last[2] === bg && last[3] === flags) last[0] += text
78 else out.push([text, fg, bg, flags])
79 }
80 return out
81}
82
83/**
84 * The play window: a field's rows centered in `cols` with a dim rail on each
85 * side, and no extra rows. `width` is the field's width in terminal columns;
86 * `x` is the column where the field's first column is drawn (pad and rail
87 * counted), which is what a Grid's `x` must be.
88 */
89export function frame(field: readonly (readonly Cell[])[], width: number, cols: number): { rows: Seg[][]; x: number } {
90 const left = Math.max(0, Math.floor((cols - width - 2) / 2))
91 const rail: Cell = ['│', 'subtle', undefined, DIM]
92 const pad: Cell[] = left > 0 ? [[' '.repeat(left)]] : []
93 return { rows: field.map(cells => paint([...pad, rail, ...cells, rail])), x: left + 1 }
94}
95
96/** A small stable integer hash, for decoration that must not touch the game's randomness. */
97export function hash2(a: number, b: number): number {
98 let h = Math.imul(a ^ Math.imul(b, 0x9e3779b1), 0x85ebca6b)
99 h ^= h >>> 13
100 return Math.imul(h, 0xc2b2ae35) >>> 0
101}
102hooks/rng.ts 49 lines1// Seeded randomness and the daily seed. Game logic never calls Math.random:
2// every draw goes through `rand`, whose state is a plain number kept in the
3// game state, so a run is a pure function of its seed and its inputs.
4
5import type { HalftimeGameId } from '../types'
6
7/** One mulberry32 step: a float in [0, 1) and the next state. */
8export function rand(state: number): [number, number] {
9 const next = (state + 0x6d2b79f5) >>> 0
10 let t = next
11 t = Math.imul(t ^ (t >>> 15), t | 1)
12 t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
13 return [((t ^ (t >>> 14)) >>> 0) / 4294967296, next]
14}
15
16/** A whole number in [0, n) and the next state. */
17export function randInt(state: number, n: number): [number, number] {
18 const [f, next] = rand(state)
19 return [Math.min(n - 1, Math.floor(f * n)), next]
20}
21
22/** FNV-1a, 32 bit: a stable hash of a string. */
23export function fnv1a(text: string): number {
24 let h = 0x811c9dc5
25 for (let i = 0; i < text.length; i += 1) {
26 h ^= text.charCodeAt(i)
27 h = Math.imul(h, 0x01000193)
28 }
29 return h >>> 0
30}
31
32const pad = (n: number) => String(n).padStart(2, '0')
33
34/** The calendar day of a time, `YYYY-MM-DD`, in the machine's own time zone. */
35export function dayKey(ms: number): string {
36 const d = new Date(ms)
37 return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
38}
39
40/** The same seed for everyone on the same day: it depends on nothing but the day and the game. */
41export function dailySeed(game: HalftimeGameId, day: string): number {
42 return fnv1a(`halftime/${game}/${day}`)
43}
44
45/** A practice seed: from the time and a counter, so two runs in one millisecond still differ. */
46export function practiceSeed(game: HalftimeGameId, now: number, n: number): number {
47 return fnv1a(`halftime/${game}/practice/${now}/${n}`)
48}
49hooks/games/cratehop.ts 257 lines1// cratehop: a one-button runner. The courier runs right on its own; one key
2// jumps the crates. The course is laid out by distance from the seed alone,
3// so everyone's daily course is the same; speed rises with distance.
4//
5// Fair by construction, in four rules the tests hold it to:
6// 1. Gentle start: the first 10 s (100 cells) run at the start speed, the
7// first crate is 4.5 s out, and the gaps around the early crates are wide.
8// 2. A slow ramp with a cap: 0.18 cells/s of speed gained per second, from
9// 10 up to 24 cells/s, about 80 s after the gentle start.
10// 3. Every gap is at least one full jump plus a 250 ms reaction margin at the
11// speed the runner has where the next crate stands (`minGap`).
12// 4. Every crate can be cleared with a timing window of 250 ms or more, at
13// any speed up to the cap (`clearWindowMs`).
14// Forgiveness: the hit boxes are smaller than the drawn art, a jump pressed up
15// to 120 ms before landing fires on touchdown, and a click anywhere on the
16// board jumps.
17
18import type { HalftimeCratehop } from '../../types'
19import { rand } from '../rng'
20import { BOLD, DIM, clamp, frame, type Cell, type Drawn, type Game } from './kit'
21import { BOLT_RUN, boltCells } from '../bolt'
22
23export const CRATEHOP = {
24 /** The runner's screen column. */
25 col: 6,
26 /** Start speed, cells/s: the courier covers the first 10 s here. */
27 v0: 10,
28 /** Cells held at the start speed (10 s of it) before the ramp begins. */
29 gentle: 100,
30 /** Twice the acceleration per cell: v(x) = sqrt(v0^2 + accel2 * (x - gentle)), i.e. +0.18 cells/s every second. */
31 accel2: 0.36,
32 /** The speed cap, cells/s: the fastest that still leaves 250 ms to see and answer a crate on a 24-column band (`reactionMs`); the hardest crate's timing window at this speed is 370 ms. */
33 vmax: 24,
34 /** Take-off speed (cells/s up) and gravity: a jump peaks at 2.58 cells, lasts 0.74 s, and clears a 2-high crate. */
35 jump: 14,
36 gravity: 38,
37 lookahead: 110,
38 /** Crate-free cells at the start: 4.5 s at the start speed. */
39 runUp: 45,
40 /** The longest stretch of time one tick may advance (a stalled frame never teleports). */
41 maxDtMs: 250,
42 subStepMs: 8,
43 rows: 5,
44 /** The reaction time every gap leaves the player, and the timing window every crate must offer, ms. */
45 reactMs: 250,
46 /** A jump pressed this close (ms) to touchdown is kept and fires on landing. */
47 bufferMs: 120,
48 /** Hit boxes. The courier is 0.4 wide, centered on its x; a crate is its drawn width less `inset` at each end; its top is `clearance` below the drawn top. */
49 halfFoot: 0.2,
50 inset: 0.35,
51 clearance: 0.35,
52 /** Early gaps are widened by up to 80% of a second of ground, fading to nothing by this distance. */
53 calmUntil: 400,
54 calmSecs: 0.8,
55} as const
56
57/** Speed at a distance: flat for the gentle start, then a slow ramp, then the cap. */
58export function speedAt(x: number): number {
59 const run = Math.max(0, x - CRATEHOP.gentle)
60 return Math.min(CRATEHOP.vmax, Math.sqrt(CRATEHOP.v0 * CRATEHOP.v0 + CRATEHOP.accel2 * run))
61}
62
63/** How far one jump carries at a speed. */
64export function airDistance(v: number): number {
65 return (v * 2 * CRATEHOP.jump) / CRATEHOP.gravity
66}
67
68/**
69 * The least clear ground after a crate, given the speed reached at the next
70 * one: one full jump, plus the ground covered in the reaction time. A player
71 * who jumps the first crate on time lands at least 250 ms before the second
72 * crate's take-off point.
73 */
74export function minGap(v: number): number {
75 return Math.ceil(airDistance(v) + (v * CRATEHOP.reactMs) / 1000)
76}
77
78/**
79 * How many milliseconds wide the window of take-off moments is that clear a
80 * crate `w` wide and `h` high at speed `v`: the time the courier spends above
81 * the crate's top in one jump, less the time it takes to cross the crate.
82 */
83export function clearWindowMs(v: number, w: number, h: number): number {
84 const need = h - CRATEHOP.clearance
85 const disc = CRATEHOP.jump * CRATEHOP.jump - 2 * CRATEHOP.gravity * need
86 if (disc <= 0) return 0
87 const above = (2 * Math.sqrt(disc)) / CRATEHOP.gravity
88 const across = (w - 2 * CRATEHOP.inset + 2 * CRATEHOP.halfFoot) / v
89 return Math.max(0, (above - across) * 1000)
90}
91
92/**
93 * How many milliseconds a player has to see and answer a crate: from its near
94 * edge coming on screen, on a field `width` cells wide (the courier stands in
95 * column `col`), to the moment the courier passes its ideal take-off point.
96 * At the speed cap this is 250 ms or more on any field from 22 cells (a
97 * 24-column band) up; narrower than that, the cap outruns the sight line.
98 */
99export function reactionMs(v: number, width: number): number {
100 const sight = width - CRATEHOP.col - 1
101 return ((sight - airDistance(v) / 2) / v) * 1000
102}
103
104/** Seconds until a courier at height y, moving at vy, touches down. */
105export function timeToLand(y: number, vy: number): number {
106 if (y <= 0 && vy <= 0) return 0
107 return (vy + Math.sqrt(vy * vy + 2 * CRATEHOP.gravity * Math.max(0, y))) / CRATEHOP.gravity
108}
109
110/**
111 * The ground after a crate whose far edge is at `from`: never below `minGap`
112 * for the speed where the next crate will stand, widened early on (`calm`) and
113 * by a random stretch. Bounded: if the speed shifts as the gap is settled, the
114 * gap is raised to match; it never settles lower than the rule.
115 */
116function gapAfter(from: number, roll: number): number {
117 const v = speedAt(from)
118 const calm = Math.max(0, 1 - from / CRATEHOP.calmUntil)
119 let gap = minGap(v) + Math.round(v * CRATEHOP.calmSecs * calm) + Math.floor(roll * Math.ceil(v * 0.6))
120 for (let i = 0; i < 4; i += 1) {
121 const need = minGap(speedAt(from + gap))
122 if (gap >= need) return gap
123 gap = need
124 }
125 return minGap(CRATEHOP.vmax)
126}
127
128/** Tall crates (two high) are rare at first and never more than three in ten. */
129const tallChance = (x: number) => clamp((x - 150) / 5000, 0, 0.3)
130
131/** How wide a low crate can be: one cell at first, then two, then three. */
132const widthCap = (x: number) => (x < 100 ? 1 : x < 300 ? 2 : 3)
133
134/** Lays out crates until the course reaches `until`; depends on the seed and distance only. */
135export function layout(s: HalftimeCratehop, until: number): HalftimeCratehop {
136 let { rng, nextX } = s
137 const obs = [...s.obs]
138 while (nextX < until) {
139 let r1: number
140 let r2: number
141 let r3: number
142 ;[r1, rng] = rand(rng)
143 ;[r2, rng] = rand(rng)
144 ;[r3, rng] = rand(rng)
145 const tall = r1 < tallChance(nextX)
146 const w = tall ? (nextX > 600 && r2 < 0.4 ? 2 : 1) : 1 + Math.floor(r2 * widthCap(nextX))
147 obs.push([nextX, w, tall ? 2 : 1])
148 nextX += w + gapAfter(nextX + w, r3)
149 }
150 return { ...s, rng, nextX, obs }
151}
152
153export function initCratehop(seed: number): HalftimeCratehop {
154 return layout({ rng: seed >>> 0, x: 0, y: 0, vy: 0, obs: [], nextX: CRATEHOP.runUp, dead: false, buffered: false }, CRATEHOP.lookahead)
155}
156
157/**
158 * Whether the courier, its feet at height y, hits a crate at distance x. The
159 * boxes are smaller than the art: the courier's is a 0.4-wide slice centered
160 * on x (the courier is drawn in cell floor(x)), the crate's is its drawn width
161 * less 0.35 at each end, and its top counts 0.35 lower - the point at which the
162 * courier is drawn one row up. A graze is not a hit.
163 */
164export function hits(obs: readonly [number, number, number][], x: number, y: number): boolean {
165 for (const [ox, w, h] of obs) {
166 if (x + CRATEHOP.halfFoot > ox + CRATEHOP.inset && x - CRATEHOP.halfFoot < ox + w - CRATEHOP.inset && y < h - CRATEHOP.clearance) return true
167 }
168 return false
169}
170
171export function jumpCratehop(s: HalftimeCratehop): HalftimeCratehop {
172 if (s.dead) return s
173 if (s.y === 0 && s.vy === 0) return { ...s, vy: CRATEHOP.jump }
174 // Pressed in the last 120 ms before touchdown: kept, and taken on landing.
175 if (timeToLand(s.y, s.vy) * 1000 <= CRATEHOP.bufferMs) return { ...s, buffered: true }
176 return s
177}
178
179/** Advances real time: small fixed sub-steps, so the outcome barely depends on the frame rate. */
180export function stepCratehop(s: HalftimeCratehop, dtMs: number): HalftimeCratehop {
181 if (s.dead) return s
182 let { x, y, vy, buffered } = s
183 let dead = false
184 let left = clamp(dtMs, 0, CRATEHOP.maxDtMs)
185 while (left > 0 && !dead) {
186 const h = Math.min(left, CRATEHOP.subStepMs)
187 left -= h
188 const sec = h / 1000
189 x += speedAt(x) * sec
190 if (y > 0 || vy > 0) {
191 vy -= CRATEHOP.gravity * sec
192 y += vy * sec
193 if (y <= 0) {
194 y = 0
195 vy = 0
196 if (buffered) {
197 vy = CRATEHOP.jump
198 buffered = false
199 }
200 }
201 }
202 dead = hits(s.obs, x, y)
203 }
204 const kept = s.obs.filter(([ox, w]) => ox + w > x - CRATEHOP.col - 2)
205 return layout({ ...s, x, y, vy, buffered, dead, obs: kept }, x + CRATEHOP.lookahead)
206}
207
208export function drawCratehop(s: HalftimeCratehop, cols: number): Drawn {
209 const width = clamp(cols - 2, 14, 70)
210 const cam = Math.floor(s.x) - CRATEHOP.col
211 const feet = 3 - clamp(Math.floor(s.y + 0.35), 0, 3)
212 // The courier is Bolt: the tiny sprite, 3 cells by 2, centered on the courier's
213 // column, its feet on `feet`. Run frames swap every 1.5 cells run; jump and crash have their own.
214 const pose = s.dead ? 'crash' : s.y > 0 ? 'jump' : BOLT_RUN[Math.floor(s.x / 1.5) % 2]!
215 const bolt = boltCells('tiny', pose)
216 const field: Cell[][] = []
217 for (let row = 0; row < CRATEHOP.rows; row += 1) {
218 const cells: Cell[] = []
219 for (let c = 0; c < width; c += 1) {
220 const wc = cam + c
221 if (row === 4) {
222 // The track: a solid floor, with a brighter mark every eight cells so the ground is seen to move.
223 cells.push(wc % 8 === 0 ? ['▀', 'text', undefined, DIM] : ['▀', 'subtle'])
224 continue
225 }
226 // Bolt's empty cells are see-through: a crate behind them still shows.
227 const sprite = bolt[row - (feet - 1)]?.[c - (CRATEHOP.col - 1)]
228 if (sprite && (sprite.fg !== undefined || sprite.bg !== undefined)) {
229 cells.push([sprite.ch, sprite.fg, sprite.bg])
230 continue
231 }
232 const crate = s.obs.find(([ox, w]) => wc >= ox && wc < ox + w)
233 cells.push(crate && (row === 3 || (row === 2 && crate[2] === 2)) ? ['█', 'warning'] : [' '])
234 }
235 field.push(cells)
236 }
237 return { rows: frame(field, width, cols).rows }
238}
239
240export const cratehop: Game<HalftimeCratehop> = {
241 id: 'cratehop',
242 title: 'cratehop',
243 realtime: true,
244 rows: CRATEHOP.rows,
245 tickMs: 60,
246 better: 'higher',
247 controls: { space: 'jump', up: 'jump', w: 'jump', k: 'jump' },
248 hint: 'space or up: jump · p: pause · esc: keys back to the prompt',
249 init: initCratehop,
250 act: (s, action) => (action === 'jump' ? jumpCratehop(s) : s),
251 step: stepCratehop,
252 click: s => jumpCratehop(s),
253 isOver: s => s.dead,
254 score: s => Math.floor(s.x),
255 draw: (s, cols) => drawCratehop(s, cols),
256}
257