SLOPSHOPPER

halftime

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

newbandspinnerrowsguardcommand
★ 1v0.1.6MITupdated 2026-10-14modmyclaude/claude-code-mods/mods/halftime
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · halftime
› fix the failing auth test and add an audit log call ╭────────────────────────────────────────────╮ │ halftime │ ⏺ Read(src/auth.ts) │ halftime · cratehop - · tailchase - · │ ⎿ Read 6 lines │ merge - · sweep - · 0s played while │ ⏺ Update(src/auth.ts) │ waiting │ ⎿ Added 2 lines, removed 1 line ╰────────────────────────────────────────────╯ ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /halftime ⟨Claude Code's own drawing⟩ ▄▀▄ halftime [ cratehop ] [ tailchase ] [ merge ] [ sweep ] [ daily ] [ auto: off ] [ close ] ▀▀▀ halftime · cratehop - · tailchase - · merge - · sweep - · 0s played while waiting ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ ▄▀▄ halftime [ cratehop ] [ tailchase ] [ merge ] [ sweep ] [ daily ] [ auto: off ] [ close ] ▀▀▀ halftime · cratehop - · tailchase - · merge - · sweep - · 0s played while waiting
README

<img src="../../brand/pixel-icons/svg/halftime.svg" width="64" height="64" alt="">

Halftime

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.

Install

/plugin install halftime --marketplace modmyclaude/claude-code-mods

Use it

CommandWhat it does
/halftimeOpens 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 autoTurns auto on or off (/halftime auto on, /halftime auto off).
/halftime offCloses 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):

  • cratehop - a courier runs on its own; 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.
  • tailchase - steer a snake on a wide, short field with the arrows, 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.
  • merge - slide a 4x4 board of lettered tiers (arrows, 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.
  • sweep - a 16x6 mine field with 12 mines. Arrows move, 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.

Settings

  • On: on by default. Off, Halftime draws nothing, blows no whistle and keeps no timer; /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.
  • Auto mode (/halftime auto) and your last game are remembered across sessions; the picker's daily / practice toggle lasts the session.

What it can touch

  • Its own store only (Claude Code's per-plugin store): personal bests (best:<game>), today's daily best (daily:<game>), totals (play time, play time while Claude worked, runs), the auto switch and your last game.
  • The clipboard, only when you press copy.
  • No files, no processes, no network, no model calls.
  • Timers run only while a game is played: the board's frame clock while a realtime run is in play and holds your keys, and a 3-second countdown. A paused or closed game ticks and redraws nothing. Auto mode sets one 10-second timer per turn.

What its hooks do

Halftime decides nothing for you and changes no setting. Each hook watches and passes the event on unchanged:

  • The On switch (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.
  • Permission prompts, input requests and notifications (classic.PermissionRequest, classic.Elicitation, classic.Notification): blow the whistle, pausing the game, when Claude needs you. They decide no permission and answer nothing.
  • A question Claude asks you (tool.call, AskUserQuestion only): pauses the game while the question is on screen. It never answers or denies it.
  • Claude moving on (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.
  • The game board (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.
  • The tests (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.

Limits

  • In the desktop app, a /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.
  • Terminal and desktop app only. VS Code and the mobile app have no band and no board; there /halftime shows a one-line scorecard instead.
  • Starting a game cannot hand it your keys. Claude Code gives the band the keyboard only on your click or its focus chord (ctrl+x tab), so /halftime cratehop puts the board up and the run starts on your first click or key.
  • Esc gives no signal to the mod. A realtime run you leave keeps going until you type at the prompt (it pauses then) or, at most, 20 seconds with no key or click, when it pauses itself. Those idle seconds never count as play. Turn-based games (merge, sweep) never tick, so they need no such pause.
  • After you answer a permission prompt, play resumes when that step's tools finish (the API has no "dialog answered" event), or at your next turn.
  • A permission prompt another hook settles before any dialog shows (one of your own settings hooks allows or denies it) still blows the whistle: Halftime passes Claude Code's answer on without reading it. Play comes back the same way, when that step's tools finish.
  • Two open sessions share bests safely (each save re-reads the store just before writing and repairs a lower write it finds after), but the store has no true compare-and-set: two saves in the same instant can still race. Totals are added the same way and can, rarely, miss a few seconds.
  • Copy works in the terminal; a surface without a clipboard path says so, and the line stays on screen to select.
  • The daily board follows your machine's calendar day.
  • cratehop's top speed leaves a quarter second to see and answer a crate on a band at least 24 columns wide; on a narrower one the courier sees too little of the course to be fair at the cap.
  • Drawing is verified against the plugin test kit, not a real terminal's paint.

Built from a prompt

prompts/halftime.md

More mods: the full catalogue.

Source 16 files
hooks/register.tsx 531 lines
1// 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
531
hooks/bolt-draw.tsx 86 lines
1// 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}
86
hooks/bests.ts 91 lines
1// 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}
91
hooks/engine.ts 311 lines
1// 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
311
hooks/games/index.ts 35 lines
1// 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}
35
hooks/share.ts 40 lines
1// 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}
40
hooks/view.ts 128 lines
1// 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}
128
hooks/board.tsx 221 lines
1// 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}
221
hooks/bolt.ts 109 lines
1// 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}
109
hooks/games/kit.ts 102 lines
1// 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}
102
hooks/rng.ts 49 lines
1// 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}
49
hooks/games/cratehop.ts 257 lines
1// 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