SLOPSHOPPER

arcade

Undici giochi da fare mentre Claude lavora (Snake, Tetris, Breakout, Wordle del codice, Labirinto 3D…), con i record e la pausa quando Claude finisce

newpaneguardcommandtoasttimer
v0.2.0MITupdated 2026-10-13Wowbagger1994/claude-mods/mods/arcade
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · arcade
│ ┃ Arcade ✕ › fix the failing auth test and add an audit log call │ ┃ ▣ client module ./board.tsx │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ 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 │ │ › /play │ ⎿ arcade: Arcade aperto: Esc torna al prompt, ctrl+x tab al gioco. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Arcade
▣ client module ./board.tsx
README

arcade

Undici giochi nel pannello di Claude Code, da fare mentre Claude lavora. La partita va in pausa da sola quando Claude finisce, così non perdi il momento di tornare al prompt.

Come si usa

/play apre il pannello con il menu e gli dà i tasti. Risponde «Arcade aperto: Esc torna al prompt, ctrl+x tab al gioco.». Se il gioco non riceve i tasti compare «Arcade: clicca sul gioco per dargli i tasti».

I giochi, nell'ordine del menu:

  1. Snake: mangia la frutta (10 punti), si allunga e accelera; il muro o la coda chiudono la partita.
  2. 2048: griglia 4×4, unisci le tessere uguali; si va avanti anche oltre 2048, finché resta una mossa.
  3. Claude runner: la mascotte di Claude corre e salta i cactus; la corsa accelera, un punto ogni due colonne.
  4. Flappy Claude: vola tra i tubi, un punto per tubo superato; toccare un tubo o il suolo chiude la partita.
  5. Acchiappa il bug: griglia 3×3 numerata come il tastierino; 30 secondi e 3 vite, 10 punti a bug e 5 in più se sei veloce; un tasto su una cella vuota o un bug che scappa costano una vita.
  6. Tetris: dieci colonne; 1–4 righe tolte insieme valgono 100, 300, 500 o 800 punti, moltiplicati per il livello, che sale ogni 10 righe.
  7. Campo minato: fino a 16×10 celle, il 15% sono mine, la prima cella è sicura; un punto per cella e un premio per la vittoria che cala con i secondi.
  8. Pioggia di parole: scrivi le parole che cadono prima che tocchino il fondo; 3 vite, le parole vengono dal codice che Claude ha scritto.
  9. Breakout: rompi il muro di mattoni con la palla; i mattoni più in alto valgono di più, a volte ne cade un bonus (racchetta larga, palla lenta, vita in più); 3 vite, e ogni muro abbattuto ne porta uno più veloce.
  10. Wordle del codice (tasto a): indovina in sei tentativi una parola di cinque lettere presa dal codice che Claude ha scritto, o dal vocabolario della programmazione; ogni parola risolta vale (7 − tentativi) × 10 punti, la prima mancata chiude la partita.
  11. Labirinto 3D (tasto b): un labirinto in prima persona; trova la porta verde nell'angolo opposto prima che scada il tempo. Ogni uscita vale 100 punti per livello più 5 per ogni secondo rimasto, e il labirinto dopo è più grande.

Com'è fatto ogni gioco: tutti stanno in una cornice dai bordi arrotondati con lo stesso tono di colori, e ogni evento si vede (punti nel bordo, lampi, cornice che cambia colore).

  • Menu: un riquadro «◆ ARCADE» con un'icona colorata per gioco, cosa si fa in due parole e il record (★) a destra; la riga scelta è evidenziata in arancione.
  • Snake: il serpente sfuma dal verde acceso della testa, con gli occhi, al verde scuro della coda; il frutto pulsa e, quando lo mangia, la cornice si accende, compare +10 e il boccone scivola lungo il corpo. Nel bordo basso, la velocità.
  • 2048: tessere dai colori classici, più alte quando c'è spazio; le fusioni dell'ultima mossa sono segnate con ✦, la tessera nuova cresce e il bordo mostra i punti fatti e la strada verso il 2048.
  • Claude runner: la mascotte corre su un terreno che scorre, con nuvole e dune più lente sullo sfondo, cactus con le braccia, scie di vento quando accelera, un'ombra quando salta e un'esplosione rossa se si schianta; ogni 100 punti il bordo lo festeggia.
  • Flappy Claude: un cielo con tre piani di profondità (stelle, nuvole e una città lontana, più lente dei tubi) e tubi in rilievo; la mascotte batte le ali a ogni spazio, prende un +1 verde a ogni tubo e, se si schianta, diventa rossa con gli occhi a croce.
  • Acchiappa il bug: ogni cella è un tasto col numero nel bordo; il bug muove le zampe e passa da viola ad ambra a rosso man mano che sta per scappare, con la barra della vita che gli resta sotto. Un colpo lo schiaccia e mostra i punti, un errore mostra «✗ mancato». In alto la barra del tempo; con meno di 16 righe le celle diventano basse e la barra sparisce.
  • Tetris: un pozzo con blocchi in rilievo, l'ombra che mostra dove cadrà il pezzo, una scia quando lo lanci giù, le righe complete che lampeggiano di bianco con i punti nel bordo, e la cornice che passa all'ambra e al rosso quando la pila sale; di lato il pezzo successivo e il livello con la sua barra.
  • Campo minato: caselle in rilievo, i numeri nei loro colori classici, il cursore arancione, le caselle appena scoperte velate di verde e contate nel bordo; in alto mine e bandierine, in basso la barra dei progressi; alla fine la mina esplosa in rosso o il campo ripulito in verde.
  • Breakout: disegnato a mezzi blocchi, così la palla scorre fluida con una scia; mattoni arcobaleno in rilievo che lampeggiano e si sbriciolano con il loro +N, bonus che cadono come pillole, vite e timer dei bonus nel bordo, cornice rossa per la palla persa e verde per il muro abbattuto.
  • Wordle del codice: tessere colorate che si girano una a una in verde, ambra o grigio; la riga corrente col cursore, la tastiera che ricorda le lettere provate, la riga che trema se mancano lettere, il +N alla parola risolta e la parola rivelata (con la sua provenienza) alla fine.
  • Labirinto 3D: muri disegnati in 3D con le ombre della distanza e due toni per lato, soffitto blu sfumato e pavimento a puntini che scorrono, la porta verde che pulsa; in alto a destra una minimappa con le celle percorse, in basso il tempo (ambra, poi rosso) e la bussola.
  • Pioggia di parole: le parole cadono in una corsia sotto una pioggia leggera; la parte già scritta diventa verde, le parole vicine al fondo ambra e poi rosse, una linea rossa segna il pericolo, i bordi mostrano combo, precisione e velocità, e ogni parola completata esplode con i suoi punti.
DoveTasti
Menu↑ ↓ (o w s) scegli · Invio o spazio gioca · 1–9, poi a e b, parte subito
Snakefrecce o WASD per girare
2048frecce o WASD per spostare le tessere
Claude runnerspazio o ↑ per saltare, ↓ per scendere
Flappy Claudespazio o ↑ per volare (il primo colpo fa partire)
Acchiappa il bug1–9: la cella col bug, come sul tastierino
Tetris← → muovi · ↑ ruota · ↓ scendi · spazio fa cadere
Campo minatofrecce muovi · spazio (o Invio) scopre · f bandierina
Pioggia di parolescrivi le parole · ⌫ corregge · Invio o spazio ricomincia la parola
Breakout← → (o a d) muovi · spazio o ↑ lancia la palla
Wordle del codicescrivi 5 lettere · Invio prova · ⌫ cancella
Labirinto 3D↑ ↓ (o w s) avanti e indietro · ← → (o a d) gira · m mostra o nasconde la mappa
In partitap pausa (Tab in Pioggia di parole e Wordle del codice) · Esc torna al prompt
In pausaInvio, spazio o il tasto di pausa riprende · m menu
Partita finitaInvio o spazio rigioca · m menu

Sopra il gioco una riga dice «● Claude sta lavorando…» oppure «✓ Claude ha finito: Esc torna al prompt». Sotto, l'icona e il nome del gioco con punti e record; sotto il gioco, i tasti. La pausa e la fine della partita compaiono in un riquadro: ambra «❚❚ PAUSA», rosso «FINE», verde «★ NUOVO RECORD».

Cosa fa da sola

  • Dopo 10 secondi di lavoro di Claude mostra «Claude ci mette un po’: /play per una partita (undici giochi, con i record)». Al massimo una volta ogni 10 minuti, e mai con il pannello già in vista.
  • Quando Claude finisce il turno mette in pausa la partita in corso («Claude ha finito: partita in pausa.») e, con il pannello in vista, mostra «Claude ha finito». Contano solo i turni della conversazione principale.
  • Dopo ogni Edit o Write riuscito prende i nomi dal testo scritto, per Pioggia di parole.
  • A fine partita salva il punteggio se è un nuovo record e scrive «Nuovo record!».

Cosa legge e cosa manda

  • File: nessuno letto. Le parole vengono dal testo nuovo di Edit e dal contenuto di Write, già passati allo strumento; mai dai file che possono contenere segreti (.env, chiavi .pem/.key, file con secret, credential, password o token nel nome).
  • Processi: nessuno.
  • Modello: nessuna chiamata.
  • Variabili d'ambiente: nessuna.
  • Stato della sessione: i record, se Claude sta lavorando, quanti turni sono finiti e fino a 200 parole per Pioggia di parole.
  • Record: restano tra una sessione e l'altra, nello store della mod ($.store, chiave best): un file JSON nella cartella di configurazione di Claude Code, scritto a ogni fine partita. Con due sessioni aperte vale il record più alto delle due.

Requisiti

  • Un terminale: nell'app desktop il pannello dice «I giochi si giocano nel terminale.».

Limiti

  • Le parole valide hanno da 4 a 14 caratteri; i nomi tutti in maiuscolo sono esclusi. Sotto le 20 parole si aggiunge un elenco di parole di JavaScript e TypeScript.
  • Il gioco ha sempre almeno 12 righe, anche se il pannello è più basso. Tetris ne vuole 16 (un pozzo di almeno 14 più il bordo): con meno il fondo resta tagliato.
  • Flappy Claude con 12 righe ha un cielo di 10 e varchi di 8: diventa molto facile. Con più spazio non cambia.
  • In 2048 i segni delle fusioni restano fino alla mossa dopo, perché il gioco non ha un orologio; lo stesso per le caselle appena scoperte di Campo minato.
  • Dopo la fine di una partita, Invio o spazio rigiocano solo dopo 600 millisecondi, per non ripartire per sbaglio.
  • In Pioggia di parole e in Wordle del codice le lettere sono mosse: per questo la pausa è Tab e non p.
  • In Wordle del codice ogni combinazione di cinque lettere vale come tentativo (niente dizionario); le parole prese dal codice possono essere sigle o pezzi di nomi.
  • In Labirinto 3D m in partita mostra o nasconde la mappa; in pausa e a fine partita torna al menu.
  • Breakout e Labirinto 3D si muovono a ogni tasto premuto: la fluidità dipende dalla ripetizione dei tasti del terminale.

Installazione

claude plugin marketplace add Wowbagger1994/claude-mods
claude plugin install arcade@enrik-mods

Oppure, dal prompt di Claude Code: /plugin install arcade --marketplace Wowbagger1994/claude-mods.

Sviluppo

Dalla cartella della mod:

claude plugin validate .
claude plugin test .

I test sono 293, in 16 file.

Source 26 files
hooks/register.tsx 164 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { GameId } from '../types'
5import { isGameId } from './games/catalog'
6import { addWords, isSecretFile, wordsFrom } from './words'
7
8const PLUGIN = 'arcade'
9const PANE = 'arcade'
10const TITLE = 'Arcade'
11/** Claude working this long earns an invitation to play. */
12const INVITE_AFTER_MS = 10_000
13/** At most one invitation in this long: a reminder, not a nag. */
14const INVITE_COOLDOWN_MS = 10 * 60_000
15/** Room the pane gives the board below its own line. */
16const MIN_BOARD_ROWS = 14
17
18const best = atom({ plugin: 'arcade', key: 'best' } as const, {})
19const isBusy = atom({ plugin: 'arcade', key: 'isBusy' } as const, false)
20const doneCount = atom({ plugin: 'arcade', key: 'doneCount' } as const, 0)
21const words = atom({ plugin: 'arcade', key: 'words' } as const, [])
22
23/** The main conversation's turn in progress, so a fork's turns neither start nor end Claude's work. */
24let activeTurn: string | null = null
25let lastInviteAt = Number.NEGATIVE_INFINITY
26
27const isPaneShown = async ($: EngineInterface) => (await $.ui.panes()).some(p => p.id === PANE && p.isShown)
28
29/** Opens the pane and gives the board the keys. */
30const openArcade = async ($: EngineInterface) => {
31  const opened = await $.ui.open({ id: PANE, title: TITLE, focus: true })
32  if (!opened.isPlaced) {
33    $.ui.toast('Arcade: allarga il terminale per vedere il pannello')
34    return
35  }
36  // Without the focus the keys stay with the prompt: a click on the board gives them.
37  const isFocused = await $.ui
38    .focus({ requestId: PANE, key: 'board' })
39    .then(r => !('deny' in r && r.deny !== undefined))
40    .catch((error: unknown) => {
41      $.ui.log(`${PLUGIN}: focus non dato al gioco: ${String(error)}`, { to: 'debug' })
42      return false
43    })
44  if (!isFocused) $.ui.toast('Arcade: clicca sul gioco per dargli i tasti')
45}
46
47const invite = async ($: EngineInterface, turnId: string) => {
48  try {
49    if (activeTurn !== turnId) return
50    const now = await $.clock.now()
51    if (now - lastInviteAt < INVITE_COOLDOWN_MS || (await isPaneShown($))) return
52    lastInviteAt = now
53    $.ui.toast('Claude ci mette un po’: /play per una partita (undici giochi, con i record)')
54  } catch (error) {
55    $.ui.log(`${PLUGIN}: invito non mostrato: ${String(error)}`, { to: 'debug' })
56  }
57}
58
59/** Where the records outlive the session: `$.state` is the session's, `$.store` the machine's. */
60const STORE_BEST = 'best'
61
62/** What the store holds, as far as it is records: known games, finite scores. */
63const savedBest = (raw: unknown): Partial<Record<GameId, number>> =>
64  typeof raw !== 'object' || raw === null
65    ? {}
66    : Object.fromEntries(Object.entries(raw).filter(([g, s]) => isGameId(g) && typeof s === 'number' && Number.isFinite(s)))
67
68/** The better of two sets of records, game by game. */
69const bestOf = (a: Partial<Record<GameId, number>>, b: Partial<Record<GameId, number>>): Partial<Record<GameId, number>> =>
70  Object.fromEntries([...new Set([...Object.keys(a), ...Object.keys(b)])].map(g => [g, Math.max(a[g as GameId] ?? 0, b[g as GameId] ?? 0)]))
71
72/** A board's message: `{ kind: 'score', game, score }` when a game ends; anything else is ignored. */
73const recordScore = async ($: EngineInterface, data: unknown) => {
74  if (typeof data !== 'object' || data === null) return
75  const { kind, game, score } = data as Record<string, unknown>
76  if (kind !== 'score' || !isGameId(game) || typeof score !== 'number' || !Number.isFinite(score)) return
77  await update($, best, b => (score > (b[game] ?? 0) ? { ...b, [game]: score } : b))
78  // Another session may have set a record meanwhile: the store keeps the better of both.
79  await $.store.set(STORE_BEST, bestOf(savedBest(await $.store.get(STORE_BEST)), await read($, best)))
80}
81
82/** The names in what Claude just wrote join the typing game's words. */
83const harvest = async ($: EngineInterface, path: string, code: string, ran: { deny?: string; isError?: true }) => {
84  if (ran.deny !== undefined || ran.isError === true || isSecretFile(path)) return
85  const fresh = wordsFrom(code)
86  if (fresh.length > 0) await update($, words, pool => addWords(pool, fresh))
87}
88
89export const register: Register = on => {
90  on('session.start', async ($, e, next) => {
91    await $.command.register({ name: 'play', description: 'Undici giochi da fare mentre Claude lavora, con i record' })
92    // A session that stopped mid-turn left this on: a new one starts idle.
93    activeTurn = null
94    await update($, isBusy, () => false)
95    const saved = savedBest(await $.store.get(STORE_BEST))
96    await update($, best, b => bestOf(b, saved))
97    return next(e)
98  })
99
100  on('command.run', { command: 'play' }, async $ => {
101    await openArcade($)
102    return { text: 'Arcade aperto: Esc torna al prompt, ctrl+x tab al gioco.' }
103  })
104
105  on('turn.start', async ($, e, next) => {
106    const started = await next(e)
107    if (activeTurn === null) {
108      activeTurn = e.turnId
109      await update($, isBusy, () => true)
110      $.clock.after(INVITE_AFTER_MS, () => void invite($, e.turnId))
111    }
112    return started
113  }).catch(($, e, next) => next(e))
114
115  on('turn.complete', async ($, e, next) => {
116    const done = await next(e)
117    if (e.turnId === activeTurn) {
118      activeTurn = null
119      await update($, isBusy, () => false)
120      await update($, doneCount, n => n + 1)
121      if (await isPaneShown($)) $.ui.toast('Claude ha finito')
122    }
123    return done
124  }).catch(($, e, next) => next(e))
125
126  on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
127    const ran = await next(e)
128    await harvest($, e.file_path, e.new_string, ran)
129    return ran
130  }).catch(($, e, next) => next(e))
131
132  on('tool.call', { tool: 'Write' }, async ($, e, next) => {
133    const ran = await next(e)
134    await harvest($, e.file_path, e.content, ran)
135    return ran
136  }).catch(($, e, next) => next(e))
137
138  on('ui.message', async ($, e) => {
139    await recordScore($, e.data)
140    return {}
141  })
142
143  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
144    const el = $.ui.resolve(e)
145    if (!('Client' in el)) return <el.Text dimColor>I giochi si giocano nel terminale.</el.Text>
146    const props = {
147      best: await read($, best),
148      isBusy: await read($, isBusy),
149      doneCount: await read($, doneCount),
150      words: await read($, words),
151      seed: Math.floor(await $.clock.now()),
152    }
153    return (
154      <el.Client
155        key="board"
156        module="./board.tsx"
157        props={props}
158        width={e.props.bodyColumns}
159        height={Math.max(MIN_BOARD_ROWS, e.props.scroll.bodyRows)}
160      />
161    )
162  })
163}
164
hooks/games/catalog.ts 19 lines
1import type { GameId } from '../../types'
2import { BREAKOUT } from './breakout'
3import { BUGS } from './bugs'
4import { G2048, RUNNER, SNAKE } from './classics'
5import { FLAPPY } from './flappy'
6import { MAZE } from './maze'
7import { MINES } from './mines'
8import { TETRIS } from './tetris'
9import type { AnyGame } from './types'
10import { TYPING } from './typing'
11import { WORDLE } from './wordle'
12
13/** The games in menu order: the key beside each (1–9, then letters) starts it. */
14export const GAMES: readonly AnyGame[] = [SNAKE, G2048, RUNNER, FLAPPY, BUGS, TETRIS, MINES, TYPING, BREAKOUT, WORDLE, MAZE]
15
16export const gameOf = (id: GameId): AnyGame => GAMES.find(g => g.id === id) ?? GAMES[0]!
17
18export const isGameId = (value: unknown): value is GameId => GAMES.some(g => g.id === value)
19
hooks/words.ts 21 lines
1/** Words kept for the typing game: enough for many rounds, small enough to hand the board. */
2export const MAX_WORDS = 200
3const MIN_LENGTH = 4
4const MAX_LENGTH = 14
5
6/** Names in a piece of code: identifiers of 4 to 14 letters, constants in capitals left out (hard to type). */
7export const wordsFrom = (code: string): string[] => {
8  const found = code.match(/[A-Za-z][A-Za-z0-9]*/g) ?? []
9  const fit = found.filter(w => w.length >= MIN_LENGTH && w.length <= MAX_LENGTH && w !== w.toUpperCase())
10  return [...new Set(fit)]
11}
12
13/** Files whose words could be secrets (`.env`, keys, credentials): never shown on the board. */
14const SECRET_FILE = /(^|\/)(\.env[^/]*|[^/]*(secret|credential|password|token)[^/]*|[^/]*\.(pem|key|p12|pfx))$/i
15
16export const isSecretFile = (path: string): boolean => SECRET_FILE.test(path)
17
18/** The pool with the newest words first, each once, at most MAX_WORDS. */
19export const addWords = (pool: readonly string[], fresh: readonly string[]): string[] =>
20  [...new Set([...fresh, ...pool])].slice(0, MAX_WORDS)
21
hooks/board.tsx 114 lines
1import type { ClientModule, ClientSurface } from 'claude-code'
2
3import { gameOf } from './games/catalog'
4import { PALETTE } from './games/style'
5import type { Row } from './games/types'
6import { bannerRows, lookOf, menuRows } from './menu'
7import type { BoardProps, Play, Step } from './play'
8import { CHROME_ROWS, drawOf, GAMES, isMoving, newPlay, onKey, onProps, onTick, pauseKeyOf, scoreOf, statusOf } from './play'
9
10/** 20 frames a second: smooth enough for the dino, light on the terminal. */
11const TICK_MS = 50
12/** The least room a game gets, however small the pane. */
13const MIN_GAME_ROWS = 12
14const BUSY = '#f59e0b'
15const DONE = '#22c55e'
16
17type Surface = ClientSurface<Play>
18type Elements = Surface['elements']
19
20/** Applies a step: redraws, and hands a finished game's score to the hooks module for the record. */
21const apply = (surface: Surface, step: Step) => {
22  if (step.play !== surface.state) surface.setState(step.play)
23  if (step.score !== null) surface.post({ kind: 'score', game: step.score.game, score: step.score.score })
24}
25
26/** Rows drawn as Texts; `prefix` keeps the keys of two blocks in one Box apart. */
27const rows = (el: Elements, lines: Row[], prefix = 'r') =>
28  lines.map((line, r) => (
29    <el.Text key={`${prefix}${r}`} wrap="truncate-end">
30      {line.map((span, i) => (
31        <el.Text key={`s${i}`} color={span.fg} backgroundColor={span.bg} bold={span.bold}>
32          {span.text}
33        </el.Text>
34      ))}
35    </el.Text>
36  ))
37
38const claudeLine = (el: Elements, props: BoardProps) =>
39  props.isBusy ? (
40    <el.Text color={BUSY}>● Claude sta lavorando…</el.Text>
41  ) : (
42    <el.Text color={DONE}>✓ Claude ha finito: Esc torna al prompt</el.Text>
43  )
44
45const menu = (el: Elements, p: Play, props: BoardProps, columns: number) => <el.Box flexDirection="column">{rows(el, menuRows(p.cursor, props.best, columns))}</el.Box>
46
47/** Under the game: the keys while playing; a framed banner when paused or over. */
48const message = (el: Elements, p: Play, columns: number) => {
49  const def = gameOf(p.game)
50  const pauseKey = pauseKeyOf(def) === 'tab' ? 'Tab' : 'p'
51  if (p.screen === 'paused') {
52    const why = p.pausedBy === 'claude' ? ['Claude ha finito:', 'partita in pausa.'] : ['In pausa.']
53    return rows(el, bannerRows({ title: '❚❚ PAUSA', color: PALETTE.hurry, lines: [...why, 'Invio riprende · m menu'] }, columns), 'banner')
54  }
55  if (p.screen === 'over') {
56    const record = p.isRecord ? ' Nuovo record!' : ''
57    const title = p.isRecord ? '★ NUOVO RECORD' : 'FINE'
58    const color = p.isRecord ? PALETTE.good : PALETTE.danger
59    return rows(el, bannerRows({ title, color, lines: [`Partita finita: ${scoreOf(p)} punti.${record}`, 'Invio rigioca · m menu'] }, columns), 'banner')
60  }
61  return [<el.Text key="help" dimColor>{`${def.keys} · ${pauseKey} pausa · Esc torna al prompt`}</el.Text>]
62}
63
64const game = (el: Elements, p: Play, props: BoardProps, columns: number) => {
65  const status = statusOf(p)
66  const look = lookOf(p.game)
67  return (
68    <el.Box flexDirection="column">
69      <el.Text>
70        <el.Text color={look.color} bold>{`${look.icon} `}</el.Text>
71        <el.Text bold>{gameOf(p.game).name}</el.Text>
72        <el.Text>{`  punti ${scoreOf(p)}`}</el.Text>
73        <el.Text dimColor>{`  record ${props.best[p.game] ?? 0}`}</el.Text>
74        {status !== '' && <el.Text color="claude">{`  ${status}`}</el.Text>}
75      </el.Text>
76      {rows(el, drawOf(p))}
77      {message(el, p, columns)}
78    </el.Box>
79  )
80}
81
82/** The arcade: a menu, then the game picked, driven by the frame clock and the keys. */
83const Board: ClientModule<BoardProps, Play> = (props, surface) => {
84  const el = surface.elements
85  const size = () => ({ columns: surface.columns, rows: Math.max(surface.rows, CHROME_ROWS + MIN_GAME_ROWS) })
86  if (surface.state === undefined) {
87    surface.every(TICK_MS, () => {
88      const p = surface.state
89      if (p !== undefined && isMoving(p)) apply(surface, onTick(p, TICK_MS, latest.props))
90    })
91    surface.onKey(k => {
92      const p = surface.state
93      if (p !== undefined) apply(surface, onKey(p, k.key, latest.props, size()))
94    })
95    surface.setState(newPlay(props))
96  }
97  latest.props = props
98  const p = surface.state ?? newPlay(props)
99  const seen = onProps(p, props)
100  if (seen !== p) surface.setState(seen)
101
102  return (
103    <el.Box flexDirection="column">
104      {claudeLine(el, props)}
105      {seen.screen === 'menu' ? menu(el, seen, props, surface.columns) : game(el, seen, props, surface.columns)}
106    </el.Box>
107  )
108}
109
110/** The props of the last call: what the timer and the key handler, set up once, read. */
111const latest: { props: BoardProps } = { props: { best: {}, isBusy: false, doneCount: 0, seed: 1, words: [] } }
112
113export default Board
114
hooks/games/breakout.ts 519 lines
1import { directionOf, isSpace } from './keys'
2import type { Rng } from './rng'
3import { nextRandom } from './rng'
4import { centerPad, framed, pack, PALETTE } from './style'
5import type { GameDef, Row, Span } from './types'
6
7/**
8 * Breakout on a field of half-block pixels: each text row holds two pixel
9 * rows, each column one pixel, so the ball moves half a row at a time and the
10 * pixels come out about square.
11 */
12
13/** The ball: where it is in pixels (fractional) and its speed in pixels per second, down positive. */
14export type Ball = { x: number; y: number; vx: number; vy: number }
15
16/** Waiting on the paddle for a launch, in flight, a moment for a lost ball, a moment for a cleared wall. */
17export type Phase = 'serve' | 'play' | 'lost' | 'cleared'
18
19export type BonusKind = 'wide' | 'slow' | 'life'
20
21/** A bonus falling from a broken brick: its middle column and its pixel row. */
22export type Drop = { kind: BonusKind; x: number; y: number }
23
24/** A brick just broken, lit for a moment where it was. */
25export type Shatter = { row: number; col: number; ms: number }
26
27/** Text floating up for a moment: the points of a brick, the name of a bonus caught. */
28export type Popup = { x: number; row: number; text: string; color: string; ms: number }
29
30export type Breakout = {
31  /** Columns and text rows of the field, inside the frame. */
32  width: number
33  height: number
34  /** Columns of room, to center the frame. */
35  columns: number
36  /** The wall: true where a brick stands, top row first. */
37  bricks: boolean[][]
38  brickWidth: number
39  /** The wall's first column, to center it in the field. */
40  brickLeft: number
41  /** The paddle's first column. */
42  paddleX: number
43  ball: Ball
44  phase: Phase
45  /** Milliseconds left of a `lost` or `cleared` moment. */
46  phaseMs: number
47  lives: number
48  score: number
49  level: number
50  /** Milliseconds left of a wide paddle and of a slow ball. */
51  wideMs: number
52  slowMs: number
53  drops: Drop[]
54  shatters: Shatter[]
55  popups: Popup[]
56  /** The column the last lost ball fell through. */
57  lostX: number
58  isOver: boolean
59  rng: Rng
60}
61
62export const LIVES = 3
63/** Points of the wall's lowest row; each row up is worth this much more. */
64export const ROW_POINTS = 10
65/** The wall's first text row: one free row above it to bounce in. */
66export const BRICK_TOP = 1
67const MIN_BRICK_ROWS = 3
68const MAX_BRICK_ROWS = 6
69/** One row of bricks for each this many rows of field. */
70const ROWS_PER_BRICK_ROW = 3
71/** Bricks are this wide, wider in a wide field. */
72const BRICK_WIDTH = 5
73const WIDE_BRICK_WIDTH = 6
74const WIDE_FIELD = 48
75export const PADDLE_WIDTH = 7
76export const WIDE_PADDLE_WIDTH = 11
77/** Columns the paddle moves for a key. */
78export const PADDLE_STEP = 3
79/** Ball speed in pixels per second at level 1, more at each level, never over the cap. */
80export const BASE_SPEED = 24
81const LEVEL_SPEEDUP = 0.12
82const MAX_SPEED = 44
83/** A slow ball moves at this share of its speed. */
84export const SLOW_FACTOR = 0.6
85/** The steepest bounce off the paddle's ends, from straight up; a launch's range. */
86const MAX_BOUNCE = (60 * Math.PI) / 180
87const MIN_LAUNCH = (15 * Math.PI) / 180
88const MAX_LAUNCH = (35 * Math.PI) / 180
89/** The ball never moves more than this many pixels in one step, so it cannot jump a brick. */
90const MAX_STEP = 0.5
91/** The share of broken bricks that drop a bonus; the share of bonuses that are a life (the rest, half wide, half slow). */
92export const DROP_CHANCE = 0.2
93const LIFE_SHARE = 0.2
94/** Pixels per second a bonus falls. */
95export const DROP_SPEED = 9
96export const WIDE_MS = 12_000
97export const SLOW_MS = 10_000
98export const MAX_LIVES = 5
99/** How long a lost ball and a cleared wall hold the game; a broken brick stays lit; a popup floats. */
100export const LOST_MS = 1_200
101export const CLEARED_MS = 1_500
102export const SHATTER_MS = 240
103export const POPUP_MS = 700
104const MIN_WIDTH = 28
105const MAX_WIDTH = 58
106const MIN_HEIGHT = 10
107const MAX_HEIGHT = 22
108/** Rows and columns the frame takes around the field. */
109const FRAME_CELLS = 2
110/** A hair under a whole pixel, to keep a reflected ball inside the field. */
111const EPSILON = 1e-6
112
113const clamp = (n: number, low: number, high: number) => Math.max(low, Math.min(high, n))
114
115// ── The field ──────────────────────────────────────────────────────────────
116
117export const paddleWidth = (g: Breakout): number => (g.wideMs > 0 ? WIDE_PADDLE_WIDTH : PADDLE_WIDTH)
118/** The pixel row of the paddle's top: it fills the field's last text row. */
119export const paddleRow = (g: Breakout): number => g.height * 2 - 2
120export const speedAt = (level: number): number => Math.min(MAX_SPEED, BASE_SPEED * (1 + LEVEL_SPEEDUP * (level - 1)))
121export const bricksLeft = (g: Breakout): number => g.bricks.reduce((n, row) => n + row.filter(Boolean).length, 0)
122const brickRowsFor = (height: number) => clamp(Math.floor(height / ROWS_PER_BRICK_ROW), MIN_BRICK_ROWS, MAX_BRICK_ROWS)
123/** Points of a brick in `row`, the top row worth the most. */
124export const pointsOf = (g: Breakout, row: number): number => (g.bricks.length - row) * ROW_POINTS
125
126const fullWall = (rows: number, cols: number): boolean[][] => Array.from({ length: rows }, () => Array<boolean>(cols).fill(true))
127
128/** The ball resting on the paddle's middle, ready to launch. */
129const restingBall = (g: Breakout): Ball => ({ x: g.paddleX + paddleWidth(g) / 2, y: paddleRow(g) - 0.5, vx: 0, vy: 0 })
130
131export const newBreakout = (columns: number, rows: number, rng: Rng): Breakout => {
132  const width = clamp(columns - FRAME_CELLS, MIN_WIDTH, MAX_WIDTH)
133  const height = clamp(rows - FRAME_CELLS, MIN_HEIGHT, MAX_HEIGHT)
134  const brickWidth = width >= WIDE_FIELD ? WIDE_BRICK_WIDTH : BRICK_WIDTH
135  const cols = Math.floor(width / brickWidth)
136  const base = {
137    width,
138    height,
139    columns,
140    bricks: fullWall(brickRowsFor(height), cols),
141    brickWidth,
142    brickLeft: Math.floor((width - cols * brickWidth) / 2),
143    paddleX: Math.floor((width - PADDLE_WIDTH) / 2),
144    ball: { x: 0, y: 0, vx: 0, vy: 0 },
145    phase: 'serve' as const,
146    phaseMs: 0,
147    lives: LIVES,
148    score: 0,
149    level: 1,
150    wideMs: 0,
151    slowMs: 0,
152    drops: [],
153    shatters: [],
154    popups: [],
155    lostX: 0,
156    isOver: false,
157    rng,
158  }
159  return { ...base, ball: restingBall(base) }
160}
161
162/** The brick a pixel falls in, standing or not; null outside the wall. */
163const brickCellOf = (g: Breakout, x: number, y: number): { row: number; col: number } | null => {
164  const row = Math.floor(y / 2) - BRICK_TOP
165  const col = Math.floor((x - g.brickLeft) / g.brickWidth)
166  const isInside = row >= 0 && row < g.bricks.length && col >= 0 && col < (g.bricks[0]?.length ?? 0) && x >= g.brickLeft
167  return isInside ? { row, col } : null
168}
169
170const standingAt = (g: Breakout, x: number, y: number) => {
171  const cell = brickCellOf(g, x, y)
172  return cell !== null && g.bricks[cell.row]?.[cell.col] === true ? cell : null
173}
174
175// ── Moves ──────────────────────────────────────────────────────────────────
176
177/** The paddle `dx` columns over, inside the field; a ball at rest rides along. */
178export const movePaddle = (g: Breakout, dx: number): Breakout => {
179  const paddleX = clamp(g.paddleX + dx, 0, g.width - paddleWidth(g))
180  const moved = { ...g, paddleX }
181  return g.phase === 'serve' ? { ...moved, ball: restingBall(moved) } : moved
182}
183
184/** The ball off the paddle, up and a little to one side, picked at random. */
185export const launch = (g: Breakout): Breakout => {
186  if (g.phase !== 'serve') return g
187  const [side, r1] = nextRandom(g.rng)
188  const [tilt, r2] = nextRandom(r1)
189  const angle = (MIN_LAUNCH + tilt * (MAX_LAUNCH - MIN_LAUNCH)) * (side < 0.5 ? -1 : 1)
190  const speed = speedAt(g.level)
191  return { ...g, phase: 'play', ball: { ...restingBall(g), vx: speed * Math.sin(angle), vy: -speed * Math.cos(angle) }, rng: r2 }
192}
193
194export const keyBreakout = (g: Breakout, key: string): Breakout => {
195  if (g.isOver) return g
196  const dir = directionOf(key)
197  if (dir === 'left' || dir === 'right') return movePaddle(g, dir === 'left' ? -PADDLE_STEP : PADDLE_STEP)
198  return isSpace(key) || dir === 'up' ? launch(g) : g
199}
200
201// ── Collisions ─────────────────────────────────────────────────────────────
202
203/** A bonus, sometimes, out of a broken brick at column `x`, pixel row `y`. */
204const maybeDrop = (g: Breakout, x: number, y: number): Breakout => {
205  const [chance, r1] = nextRandom(g.rng)
206  if (chance >= DROP_CHANCE) return { ...g, rng: r1 }
207  const [pick, r2] = nextRandom(r1)
208  const kind: BonusKind = pick < LIFE_SHARE ? 'life' : pick < (1 + LIFE_SHARE) / 2 ? 'wide' : 'slow'
209  return { ...g, drops: [...g.drops, { kind, x, y }], rng: r2 }
210}
211
212/** The brick at `cell` broken: its points, a flash and a popup where it was, maybe a bonus, maybe the wall cleared. */
213export const breakBrick = (g: Breakout, cell: { row: number; col: number }): Breakout => {
214  const points = pointsOf(g, cell.row)
215  const middle = g.brickLeft + cell.col * g.brickWidth + Math.floor(g.brickWidth / 2)
216  const textRow = BRICK_TOP + cell.row
217  const broken: Breakout = {
218    ...g,
219    bricks: g.bricks.map((row, r) => (r === cell.row ? row.map((b, c) => (c === cell.col ? false : b)) : row)),
220    score: g.score + points,
221    shatters: [...g.shatters, { ...cell, ms: SHATTER_MS }],
222    popups: [...g.popups, { x: middle, row: textRow, text: `+${points}`, color: LIGHTS[cell.row % LIGHTS.length]!, ms: POPUP_MS }],
223  }
224  if (bricksLeft(broken) === 0) return { ...broken, phase: 'cleared', phaseMs: CLEARED_MS, drops: [] }
225  return maybeDrop(broken, middle, textRow * 2 + 1)
226}
227
228/** The ball off the paddle: straight up from its middle, slanting more the nearer an end it lands. */
229const bounceOffPaddle = (g: Breakout, ball: Ball): Ball => {
230  const half = paddleWidth(g) / 2
231  const offset = clamp((ball.x - (g.paddleX + half)) / half, -1, 1)
232  const speed = Math.hypot(ball.vx, ball.vy)
233  const angle = offset * MAX_BOUNCE
234  const top = paddleRow(g)
235  return { x: ball.x, y: top - (ball.y - top) - EPSILON, vx: speed * Math.sin(angle), vy: -speed * Math.cos(angle) }
236}
237
238/** A ball through the bottom: a life gone, and the game with it when none are left. */
239export const loseBall = (g: Breakout): Breakout => {
240  const lives = g.lives - 1
241  return { ...g, lives, phase: 'lost', phaseMs: LOST_MS, lostX: clamp(Math.floor(g.ball.x), 0, g.width - 1), drops: [], wideMs: 0, slowMs: 0, isOver: lives <= 0 }
242}
243
244/** The ball off the walls and the ceiling, at its new place. */
245const offWalls = (g: Breakout, b: Ball): Ball => {
246  const fromSide = b.x < 0 ? { x: -b.x, vx: Math.abs(b.vx) } : b.x >= g.width ? { x: 2 * g.width - b.x - EPSILON, vx: -Math.abs(b.vx) } : { x: b.x, vx: b.vx }
247  const fromTop = b.y < 0 ? { y: -b.y, vy: Math.abs(b.vy) } : { y: b.y, vy: b.vy }
248  return { ...fromSide, ...fromTop }
249}
250
251/** The ball moved `dt` seconds (short enough not to skip a pixel), and what it hits. */
252const stepBall = (g: Breakout, dt: number): Breakout => {
253  const from = g.ball
254  const to = offWalls(g, { ...from, x: from.x + from.vx * dt, y: from.y + from.vy * dt })
255  const hit = standingAt(g, to.x, to.y)
256  if (hit !== null) {
257    // Into a brick from the side when it stays in the same row of bricks: then it turns back across, else up or down.
258    const isFromSide = Math.floor(from.y / 2) === Math.floor(to.y / 2)
259    const ball = { ...from, vx: isFromSide ? -to.vx : to.vx, vy: isFromSide ? to.vy : -to.vy }
260    return breakBrick({ ...g, ball }, hit)
261  }
262  const top = paddleRow(g)
263  const isOnPaddle = to.vy > 0 && from.y < top && to.y >= top && to.x >= g.paddleX - MAX_STEP && to.x < g.paddleX + paddleWidth(g) + MAX_STEP
264  if (isOnPaddle) return { ...g, ball: bounceOffPaddle(g, to) }
265  if (to.y >= g.height * 2) return loseBall({ ...g, ball: to })
266  return { ...g, ball: to }
267}
268
269/** `ms` of flight in short steps, stopping at a lost ball or a cleared wall. */
270const fly = (g: Breakout, ms: number): Breakout => {
271  const factor = g.slowMs > 0 ? SLOW_FACTOR : 1
272  const distance = Math.hypot(g.ball.vx, g.ball.vy) * factor * (ms / 1000)
273  const steps = Math.max(1, Math.ceil(distance / MAX_STEP))
274  const dt = (factor * ms) / 1000 / steps
275  let state = g
276  for (let i = 0; i < steps && state.phase === 'play'; i++) state = stepBall(state, dt)
277  return state
278}
279
280const BONUS_NAMES: Record<BonusKind, string> = { wide: 'larga!', slow: 'lenta!', life: '+1 ♥' }
281
282/** A bonus caught by the paddle at work. */
283export const catchBonus = (g: Breakout, kind: BonusKind): Breakout => {
284  const popup = { x: g.paddleX + Math.floor(paddleWidth(g) / 2), row: g.height - 2, text: BONUS_NAMES[kind], color: BONUS_COLORS[kind], ms: POPUP_MS }
285  const caught = { ...g, popups: [...g.popups, popup] }
286  if (kind === 'life') return { ...caught, lives: Math.min(MAX_LIVES, g.lives + 1) }
287  if (kind === 'slow') return { ...caught, slowMs: SLOW_MS }
288  // Wider about its middle, kept inside the field.
289  const grow = (WIDE_PADDLE_WIDTH - paddleWidth(g)) / 2
290  return { ...caught, wideMs: WIDE_MS, paddleX: clamp(Math.round(g.paddleX - grow), 0, g.width - WIDE_PADDLE_WIDTH) }
291}
292
293/** Falling bonuses moved on: the ones the paddle meets caught, the ones past it gone. */
294const fall = (g: Breakout, ms: number): Breakout => {
295  const moved = g.drops.map(d => ({ ...d, y: d.y + DROP_SPEED * (ms / 1000) }))
296  const width = paddleWidth(g)
297  const isCaught = (d: Drop) => d.y >= paddleRow(g) - 1 && d.x + 1 >= g.paddleX && d.x - 1 < g.paddleX + width
298  const caught = moved.filter(isCaught)
299  const left = { ...g, drops: moved.filter(d => !isCaught(d) && d.y < g.height * 2) }
300  return caught.reduce((state, d) => catchBonus(state, d.kind), left)
301}
302
303/** The bonuses' time running out: a wide paddle shrinks back about its middle. */
304const wearOff = (g: Breakout, ms: number): Breakout => {
305  const wideMs = Math.max(0, g.wideMs - ms)
306  const shrunk = g.wideMs > 0 && wideMs === 0
307  const paddleX = shrunk ? clamp(g.paddleX + (WIDE_PADDLE_WIDTH - PADDLE_WIDTH) / 2, 0, g.width - PADDLE_WIDTH) : g.paddleX
308  return { ...g, wideMs, slowMs: Math.max(0, g.slowMs - ms), paddleX }
309}
310
311/** After a lost ball, the next one on the paddle; after a cleared wall, the next level's. */
312const afterMoment = (g: Breakout): Breakout => {
313  const next =
314    g.phase === 'cleared'
315      ? { ...g, level: g.level + 1, bricks: fullWall(g.bricks.length, g.bricks[0]?.length ?? 0), shatters: [], wideMs: 0, slowMs: 0, paddleX: Math.floor((g.width - PADDLE_WIDTH) / 2) }
316      : g
317  return { ...next, phase: 'serve', phaseMs: 0, ball: restingBall(next) }
318}
319
320const age = <E extends { ms: number }>(effects: readonly E[], ms: number): E[] => effects.filter(e => e.ms > ms).map(e => ({ ...e, ms: e.ms - ms }))
321
322/** Time passing: the effects fade, the moments run out, the ball flies and the bonuses fall. */
323export const tickBreakout = (g: Breakout, ms: number): Breakout => {
324  if (g.isOver) return g
325  const aged = { ...g, shatters: age(g.shatters, ms), popups: age(g.popups, ms) }
326  if (g.phase === 'lost' || g.phase === 'cleared') {
327    return g.phaseMs > ms ? { ...aged, phaseMs: g.phaseMs - ms } : afterMoment(aged)
328  }
329  if (g.phase === 'serve') return aged
330  return fall(wearOff(fly(aged, ms), ms), ms)
331}
332
333// ── Drawing ────────────────────────────────────────────────────────────────
334
335/** The wall's rows, top to bottom, as a rainbow: each colour, its lit top edge, its shaded side. */
336export const COLORS = ['#ef4444', '#f97316', '#facc15', '#22c55e', '#38bdf8', '#a855f7']
337export const LIGHTS = ['#fca5a5', '#fdba74', '#fef08a', '#86efac', '#bae6fd', '#d8b4fe']
338export const SHADES = ['#991b1b', '#9a3412', '#a16207', '#166534', '#0369a1', '#6b21a8']
339export const BONUS_COLORS: Record<BonusKind, string> = { wide: PALETTE.sky, slow: PALETTE.purple, life: PALETTE.danger }
340const BONUS_GLYPHS: Record<BonusKind, string> = { wide: '↔', slow: '≈', life: '♥' }
341const BALL = PALETTE.white
342const TRAIL = '#52525b'
343/** The paddle's lit top, its body, and its ends. */
344export const PADDLE_LIGHT = '#fbcfe8'
345export const PADDLE = '#f472b6'
346const PADDLE_END = '#be185d'
347const CHIP_TEXT = '#18181b'
348/** How far back the ball's trail is drawn, in seconds of flight. */
349const TRAIL_S = 0.06
350/** A broken brick shows white for the first half of its flash, crumbled for the rest. */
351const FLASH = PALETTE.white
352/** The frame of a cleared wall blinks between two greens this often. */
353const BLINK_MS = 200
354const CLEARED_GLOW = '#86efac'
355
356/** The pixels of the ball and its trail, when it is in flight or at rest on the paddle. */
357const ballPixels = (g: Breakout): { x: number; y: number; color: string }[] => {
358  if (g.phase === 'lost' || g.phase === 'cleared') return []
359  const ball = { x: Math.floor(g.ball.x), y: Math.floor(g.ball.y), color: BALL }
360  if (g.phase !== 'play') return [ball]
361  const trail = { x: Math.floor(g.ball.x - g.ball.vx * TRAIL_S), y: Math.floor(g.ball.y - g.ball.vy * TRAIL_S), color: TRAIL }
362  const isApart = trail.x !== ball.x || trail.y !== ball.y
363  return isApart && standingAt(g, trail.x + 0.5, trail.y + 0.5) === null ? [ball, trail] : [ball]
364}
365
366/** The paddle's colour at a pixel: a lit top over its body, its ends darker; null off it. */
367const paddlePixel = (g: Breakout, x: number, y: number): string | null => {
368  const top = paddleRow(g)
369  if (y < top || y > top + 1 || x < g.paddleX || x >= g.paddleX + paddleWidth(g)) return null
370  const isEnd = x === g.paddleX || x === g.paddleX + paddleWidth(g) - 1
371  if (y === top) return isEnd ? PADDLE : PADDLE_LIGHT
372  return isEnd ? PADDLE_END : PADDLE
373}
374
375/** Two pixels, one over the other, as one cell of half blocks. */
376const halfBlock = (top: string | null, bottom: string | null): Span | null => {
377  if (top === null && bottom === null) return null
378  if (top === null) return { text: '▄', fg: bottom! }
379  if (bottom === null || top === bottom) return { text: bottom === null ? '▀' : '█', fg: top }
380  return { text: '▀', fg: top, bg: bottom }
381}
382
383type Pixel = { x: number; y: number; color: string }
384
385/** Ball, trail and paddle in a cell: drawn when the ball or its trail is there. */
386const ballCell = (g: Breakout, pixels: readonly Pixel[], x: number, row: number): Span | null => {
387  const at = (y: number) => pixels.find(p => p.x === x && p.y === y)?.color ?? null
388  const top = at(row * 2)
389  const bottom = at(row * 2 + 1)
390  if (top === null && bottom === null) return null
391  return halfBlock(top ?? paddlePixel(g, x, row * 2), bottom ?? paddlePixel(g, x, row * 2 + 1))
392}
393
394/** A standing brick's cell: its colour under a lit top edge, its last column shaded with a hair of gap. */
395const brickCell = (g: Breakout, x: number, row: number): Span | null => {
396  const cell = standingAt(g, x + 0.5, row * 2)
397  if (cell === null) return null
398  const isLast = (x - g.brickLeft) % g.brickWidth === g.brickWidth - 1
399  const i = cell.row % COLORS.length
400  return isLast ? { text: '▉', fg: SHADES[i]! } : { text: '▆', fg: COLORS[i]!, bg: LIGHTS[i]! }
401}
402
403/** A brick just broken: white at first, then crumbling in its colour. */
404const shatterCell = (g: Breakout, x: number, row: number): Span | null => {
405  const cell = brickCellOf(g, x + 0.5, row * 2)
406  const shatter = cell === null ? undefined : g.shatters.find(s => s.row === cell.row && s.col === cell.col)
407  if (shatter === undefined) return null
408  return shatter.ms > SHATTER_MS / 2 ? { text: '█', fg: FLASH, bold: true } : { text: '░', fg: COLORS[shatter.row % COLORS.length]! }
409}
410
411/** A falling bonus, a pill three columns wide: `▐↔▌`. */
412const dropCell = (g: Breakout, x: number, row: number): Span | null => {
413  const drop = g.drops.find(d => Math.floor(d.y / 2) === row && Math.abs(x - d.x) <= 1)
414  if (drop === undefined) return null
415  const color = BONUS_COLORS[drop.kind]
416  if (x === drop.x) return { text: BONUS_GLYPHS[drop.kind], fg: CHIP_TEXT, bg: color, bold: true }
417  return { text: x < drop.x ? '▐' : '▌', fg: color }
418}
419
420const paddleCell = (g: Breakout, x: number, row: number): Span | null => halfBlock(paddlePixel(g, x, row * 2), paddlePixel(g, x, row * 2 + 1))
421
422/** Where the lost ball fell through, a spark at the bottom while the moment lasts. */
423const lostCell = (g: Breakout, x: number, row: number): Span | null =>
424  g.phase === 'lost' && x === g.lostX && row === g.height - 1 ? { text: '✸', fg: PALETTE.danger, bold: true } : null
425
426/** The row a message shows on: halfway between the wall and the paddle. */
427export const messageRow = (g: Breakout): number => Math.floor((BRICK_TOP + g.bricks.length + g.height - 1) / 2)
428
429/** The message of the moment, if any: a lost ball, a cleared wall, the launch to make. */
430const messageOf = (g: Breakout): { text: string; color: string } | null => {
431  if (g.isOver) return { text: 'partita finita', color: PALETTE.danger }
432  if (g.phase === 'lost') return { text: 'palla persa', color: PALETTE.danger }
433  if (g.phase === 'cleared') return { text: `livello ${g.level} superato!`, color: PALETTE.good }
434  return g.phase === 'serve' ? { text: 'spazio per lanciare', color: PALETTE.label } : null
435}
436
437/** Text over the field, keyed by `column,row`: the popups, rising, and the message. */
438const overlayOf = (g: Breakout): Map<string, Span> => {
439  const out = new Map<string, Span>()
440  const put = (text: string, x: number, row: number, color: string) =>
441    [...text].forEach((ch, i) => {
442      if (x + i >= 0 && x + i < g.width && row >= 0) out.set(`${x + i},${row}`, { text: ch, fg: color, bold: true })
443    })
444  for (const p of g.popups) {
445    const rise = Math.floor((1 - p.ms / POPUP_MS) * 2)
446    put(p.text, p.x - Math.floor([...p.text].length / 2), p.row - rise, p.color)
447  }
448  const message = messageOf(g)
449  if (message !== null) {
450    const text = ` ${message.text} `
451    put(text, Math.floor((g.width - [...text].length) / 2), messageRow(g), message.color)
452  }
453  return out
454}
455
456const fieldRows = (g: Breakout): Row[] => {
457  const pixels = ballPixels(g)
458  const overlay = overlayOf(g)
459  return Array.from({ length: g.height }, (_, row) =>
460    pack(
461      Array.from(
462        { length: g.width },
463        (_, x): Span =>
464          ballCell(g, pixels, x, row) ??
465          overlay.get(`${x},${row}`) ??
466          brickCell(g, x, row) ??
467          shatterCell(g, x, row) ??
468          dropCell(g, x, row) ??
469          paddleCell(g, x, row) ??
470          lostCell(g, x, row) ?? { text: ' ' },
471      ),
472    ),
473  )
474}
475
476/** The bottom edge: the lives as hearts, then the bonuses at work with their seconds left. */
477const footerOf = (g: Breakout): Span[] => {
478  const lives = Math.max(0, g.lives)
479  const timer = (kind: BonusKind, ms: number): Span[] =>
480    ms > 0 ? [{ text: '─', fg: PALETTE.frame }, { text: ` ${BONUS_GLYPHS[kind]} ${Math.ceil(ms / 1000)}s `, fg: BONUS_COLORS[kind], bold: true }] : []
481  return [
482    { text: ' ', fg: PALETTE.label },
483    { text: '♥'.repeat(lives), fg: PALETTE.danger, bold: true },
484    { text: '♡'.repeat(Math.max(0, LIVES - lives)), fg: PALETTE.label },
485    { text: ' ', fg: PALETTE.label },
486    ...timer('wide', g.wideMs),
487    ...timer('slow', g.slowMs),
488  ]
489}
490
491/** The frame: red for a lost ball, blinking green for a cleared wall, dim otherwise. */
492const frameColor = (g: Breakout): string => {
493  if (g.isOver || g.phase === 'lost') return PALETTE.danger
494  if (g.phase === 'cleared') return Math.floor((CLEARED_MS - g.phaseMs) / BLINK_MS) % 2 === 0 ? PALETTE.good : CLEARED_GLOW
495  return PALETTE.frame
496}
497
498/** The field in a rounded frame, the level in its top edge and the lives in its bottom one, centered. */
499export const drawBreakout = (g: Breakout): Row[] => {
500  const color = frameColor(g)
501  const titleColor = g.phase === 'cleared' ? PALETTE.good : g.phase === 'lost' || g.isOver ? PALETTE.danger : PALETTE.label
502  const board = framed(fieldRows(g), g.width, { title: `livello ${g.level}`, color, titleColor, footer: footerOf(g) })
503  const pad = centerPad(g.columns, g.width + FRAME_CELLS)
504  return pad.text === '' ? board : board.map(r => pack([pad, ...r]))
505}
506
507export const BREAKOUT: GameDef<Breakout> = {
508  id: 'breakout',
509  name: 'Breakout',
510  keys: '←→ o a/d muovi · spazio o ↑ lancia la palla',
511  create: ctx => newBreakout(ctx.columns, ctx.rows, ctx.rng),
512  key: keyBreakout,
513  tick: tickBreakout,
514  isOver: g => g.isOver,
515  score: g => g.score,
516  draw: drawBreakout,
517  status: g => `mattoni ${bricksLeft(g)}`,
518}
519
hooks/games/bugs.ts 128 lines
1import { drawBugs } from './bugs-draw'
2import type { Rng } from './rng'
3import { nextInt } from './rng'
4import type { GameDef, Row, Span } from './types'
5
6/** A bug in a cell of the keypad grid (1–9), and how long it has been there. */
7export type Bug = { cell: number; age: number; life: number }
8
9/** The last press, shown on its cell for a moment: a hit, with the points it made, or a miss. */
10export type Flash = { cell: number; isHit: boolean; points: number; left: number }
11
12export type Bugs = {
13  bugs: Bug[]
14  /** Milliseconds left in the round. */
15  timeLeft: number
16  /** Milliseconds played: bugs live shorter and come faster as it grows. */
17  elapsed: number
18  /** Milliseconds before the next bug shows up. */
19  spawnIn: number
20  lives: number
21  score: number
22  flash: Flash | null
23  /** Columns of room, to center the grid; rows of room, to pick tall cells or short ones. */
24  columns: number
25  rows: number
26  isOver: boolean
27  rng: Rng
28}
29
30export const ROUND_MS = 30_000
31export const LIVES = 3
32export const HIT_POINTS = 10
33/** Extra points for a bug hit within the first third of its life. */
34export const FAST_BONUS = 5
35const FIRST_SPAWN_MS = 600
36const FLASH_MS = 400
37/** A bug lives this long at the start, and this long at the end of the round. */
38const LIFE_START_MS = 1_600
39const LIFE_END_MS = 700
40/** A new bug every this many ms at the start, and at the end. */
41const SPAWN_START_MS = 900
42const SPAWN_END_MS = 420
43const CELLS = 9
44
45/** From `start` to `end` as the round goes by. */
46const ramp = (elapsed: number, start: number, end: number) => start + (end - start) * Math.min(1, elapsed / ROUND_MS)
47
48export const lifeAt = (elapsed: number): number => Math.round(ramp(elapsed, LIFE_START_MS, LIFE_END_MS))
49export const spawnGapAt = (elapsed: number): number => Math.round(ramp(elapsed, SPAWN_START_MS, SPAWN_END_MS))
50
51/** Rows the tall grid takes: the time bar and three rows of five-row cells. */
52export const ROOMY_ROWS = 16
53
54export const newBugs = (columns: number, rng: Rng, rows = ROOMY_ROWS): Bugs => ({
55  bugs: [],
56  timeLeft: ROUND_MS,
57  elapsed: 0,
58  spawnIn: FIRST_SPAWN_MS,
59  lives: LIVES,
60  score: 0,
61  flash: null,
62  columns,
63  rows,
64  isOver: false,
65  rng,
66})
67
68/** A bug in a free cell, picked at random; none when all nine are taken. */
69const spawn = (g: Bugs): Bugs => {
70  const free = Array.from({ length: CELLS }, (_, i) => i + 1).filter(c => !g.bugs.some(b => b.cell === c))
71  if (free.length === 0) return { ...g, spawnIn: spawnGapAt(g.elapsed) }
72  const [i, rng] = nextInt(g.rng, free.length)
73  const bug = { cell: free[i]!, age: 0, life: lifeAt(g.elapsed) }
74  return { ...g, bugs: [...g.bugs, bug], spawnIn: spawnGapAt(g.elapsed), rng }
75}
76
77const end = (g: Bugs): Bugs => (g.timeLeft <= 0 || g.lives <= 0 ? { ...g, isOver: true } : g)
78
79/** Time passing: bugs age and the ones left too long cost a life; new ones come. */
80export const tickBugs = (g: Bugs, ms: number): Bugs => {
81  if (g.isOver) return g
82  const aged = g.bugs.map(b => ({ ...b, age: b.age + ms }))
83  const escaped = aged.filter(b => b.age >= b.life)
84  const moved: Bugs = {
85    ...g,
86    bugs: aged.filter(b => b.age < b.life),
87    timeLeft: Math.max(0, g.timeLeft - ms),
88    elapsed: g.elapsed + ms,
89    spawnIn: g.spawnIn - ms,
90    lives: g.lives - escaped.length,
91    flash: g.flash === null || g.flash.left <= ms ? null : { ...g.flash, left: g.flash.left - ms },
92  }
93  return end(moved.spawnIn <= 0 ? spawn(moved) : moved)
94}
95
96/** A digit hits the bug in that cell; a digit on an empty cell costs a life. */
97export const pressBugs = (g: Bugs, key: string): Bugs => {
98  const cell = Number(key)
99  if (g.isOver || !/^[1-9]$/.test(key)) return g
100  const bug = g.bugs.find(b => b.cell === cell)
101  if (bug === undefined) return end({ ...g, lives: g.lives - 1, flash: { cell, isHit: false, points: 0, left: FLASH_MS } })
102  const points = HIT_POINTS + (bug.age < bug.life / 3 ? FAST_BONUS : 0)
103  return {
104    ...g,
105    bugs: g.bugs.filter(b => b.cell !== cell),
106    score: g.score + points,
107    flash: { cell, isHit: true, points, left: FLASH_MS },
108  }
109}
110
111export { drawBugs }
112
113export const bugsStatus = (g: Bugs): string =>
114  `⏱ ${Math.ceil(g.timeLeft / 1000)} s · ${'♥'.repeat(Math.max(0, g.lives))}${'♡'.repeat(LIVES - Math.max(0, g.lives))}`
115
116export const BUGS: GameDef<Bugs> = {
117  id: 'bugs',
118  name: 'Acchiappa il bug',
119  keys: 'premi il numero della cella col bug (1–9, come il tastierino)',
120  create: ctx => newBugs(ctx.columns, ctx.rng, ctx.rows),
121  key: pressBugs,
122  tick: tickBugs,
123  isOver: g => g.isOver,
124  score: g => g.score,
125  draw: g => drawBugs(g),
126  status: bugsStatus,
127}
128
hooks/games/classics.ts 98 lines
1import type { Ate, Fx2048 } from '../draw'
2import { draw2048, drawDino, drawSnake, EAT_FLASH_MS } from '../draw'
3import type { Dino } from './dino'
4import { dropDino, jumpDino, newDino, stepDino } from './dino'
5import type { Game2048 } from './g2048'
6import { mergesOf, move2048, new2048, slide } from './g2048'
7import { directionOf, isSpace } from './keys'
8import type { Direction, Snake } from './snake'
9import { newSnake, snakeInterval, stepSnake, turnSnake } from './snake'
10import type { GameDef } from './types'
11
12const clamp = (n: number, low: number, high: number) => Math.max(low, Math.min(high, n))
13
14/**
15 * The snake and the milliseconds since it last moved: it steps faster as it
16 * grows. The rest is for the drawing: the room, the time played, the last bite.
17 */
18export type SnakePlay = { snake: Snake; since: number; columns?: number; clock?: number; ate?: Ate | null }
19
20/** The bite `ms` older, gone once its flash is over. */
21const aged = (ate: Ate | null | undefined, ms: number): Ate | null =>
22  ate === undefined || ate === null || ate.ms + ms >= EAT_FLASH_MS ? null : { ...ate, ms: ate.ms + ms }
23
24export const SNAKE: GameDef<SnakePlay> = {
25  id: 'snake',
26  name: 'Snake',
27  keys: 'frecce o WASD per girare',
28  create: ctx => ({
29    snake: newSnake(clamp(Math.floor((ctx.columns - 2) / 2), 10, 30), clamp(ctx.rows - 2, 6, 16), ctx.rng),
30    since: 0,
31    columns: ctx.columns,
32    clock: 0,
33    ate: null,
34  }),
35  key: (s, key) => {
36    const dir = directionOf(key)
37    return dir === undefined ? s : { ...s, snake: turnSnake(s.snake, dir) }
38  },
39  tick: (s, ms) => {
40    let snake = s.snake
41    let since = s.since + ms
42    let ate = aged(s.ate, ms)
43    while (since >= snakeInterval(snake) && !snake.isOver) {
44      since -= snakeInterval(snake)
45      const before = snake
46      snake = stepSnake(snake)
47      if (snake.score > before.score) ate = { at: snake.body[0]!, points: snake.score - before.score, ms: 0 }
48    }
49    return { ...s, snake, since, clock: (s.clock ?? 0) + ms, ate }
50  },
51  isOver: s => s.snake.isOver,
52  score: s => s.snake.score,
53  draw: s => drawSnake(s.snake, s),
54}
55
56/** The 2048 game, the room it is drawn in, and what the last move did. */
57export type Play2048 = { game: Game2048; columns: number; rows: number; last: Fx2048 | null }
58
59/** What a move from `before` to `after` did, for the drawing to mark. */
60export const moveFx = (before: Game2048, after: Game2048, dir: Direction): Fx2048 => {
61  const slid = slide(before.grid, dir).grid
62  const added = after.grid.flatMap((row, r) => row.flatMap((n, c) => (n !== slid[r]![c] ? [{ r, c }] : [])))
63  return { merged: mergesOf(before.grid, dir), spawned: added[0] ?? null, gained: after.score - before.score }
64}
65
66export const G2048: GameDef<Play2048> = {
67  id: '2048',
68  name: '2048',
69  keys: 'frecce o WASD per spostare le tessere',
70  create: ctx => ({ game: new2048(ctx.rng), columns: ctx.columns, rows: ctx.rows, last: null }),
71  key: (p, key) => {
72    const dir = directionOf(key)
73    if (dir === undefined) return p
74    const game = move2048(p.game, dir)
75    return game === p.game ? p : { ...p, game, last: moveFx(p.game, game, dir) }
76  },
77  isOver: p => p.game.isOver,
78  score: p => p.game.score,
79  draw: p => draw2048(p.game, p),
80}
81
82export const RUNNER: GameDef<Dino> = {
83  id: 'dino',
84  name: 'Claude runner',
85  keys: 'spazio o ↑ per saltare, ↓ per scendere',
86  // Two columns go to the frame.
87  create: ctx => ({ ...newDino(clamp(ctx.columns - 2, 30, 90), ctx.rng), screen: { columns: ctx.columns, rows: ctx.rows } }),
88  key: (d, key) => {
89    const dir = directionOf(key)
90    if (isSpace(key) || dir === 'up') return jumpDino(d)
91    return dir === 'down' ? dropDino(d) : d
92  },
93  tick: stepDino,
94  isOver: d => d.isOver,
95  score: d => d.score,
96  draw: drawDino,
97}
98
hooks/games/flappy.ts 282 lines
1import { FEET_APART, FEET_TOGETHER, MASCOT, MASCOT_BODY } from '../draw'
2import { directionOf, isSpace } from './keys'
3import type { Rng } from './rng'
4import { nextInt } from './rng'
5import { centerPad, framed, pack, PALETTE } from './style'
6import type { GameDef, Row, Span } from './types'
7
8/** A pipe: its left column (fractional while it scrolls) and the first row of its gap. */
9export type Pipe = { x: number; gapTop: number; isPassed: boolean }
10
11export type Flappy = {
12  /** Columns of sky, inside the frame. */
13  width: number
14  /** Rows of sky, inside the frame; its bottom edge is the ground. */
15  height: number
16  /** Columns of room, to center the frame. */
17  columns: number
18  /** The mascot's top row, fractional; 0 is the top of the sky. */
19  y: number
20  /** Rows per second, down positive. */
21  vy: number
22  pipes: Pipe[]
23  /** Columns before the next pipe enters at the right edge. */
24  nextIn: number
25  /** False until the first flap: the mascot hovers and nothing scrolls. */
26  isFlying: boolean
27  /** Columns flown, for the wing beat and the scrolling sky. */
28  distance: number
29  /** Milliseconds the wings stay up after a flap. */
30  flapFor: number
31  /** Milliseconds the `+1` of the last pipe passed still shows. */
32  cheerFor: number
33  score: number
34  isOver: boolean
35  rng: Rng
36}
37
38/** The mascot's left column and size in cells, as drawn. */
39export const BIRD_X = 6
40export const BIRD_WIDTH = 9
41export const BIRD_HEIGHT = 3
42/** What a pipe must touch to end the flight: the solid body, a little inside the drawing. */
43export const HITBOX = { left: BIRD_X + 2, width: 5, top: 0.4, bottom: 2.6 }
44
45/** Rows per second², down. */
46export const GRAVITY = 45
47/** A flap's upward speed: it rises about 2 rows (v² / 2g). */
48export const FLAP_SPEED = 14
49/** Columns per second the pipes scroll. */
50export const SPEED = 14
51export const PIPE_WIDTH = 4
52/** Rows of the gap: the 3-row mascot with room to spare. */
53export const GAP = 8
54/** Columns from one pipe to the next. */
55export const PIPE_SPACING = 28
56/** How far a gap may move from the last one, so it can always be reached in time. */
57export const MAX_GAP_SHIFT = 5
58/** How long the wings stay up after a flap, and the `+1` shows after a pipe. */
59export const WING_MS = 180
60export const CHEER_MS = 600
61/** The least sky: a gap with a row above and below it. */
62const MIN_HEIGHT = GAP + 2
63const MAX_HEIGHT = 24
64/** Columns of the whole board, frame included. */
65const MIN_WIDTH = 40
66const MAX_WIDTH = 100
67/** Rows and columns the frame takes around the sky. */
68const FRAME_CELLS = 2
69
70const clamp = (n: number, low: number, high: number) => Math.max(low, Math.min(high, n))
71
72export const newFlappy = (columns: number, rows: number, rng: Rng): Flappy => {
73  const height = clamp(rows - FRAME_CELLS, MIN_HEIGHT, MAX_HEIGHT)
74  return {
75    width: clamp(columns, MIN_WIDTH, MAX_WIDTH) - FRAME_CELLS,
76    height,
77    columns,
78    y: Math.floor((height - BIRD_HEIGHT) / 2),
79    vy: 0,
80    pipes: [],
81    nextIn: 0,
82    isFlying: false,
83    distance: 0,
84    flapFor: 0,
85    cheerFor: 0,
86    score: 0,
87    isOver: false,
88    rng,
89  }
90}
91
92/** A flap: up at FLAP_SPEED, whatever the fall; the first one starts the flight. */
93export const flap = (f: Flappy): Flappy => (f.isOver ? f : { ...f, vy: -FLAP_SPEED, isFlying: true, flapFor: WING_MS })
94
95/** The row a new gap starts on: random, within reach of the last one, never touching the edges. */
96const nextGapTop = (f: Flappy): [number, Rng] => {
97  const highest = 1
98  const lowest = f.height - GAP - 1
99  const last = f.pipes[f.pipes.length - 1]?.gapTop ?? Math.floor((f.height - GAP) / 2)
100  const low = Math.max(highest, last - MAX_GAP_SHIFT)
101  const high = Math.min(lowest, last + MAX_GAP_SHIFT)
102  const [offset, rng] = nextInt(f.rng, high - low + 1)
103  return [low + offset, rng]
104}
105
106const hitsPipe = (f: Flappy, p: Pipe): boolean => {
107  const isAcross = p.x < HITBOX.left + HITBOX.width && p.x + PIPE_WIDTH > HITBOX.left
108  const isOutsideGap = f.y + HITBOX.top < p.gapTop || f.y + HITBOX.bottom > p.gapTop + GAP
109  return isAcross && isOutsideGap
110}
111
112/** `ms` of flight: gravity, scrolling pipes, a point for each pipe left behind; a pipe or the ground ends it. */
113export const tickFlappy = (f: Flappy, ms: number): Flappy => {
114  if (f.isOver || !f.isFlying) return f
115  const dt = ms / 1000
116  const vy = f.vy + GRAVITY * dt
117  // The mean of the speeds at both ends: the exact parabola. The top of the sky is a ceiling.
118  const y = Math.max(0, f.y + ((f.vy + vy) / 2) * dt)
119  const moved = SPEED * dt
120  const scrolled = f.pipes.map(p => ({ ...p, x: p.x - moved })).filter(p => p.x + PIPE_WIDTH > 0)
121  const passed = scrolled.filter(p => !p.isPassed && p.x + PIPE_WIDTH <= HITBOX.left).length
122  const pipes = scrolled.map(p => (p.x + PIPE_WIDTH <= HITBOX.left ? { ...p, isPassed: true } : p))
123
124  const base: Flappy = {
125    ...f,
126    y,
127    vy,
128    pipes,
129    nextIn: f.nextIn - moved,
130    distance: f.distance + moved,
131    score: f.score + passed,
132    flapFor: Math.max(0, f.flapFor - ms),
133    cheerFor: passed > 0 ? CHEER_MS : Math.max(0, f.cheerFor - ms),
134  }
135  const withPipe = base.nextIn > 0 ? base : addPipe(base)
136  const isDown = withPipe.y + BIRD_HEIGHT > withPipe.height
137  return isDown || withPipe.pipes.some(p => hitsPipe(withPipe, p)) ? { ...withPipe, isOver: true } : withPipe
138}
139
140const addPipe = (f: Flappy): Flappy => {
141  const [gapTop, rng] = nextGapTop(f)
142  return { ...f, pipes: [...f.pipes, { x: f.width + f.nextIn, gapTop, isPassed: false }], nextIn: f.nextIn + PIPE_SPACING, rng }
143}
144
145// ── Drawing ─────────────────────────────────────────────────────────────────
146
147/** A pipe's columns, lit side to shaded side: its body, then the wider-looking cap at the gap. */
148const PIPE_BODY: readonly Span[] = [
149  { text: '▐', fg: '#4ade80' },
150  { text: '█', fg: PALETTE.good },
151  { text: '█', fg: '#16a34a' },
152  { text: '▌', fg: '#15803d' },
153]
154const PIPE_CAP: readonly Span[] = [
155  { text: '█', fg: '#86efac' },
156  { text: '█', fg: '#4ade80' },
157  { text: '█', fg: PALETTE.good },
158  { text: '█', fg: '#15803d' },
159]
160const GROUND = '#a8a29e'
161const STAR = '#52525b'
162/** How fast each layer of the far sky drifts, as a share of the pipes' speed. */
163const STAR_DRIFT = 0.1
164const SKYLINE_DRIFT = 0.25
165const CLOUD_DRIFT = 0.45
166/** One star in about this many cells; the skyline's buildings, BLOCK columns wide and up to MAX_TOWER rows tall. */
167const STAR_ODDS = 60
168const BLOCK = 3
169const MAX_TOWER = 3
170/** A building is at most one row tall for each this many rows of sky. */
171const TOWER_SHARE = 6
172const CLOUD_PERIOD = 26
173const CLOUD = [' ▄██▄  ', '▀▀▀▀▀▀▀']
174/** Columns flown between two beats of the feet; ground ties every TIE columns. */
175const STRIDE = 2
176const TIE = 6
177/** Falling faster than this, in rows per second, the wings trail down. */
178const DIVE_SPEED = 8
179
180/** The mascot's middle row with the wings up after a flap, level, or trailing in a dive. */
181const WINGS_UP = '▐▜█████▛▌'
182const WINGS_DOWN = '▗▜█████▛▖'
183const DEAD_FACE = ' ▐╳███╳▌ '
184
185/** A fixed scramble of a number, for stars and buildings that stay put as they scroll. */
186const hash = (n: number): number => {
187  const a = Math.imul(n ^ (n >>> 16), 0x85ebca6b)
188  const b = Math.imul(a ^ (a >>> 13), 0xc2b2ae35)
189  return (b ^ (b >>> 16)) >>> 0
190}
191
192/** The mascot, row by row: wings by the last flap and the fall, feet beating, eyes crossed after a crash. */
193const birdRows = (f: Flappy): string[] => {
194  const isBeat = f.isFlying && !f.isOver && Math.floor(f.distance / STRIDE) % 2 === 1
195  const wings = f.flapFor > 0 ? WINGS_UP : f.vy > DIVE_SPEED && !f.isOver ? WINGS_DOWN : MASCOT_BODY[1]!
196  return [f.isOver ? DEAD_FACE : MASCOT_BODY[0]!, wings, isBeat ? FEET_APART : FEET_TOGETHER]
197}
198
199const pipeCell = (f: Flappy, x: number, row: number): Span | null => {
200  const pipe = f.pipes.find(p => x >= Math.round(p.x) && x < Math.round(p.x) + PIPE_WIDTH)
201  if (pipe === undefined || (row >= pipe.gapTop && row < pipe.gapTop + GAP)) return null
202  const isCap = row === pipe.gapTop - 1 || row === pipe.gapTop + GAP
203  return (isCap ? PIPE_CAP : PIPE_BODY)[x - Math.round(pipe.x)]!
204}
205
206/** The far sky, slowest first to nearest: a skyline in the last rows, stars, clouds in the upper half. */
207const farCell = (f: Flappy, x: number, row: number): Span | null => {
208  const block = Math.floor((x + f.distance * SKYLINE_DRIFT) / BLOCK)
209  const tall = hash(block) % (clamp(Math.floor(f.height / TOWER_SHARE), 1, MAX_TOWER) + 1)
210  if (row >= f.height - tall) return { text: row === f.height - tall ? '▄' : '█', fg: PALETTE.faint }
211  const cloudX = Math.floor(x + f.distance * CLOUD_DRIFT)
212  const cloud = Math.floor(cloudX / CLOUD_PERIOD)
213  const cloudRow = 1 + (hash(cloud + 7) % Math.max(1, Math.floor(f.height / 2) - 2))
214  const glyph = hash(cloud) % 3 === 0 ? undefined : CLOUD[row - cloudRow]?.[cloudX % CLOUD_PERIOD]
215  if (glyph !== undefined && glyph !== ' ') return { text: glyph, fg: PALETTE.frame }
216  const starX = Math.floor(x + f.distance * STAR_DRIFT)
217  const star = hash(starX * 131 + row * 7919)
218  if (star % STAR_ODDS !== 0) return null
219  return (star >>> 20) % 4 === 0 ? { text: '✦', fg: PALETTE.label } : { text: '·', fg: STAR }
220}
221
222/** The `+1` floating up from the mascot's head while a passed pipe is cheered. */
223const cheerCell = (f: Flappy, x: number, row: number): Span | null => {
224  if (f.cheerFor <= 0) return null
225  const rise = Math.floor((1 - f.cheerFor / CHEER_MS) * 2)
226  const at = Math.round(f.y) - 1 - rise
227  const glyph = '+1'[x - (BIRD_X + 3)]
228  return row === at && glyph !== undefined ? { text: glyph, fg: PALETTE.good, bold: true } : null
229}
230
231/** Sparks around a crash. */
232const crashCell = (f: Flappy, x: number, row: number): Span | null => {
233  if (!f.isOver) return null
234  const top = Math.round(f.y)
235  const sparks: Record<string, string> = { [`${BIRD_X - 1},${top}`]: '✸', [`${BIRD_X + BIRD_WIDTH},${top + 1}`]: '✸', [`${BIRD_X + BIRD_WIDTH - 1},${top - 1}`]: '·', [`${BIRD_X},${top + 3}`]: '·' }
236  const spark = sparks[`${x},${row}`]
237  return spark === undefined ? null : { text: spark, fg: spark === '✸' ? PALETTE.hurry : PALETTE.danger, bold: true }
238}
239
240const skyCell = (f: Flappy, sprite: readonly string[], x: number, row: number): Span => {
241  const glyph = sprite[row - Math.round(f.y)]?.[x - BIRD_X]
242  if (glyph !== undefined && glyph !== ' ') return { text: glyph, fg: f.isOver ? PALETTE.danger : MASCOT, bold: f.isOver && glyph === '╳' }
243  const near = crashCell(f, x, row) ?? cheerCell(f, x, row) ?? pipeCell(f, x, row)
244  // The far sky stays behind the mascot: nothing of it shows between its feet.
245  return near ?? (glyph === undefined ? farCell(f, x, row) : null) ?? { text: ' ' }
246}
247
248/** The bottom edge: the ground scrolling at the pipes' speed, the `+1` cut in under the mascot. */
249const groundOf = (f: Flappy): Span[] => {
250  const ground = (from: number, to: number): Span[] =>
251    Array.from({ length: Math.max(0, to - from) }, (_, i) => ({ text: Math.floor(from + i + f.distance) % TIE === 0 ? '┴' : '─', fg: GROUND }))
252  if (f.cheerFor <= 0) return ground(0, f.width - 1)
253  const label = ' +1 '
254  return [...ground(0, BIRD_X + 1), { text: label, fg: PALETTE.good, bold: true }, ...ground(BIRD_X + 1 + label.length, f.width - 1)]
255}
256
257const frameColor = (f: Flappy): string => (f.isOver ? PALETTE.danger : f.cheerFor > 0 ? PALETTE.good : PALETTE.frame)
258
259/** The sky in a rounded frame: the key cut into its top edge, lit on a flap; the ground in its bottom edge. */
260export const drawFlappy = (f: Flappy): Row[] => {
261  const sprite = birdRows(f)
262  const sky = Array.from({ length: f.height }, (_, row) => pack(Array.from({ length: f.width }, (_, x) => skyCell(f, sprite, x, row))))
263  const title = f.isOver ? 'schianto!' : 'spazio'
264  const titleColor = f.isOver ? PALETTE.danger : f.flapFor > 0 ? PALETTE.sky : PALETTE.label
265  const board = framed(sky, f.width, { title, color: frameColor(f), titleColor, footer: groundOf(f) })
266  const pad = centerPad(f.columns, f.width + FRAME_CELLS)
267  return pad.text === '' ? board : board.map(r => pack([pad, ...r]))
268}
269
270export const FLAPPY: GameDef<Flappy> = {
271  id: 'flappy',
272  name: 'Flappy Claude',
273  keys: 'spazio o ↑ per volare',
274  create: ctx => newFlappy(ctx.columns, ctx.rows, ctx.rng),
275  key: (f, key) => (isSpace(key) || directionOf(key) === 'up' ? flap(f) : f),
276  tick: tickFlappy,
277  isOver: f => f.isOver,
278  score: f => f.score,
279  draw: drawFlappy,
280  status: f => (f.isFlying ? '' : 'spazio per partire'),
281}
282
hooks/games/maze.ts 206 lines
1import { directionOf } from './keys'
2import { drawMaze } from './maze-view'
3import type { Rng } from './rng'
4import { nextInt } from './rng'
5import type { GameDef } from './types'
6
7/** A cell of the maze: wall, open floor, or the exit door cut into the outer wall. */
8export const WALL = '#'
9export const OPEN = ' '
10export const EXIT = 'E'
11
12/** The maze, row by row (y grows south), one character per cell. */
13export type Grid = readonly string[]
14export type Point = { x: number; y: number }
15
16/** The exit reached: the points it made, and how long the celebration still lasts. */
17export type Cleared = { points: number; ms: number }
18
19export type Maze = {
20  level: number
21  grid: Grid
22  /** Where the player stands, in cells: x grows east, y south. */
23  x: number
24  y: number
25  /** Where the player looks, in steps of `TURN_DEGREES` clockwise from east (0 east, 6 south). */
26  heading: number
27  /** Cells the player has stood in, as `y * size + x`. */
28  visited: readonly boolean[]
29  timeLeft: number
30  timeLimit: number
31  /** Milliseconds spent in this maze: the door's glow and the opening hint follow it. */
32  playedMs: number
33  score: number
34  isOver: boolean
35  showMap: boolean
36  cleared: Cleared | null
37  /** The room the drawing fills, frame included. */
38  columns: number
39  rows: number
40  rng: Rng
41}
42
43/** Level 1 is 9×9 cells, each level two more a side. */
44const FIRST_SIZE = 9
45const SIZE_STEP = 2
46export const TURN_DEGREES = 15
47export const HEADINGS = 360 / TURN_DEGREES
48/** Cells walked per press of ↑ or ↓. */
49export const STEP = 0.25
50/** Half the player's width: how close it may come to a wall. */
51export const RADIUS = 0.2
52/** A maze's time: a base, and more for each room of it. */
53const TIME_BASE_MS = 20_000
54const TIME_PER_ROOM_MS = 3_000
55/** An exit's points: this much times the level, and this much per whole second left. */
56export const LEVEL_POINTS = 100
57export const SECOND_POINTS = 5
58/** How long the exit is celebrated before the next maze. */
59export const CLEARED_MS = 1_800
60
61export const sizeOf = (level: number): number => FIRST_SIZE + SIZE_STEP * (level - 1)
62
63/** Rooms of a maze `size` cells a side: the open cells at odd coordinates. */
64const roomsOf = (size: number) => ((size - 1) / 2) ** 2
65
66export const timeLimitOf = (size: number): number => TIME_BASE_MS + TIME_PER_ROOM_MS * roomsOf(size)
67
68export const pointsFor = (level: number, timeLeft: number): number => LEVEL_POINTS * level + SECOND_POINTS * Math.floor(timeLeft / 1000)
69
70export const cellAt = (grid: Grid, x: number, y: number): string => grid[Math.floor(y)]?.[Math.floor(x)] ?? WALL
71
72const NEIGHBOURS: readonly Point[] = [
73  { x: 2, y: 0 },
74  { x: -2, y: 0 },
75  { x: 0, y: 2 },
76  { x: 0, y: -2 },
77]
78
79/**
80 * A perfect maze `size` cells a side (odd): rooms at odd coordinates, carved
81 * depth first from the top-left room, so every room is reached by one path
82 * only; the exit is a door in the east wall beside the bottom-right room.
83 */
84export const carveMaze = (size: number, rng: Rng): [Grid, Rng] => {
85  const cells = Array.from({ length: size }, () => Array<string>(size).fill(WALL))
86  const isInside = (p: Point) => p.x > 0 && p.y > 0 && p.x < size - 1 && p.y < size - 1
87  let r = rng
88  const stack: Point[] = [{ x: 1, y: 1 }]
89  cells[1]![1] = OPEN
90  while (stack.length > 0) {
91    const here = stack[stack.length - 1]!
92    const options = NEIGHBOURS.map(d => ({ x: here.x + d.x, y: here.y + d.y })).filter(p => isInside(p) && cells[p.y]![p.x] === WALL)
93    if (options.length === 0) {
94      stack.pop()
95      continue
96    }
97    const [i, next] = nextInt(r, options.length)
98    r = next
99    const to = options[i]!
100    cells[(here.y + to.y) / 2]![(here.x + to.x) / 2] = OPEN
101    cells[to.y]![to.x] = OPEN
102    stack.push(to)
103  }
104  cells[size - 2]![size - 1] = EXIT
105  return [cells.map(row => row.join('')), r]
106}
107
108const markVisited = (visited: readonly boolean[], size: number, x: number, y: number): readonly boolean[] => {
109  const i = Math.floor(y) * size + Math.floor(x)
110  return visited[i] === true ? visited : visited.map((v, j) => v || j === i)
111}
112
113/** A new maze for `level`, the player in the top-left room looking down its open corridor. */
114export const newLevel = (level: number, base: Pick<Maze, 'score' | 'showMap' | 'columns' | 'rows' | 'rng'>): Maze => {
115  const size = sizeOf(level)
116  const [grid, rng] = carveMaze(size, base.rng)
117  const timeLimit = timeLimitOf(size)
118  return {
119    ...base,
120    level,
121    grid,
122    x: 1.5,
123    y: 1.5,
124    heading: cellAt(grid, 2, 1) === OPEN ? 0 : HEADINGS / 4,
125    visited: markVisited(Array<boolean>(size * size).fill(false), size, 1, 1),
126    timeLeft: timeLimit,
127    timeLimit,
128    playedMs: 0,
129    isOver: false,
130    cleared: null,
131    rng,
132  }
133}
134
135export const newMaze = (columns: number, rows: number, rng: Rng): Maze =>
136  newLevel(1, { score: 0, showMap: true, columns, rows, rng })
137
138export const angleOf = (heading: number): number => (heading * TURN_DEGREES * Math.PI) / 180
139
140/** The four corners of the player standing at `p`. */
141const cornersOf = (p: Point): Point[] => [-RADIUS, RADIUS].flatMap(dx => [-RADIUS, RADIUS].map(dy => ({ x: p.x + dx, y: p.y + dy })))
142
143const isClear = (grid: Grid, p: Point) => cornersOf(p).every(c => cellAt(grid, c.x, c.y) !== WALL)
144const touchesExit = (grid: Grid, p: Point) => cornersOf(p).some(c => cellAt(grid, c.x, c.y) === EXIT)
145
146/** The exit reached: the points made, the celebration started; the player stays at the door, looking at it. */
147const reachExit = (g: Maze): Maze => {
148  const points = pointsFor(g.level, g.timeLeft)
149  return { ...g, score: g.score + points, cleared: { points, ms: CLEARED_MS } }
150}
151
152/** A step along the heading (back, when `distance` is negative), sliding along a wall it meets at a slant. */
153export const walk = (g: Maze, distance: number): Maze => {
154  const angle = angleOf(g.heading)
155  const dx = Math.cos(angle) * distance
156  const dy = Math.sin(angle) * distance
157  const tries: Point[] = [
158    { x: g.x + dx, y: g.y + dy },
159    { x: g.x + dx, y: g.y },
160    { x: g.x, y: g.y + dy },
161  ]
162  const to = tries.find(p => (p.x !== g.x || p.y !== g.y) && isClear(g.grid, p))
163  if (to === undefined) return g
164  if (touchesExit(g.grid, to)) return reachExit(g)
165  return { ...g, ...to, visited: markVisited(g.visited, g.grid.length, to.x, to.y) }
166}
167
168export const turn = (g: Maze, steps: number): Maze => ({ ...g, heading: (((g.heading + steps) % HEADINGS) + HEADINGS) % HEADINGS })
169
170export const keyMaze = (g: Maze, key: string): Maze => {
171  if (g.isOver) return g
172  if (key === 'm') return { ...g, showMap: !g.showMap }
173  if (g.cleared !== null) return g
174  const dir = directionOf(key)
175  if (dir === 'up') return walk(g, STEP)
176  if (dir === 'down') return walk(g, -STEP)
177  if (dir === 'left') return turn(g, -1)
178  if (dir === 'right') return turn(g, 1)
179  return g
180}
181
182/** Time passing: the clock runs down and ends the game at zero; after an exit, the next maze. */
183export const tickMaze = (g: Maze, ms: number): Maze => {
184  if (g.isOver) return g
185  if (g.cleared !== null) {
186    return g.cleared.ms > ms ? { ...g, cleared: { ...g.cleared, ms: g.cleared.ms - ms } } : newLevel(g.level + 1, g)
187  }
188  const timeLeft = Math.max(0, g.timeLeft - ms)
189  return { ...g, timeLeft, playedMs: g.playedMs + ms, isOver: timeLeft === 0 }
190}
191
192export const mazeStatus = (g: Maze): string => `livello ${g.level} · ${Math.ceil(g.timeLeft / 1000)} s`
193
194export const MAZE: GameDef<Maze> = {
195  id: 'maze',
196  name: 'Labirinto 3D',
197  keys: '↑↓ avanti e indietro · ←→ gira · m mappa',
198  create: ctx => newMaze(ctx.columns, ctx.rows, ctx.rng),
199  key: keyMaze,
200  tick: tickMaze,
201  isOver: g => g.isOver,
202  score: g => g.score,
203  draw: g => drawMaze(g),
204  status: mazeStatus,
205}
206
hooks/games/mines.ts 257 lines
1import { directionOf, isSpace } from './keys'
2import type { Rng } from './rng'
3import { nextInt } from './rng'
4import { centerPad, framed, meter, pack, PALETTE, widthOf } from './style'
5import type { GameDef, Row, Span } from './types'
6
7export type Point = { x: number; y: number }
8
9/** What the last key did, shown until the next one: cells opened, a flag set or taken off. */
10export type MinesEvent = { kind: 'open'; cells: Point[] } | { kind: 'flag'; isOn: boolean }
11
12export type Mines = {
13  width: number
14  height: number
15  /** Where the mines are; null until the first cell is opened, so that one is never a mine. */
16  mines: boolean[][] | null
17  mineCount: number
18  revealed: boolean[][]
19  flagged: boolean[][]
20  cursor: Point
21  /** Milliseconds since the first cell was opened, while the game runs. */
22  elapsedMs: number
23  isOver: boolean
24  hasWon: boolean
25  /** The mine that ended the game, drawn in red. */
26  exploded: Point | null
27  rng: Rng
28  /** Columns of the room, to center the drawing; 0 draws it at the left. */
29  columns: number
30  event: MinesEvent | null
31}
32
33const MAX = { width: 16, height: 10 }
34const MIN = { width: 8, height: 6 }
35/** Each cell is three columns wide, plus the two-column frame. */
36const CELL_WIDTH = 3
37const FRAME_ROWS = 2
38export const MINE_SHARE = 0.15
39/** A won game's bonus: this much, less a little per second, never below the floor. */
40export const WIN_BONUS = 1000
41const BONUS_PER_SECOND = 5
42const MIN_WIN_BONUS = 100
43
44/** The classic colours of 1 to 8, brightened for a dark terminal. */
45export const NUMBER_COLORS = ['', '#60a5fa', '#4ade80', '#f87171', '#c084fc', '#fbbf24', '#2dd4bf', '#fafafa', '#a1a1aa']
46/** A covered cell: a raised tile, `▐█▌`, half a column of gap each side. */
47export const TILE = '#52525b'
48const CURSOR = PALETTE.claude
49const DARK_TEXT = '#1a1a1a'
50/** The wash on the cells the last key opened. */
51export const OPENED_BG = '#14532d'
52/** Cells of the progress meter in the bottom edge; fewer on a field narrower than `WIDE_FIELD`. */
53const METER_CELLS = 8
54const SHORT_METER_CELLS = 5
55const WIDE_FIELD = 12
56
57const clamp = (n: number, low: number, high: number) => Math.max(low, Math.min(high, n))
58const grid = <T,>(width: number, height: number, value: T): T[][] =>
59  Array.from({ length: height }, () => Array<T>(width).fill(value))
60
61export const newMines = (width: number, height: number, rng: Rng, columns = 0): Mines => ({
62  width,
63  height,
64  mines: null,
65  mineCount: Math.round(width * height * MINE_SHARE),
66  revealed: grid(width, height, false),
67  flagged: grid(width, height, false),
68  cursor: { x: Math.floor(width / 2), y: Math.floor(height / 2) },
69  elapsedMs: 0,
70  isOver: false,
71  hasWon: false,
72  exploded: null,
73  rng,
74  columns,
75  event: null,
76})
77
78const neighbours = (m: Pick<Mines, 'width' | 'height'>, p: Point): Point[] =>
79  [-1, 0, 1]
80    .flatMap(dy => [-1, 0, 1].map(dx => ({ x: p.x + dx, y: p.y + dy })))
81    .filter(q => (q.x !== p.x || q.y !== p.y) && q.x >= 0 && q.y >= 0 && q.x < m.width && q.y < m.height)
82
83/** Mines laid at random, none on the first cell opened nor around it (unless the board is too small for that). */
84export const layMines = (m: Mines, first: Point): Mines => {
85  const all = Array.from({ length: m.width * m.height }, (_, i) => ({ x: i % m.width, y: Math.floor(i / m.width) }))
86  const near = new Set([first, ...neighbours(m, first)].map(p => `${p.x},${p.y}`))
87  const away = all.filter(p => !near.has(`${p.x},${p.y}`))
88  const candidates = away.length >= m.mineCount ? away : all.filter(p => p.x !== first.x || p.y !== first.y)
89  let rng = m.rng
90  const pool = [...candidates]
91  const chosen: Point[] = []
92  for (let i = 0; i < m.mineCount && pool.length > 0; i++) {
93    const [j, next] = nextInt(rng, pool.length)
94    rng = next
95    chosen.push(pool.splice(j, 1)[0]!)
96  }
97  const mines = grid(m.width, m.height, false).map((row, y) => row.map((_, x) => chosen.some(p => p.x === x && p.y === y)))
98  return { ...m, mines, mineCount: chosen.length, rng }
99}
100
101export const minesAround = (m: Mines, p: Point): number => neighbours(m, p).filter(q => m.mines?.[q.y]?.[q.x] === true).length
102
103const revealedCount = (m: Mines): number => m.revealed.flat().filter(Boolean).length
104
105/** Opens a cell: a mine ends the game; a cell with no mines around opens its neighbours too. */
106export const reveal = (start: Mines, p: Point): Mines => {
107  const m = start.mines === null ? layMines(start, p) : start
108  if (m.isOver || m.revealed[p.y]?.[p.x] === true || m.flagged[p.y]?.[p.x] === true) return m
109  if (m.mines?.[p.y]?.[p.x] === true) return { ...m, isOver: true, hasWon: false, exploded: p }
110
111  const revealed = m.revealed.map(row => [...row])
112  const queue = [p]
113  while (queue.length > 0) {
114    const q = queue.shift()!
115    if (revealed[q.y]![q.x] || m.flagged[q.y]![q.x]) continue
116    revealed[q.y]![q.x] = true
117    if (minesAround(m, q) === 0) queue.push(...neighbours(m, q).filter(n => !revealed[n.y]![n.x]))
118  }
119  const opened = { ...m, revealed }
120  const hasWon = revealedCount(opened) === m.width * m.height - m.mineCount
121  return hasWon ? { ...opened, isOver: true, hasWon: true } : opened
122}
123
124export const toggleFlag = (m: Mines, p: Point): Mines =>
125  m.isOver || m.revealed[p.y]?.[p.x] === true
126    ? m
127    : { ...m, flagged: m.flagged.map((row, y) => row.map((f, x) => (x === p.x && y === p.y ? !f : f))) }
128
129/** The game after a cell was opened, remembering which cells that opened. */
130const withOpened = (before: Mines, after: Mines): Mines => {
131  if (after === before) return before
132  const cells = after.revealed.flatMap((row, y) => row.flatMap((isOpen, x) => (isOpen && before.revealed[y]?.[x] !== true ? [{ x, y }] : [])))
133  return { ...after, event: { kind: 'open', cells } }
134}
135
136/** The game after a flag was toggled, remembering whether it went on or off. */
137const withFlag = (before: Mines, after: Mines): Mines =>
138  after === before ? before : { ...after, event: { kind: 'flag', isOn: after.flagged[after.cursor.y]?.[after.cursor.x] === true } }
139
140export const keyMines = (m: Mines, key: string): Mines => {
141  if (m.isOver) return m
142  if (isSpace(key) || key === 'return') return withOpened(m, reveal(m, m.cursor))
143  if (key === 'f') return withFlag(m, toggleFlag(m, m.cursor))
144  const dir = directionOf(key)
145  if (dir === undefined) return m
146  const dx = dir === 'left' ? -1 : dir === 'right' ? 1 : 0
147  const dy = dir === 'up' ? -1 : dir === 'down' ? 1 : 0
148  return { ...m, cursor: { x: clamp(m.cursor.x + dx, 0, m.width - 1), y: clamp(m.cursor.y + dy, 0, m.height - 1) }, event: null }
149}
150
151/** The clock runs from the first cell opened to the end. */
152export const tickMines = (m: Mines, ms: number): Mines => (m.mines === null || m.isOver ? m : { ...m, elapsedMs: m.elapsedMs + ms })
153
154/** Cells opened, and on a win a bonus that shrinks with the seconds taken. */
155export const scoreMines = (m: Mines): number => {
156  const seconds = Math.floor(m.elapsedMs / 1000)
157  const bonus = m.hasWon ? Math.max(MIN_WIN_BONUS, WIN_BONUS - seconds * BONUS_PER_SECOND) : 0
158  return revealedCount(m) + bonus
159}
160
161const flagsLeft = (m: Mines): number => m.mineCount - m.flagged.flat().filter(Boolean).length
162
163/** A tile three columns wide: `▐` and `▌` in its colour around the middle. */
164const tile = (color: string, middle: Span = { text: '█', fg: color }): Span[] => [{ text: '▐', fg: color }, middle, { text: '▌', fg: color }]
165
166const onTile = (text: string, fg: string): Span => ({ text, fg, bg: TILE, bold: true })
167
168const isFresh = (m: Mines, x: number, y: number) => m.event?.kind === 'open' && m.event.cells.some(p => p.x === x && p.y === y)
169
170/** An opened cell: its count in its colour, or a faint dot; washed green when the last key opened it. */
171const openedSpan = (m: Mines, x: number, y: number): Span => {
172  const n = minesAround(m, { x, y })
173  const span: Span = n === 0 ? { text: ' · ', fg: PALETTE.frame } : { text: ` ${n} `, fg: NUMBER_COLORS[n], bold: true }
174  return isFresh(m, x, y) ? { ...span, bg: OPENED_BG } : span
175}
176
177/** A cell as the game shows it, three columns: a tile, a flag, a number, or at the end the mines. */
178const cellSpans = (m: Mines, x: number, y: number): Span[] => {
179  const isMine = m.mines?.[y]?.[x] === true
180  const isFlag = m.flagged[y]?.[x] === true
181  if (m.isOver && isMine && (m.hasWon || isFlag)) return tile(TILE, onTile('⚑', m.hasWon ? PALETTE.good : PALETTE.danger))
182  if (m.isOver && isMine) {
183    const isExploded = m.exploded?.x === x && m.exploded?.y === y
184    return isExploded ? tile(PALETTE.danger, { text: '✸', fg: PALETTE.white, bg: PALETTE.danger, bold: true }) : [{ text: ' ✱ ', fg: PALETTE.danger, bold: true }]
185  }
186  if (isFlag) return tile(TILE, onTile(m.isOver ? '✗' : '⚑', PALETTE.danger))
187  return m.revealed[y]?.[x] === true ? [openedSpan(m, x, y)] : tile(TILE)
188}
189
190/** The cell under the cursor, in Claude's orange: a lit tile, or an opened cell on orange. */
191const cursorSpans = (m: Mines, x: number, y: number): Span[] => {
192  if (m.revealed[y]?.[x] === true) return [{ ...openedSpan(m, x, y), fg: DARK_TEXT, bg: CURSOR, bold: true }]
193  const middle: Span = m.flagged[y]?.[x] === true ? { text: '⚑', fg: DARK_TEXT, bg: CURSOR, bold: true } : { text: '█', fg: CURSOR }
194  return tile(CURSOR, middle)
195}
196
197/** What the last key did, or how the game ended, for the bottom edge. */
198const eventLabel = (m: Mines): Span[] => {
199  if (m.isOver) return [m.hasWon ? { text: ' ripulito! ', fg: PALETTE.good, bold: true } : { text: ' BOOM ', fg: PALETTE.danger, bold: true }]
200  if (m.event?.kind === 'open' && m.event.cells.length > 0) return [{ text: ` +${m.event.cells.length} `, fg: PALETTE.good, bold: true }]
201  if (m.event?.kind === 'flag') return [{ text: m.event.isOn ? ' ⚑ messa ' : ' ⚑ tolta ', fg: PALETTE.hurry, bold: true }]
202  return []
203}
204
205/**
206 * The bottom edge: the share of safe cells opened as a meter, then the last
207 * event; on a field too narrow for all, the meter goes first, then the share.
208 */
209const footerOf = (m: Mines): Span[] => {
210  const share = revealedCount(m) / Math.max(1, m.width * m.height - m.mineCount)
211  const cells = m.width >= WIDE_FIELD ? METER_CELLS : SHORT_METER_CELLS
212  const event = eventLabel(m)
213  const percent: Span = { text: ` ${Math.floor(share * 100)}% `, fg: PALETTE.label }
214  // A blank with no colour becomes the frame's line: `▰▰▱▱ 31% ─ +43 ─`.
215  const tail = event.length > 0 ? [{ text: ' ' }, ...event] : []
216  const room = m.width * CELL_WIDTH - 1
217  const choices = [[...meter(share, cells, PALETTE.good), percent, ...tail], [percent, ...tail], event]
218  return choices.find(spans => widthOf(spans) <= room) ?? event
219}
220
221/** The top edge: the mines in the field and the flags set, amber with more flags than mines. */
222const titleOf = (m: Mines) => {
223  const flags = m.flagged.flat().filter(Boolean).length
224  return { title: `✱ ${m.mineCount} · ⚑ ${flags}`, titleColor: flags > m.mineCount ? PALETTE.hurry : PALETTE.label }
225}
226
227const frameColor = (m: Mines): string => (!m.isOver ? PALETTE.frame : m.hasWon ? PALETTE.good : PALETTE.danger)
228
229/** The field in a rounded frame, centered: the counts on top, the progress and the last event below. */
230export const drawMines = (m: Mines): Row[] => {
231  const rows = m.revealed.map((row, y) =>
232    row.flatMap((_, x) => (!m.isOver && m.cursor.x === x && m.cursor.y === y ? cursorSpans(m, x, y) : cellSpans(m, x, y))),
233  )
234  const width = m.width * CELL_WIDTH
235  const pad = centerPad(m.columns, width + 2)
236  return framed(rows, width, { ...titleOf(m), color: frameColor(m), footer: footerOf(m) }).map(row => pack([pad, ...row]))
237}
238
239export const MINES: GameDef<Mines> = {
240  id: 'mines',
241  name: 'Campo minato',
242  keys: 'frecce muovi · spazio scopre · f bandierina',
243  create: ctx =>
244    newMines(
245      clamp(Math.floor((ctx.columns - 2) / CELL_WIDTH), MIN.width, MAX.width),
246      clamp(ctx.rows - FRAME_ROWS, MIN.height, MAX.height),
247      ctx.rng,
248      ctx.columns,
249    ),
250  key: keyMines,
251  tick: tickMines,
252  isOver: m => m.isOver,
253  score: scoreMines,
254  draw: drawMines,
255  status: m => `⚑ ${flagsLeft(m)} · ${Math.floor(m.elapsedMs / 1000)}s`,
256}
257
hooks/games/tetris.ts 368 lines
1import { directionOf, isSpace } from './keys'
2import type { Rng } from './rng'
3import { nextInt } from './rng'
4import { centerPad, framed, meter, pack, PALETTE } from './style'
5import type { GameDef, Row, Span } from './types'
6
7export type Kind = 'I' | 'O' | 'T' | 'S' | 'Z' | 'J' | 'L'
8export type Point = { x: number; y: number }
9
10/** A falling piece: its shape (filled cells of a square matrix, row-major) and the matrix's top-left on the board. */
11export type Piece = { kind: Kind; cells: Point[]; size: number; x: number; y: number }
12
13/** Row-major, top first; null for an empty cell, else the kind that locked there. */
14export type Board = (Kind | null)[][]
15
16export type Tetris = {
17  width: number
18  height: number
19  board: Board
20  piece: Piece
21  next: Kind
22  /** Kinds still to come from the current bag of seven. */
23  bag: Kind[]
24  rng: Rng
25  score: number
26  lines: number
27  level: number
28  /** Milliseconds since the piece last fell a row. */
29  fallMs: number
30  isOver: boolean
31  /** Columns of the room, to center the drawing; 0 draws it at the left. */
32  columns: number
33  /** Rows just cleared, lit for a moment: the board as it was with them, and which they were. */
34  flash: { board: Board; rows: number[]; ms: number } | null
35  /** A hard drop's streak: the cells the piece fell through, for a moment. */
36  trail: { kind: Kind; cells: Point[]; ms: number } | null
37  /** The last clear's points and name, cut into the well's bottom edge for a while. */
38  banner: { text: string; ms: number } | null
39}
40
41export const WIDTH = 10
42const MAX_HEIGHT = 20
43const MIN_HEIGHT = 14
44/** Rows of the board's room the frame takes. */
45const FRAME_ROWS = 2
46const KINDS: readonly Kind[] = ['I', 'O', 'T', 'S', 'Z', 'J', 'L']
47/** Points for 1, 2, 3 and 4 rows cleared at once, times the level. */
48export const LINE_POINTS = [0, 100, 300, 500, 800]
49export const LINES_PER_LEVEL = 10
50const SOFT_DROP_POINTS = 1
51const HARD_DROP_POINTS = 2
52/** Milliseconds per row at level 1, less at each level, never below the floor. */
53const BASE_FALL_MS = 800
54const FALL_STEP_MS = 70
55const MIN_FALL_MS = 80
56/** How long cleared rows stay lit, a hard drop's streak lasts, a clear's points show. */
57export const FLASH_MS = 240
58export const TRAIL_MS = 160
59export const BANNER_MS = 1600
60/** A clear of 1, 2, 3 and 4 rows, by name. */
61const CLEAR_NAMES = ['', 'singola', 'doppia', 'tripla', 'TETRIS!']
62/** Where a rotation that does not fit may try instead: in place, one left, one right. */
63const KICKS = [0, -1, 1]
64
65const SHAPES: Record<Kind, string[]> = {
66  I: ['....', 'XXXX', '....', '....'],
67  O: ['XX', 'XX'],
68  T: ['.X.', 'XXX', '...'],
69  S: ['.XX', 'XX.', '...'],
70  Z: ['XX.', '.XX', '...'],
71  J: ['X..', 'XXX', '...'],
72  L: ['..X', 'XXX', '...'],
73}
74
75export const COLORS: Record<Kind, string> = {
76  I: '#22d3ee',
77  O: '#facc15',
78  T: '#a855f7',
79  S: '#22c55e',
80  Z: '#ef4444',
81  J: '#3b82f6',
82  L: '#f97316',
83}
84/** Each kind's lit top edge, the bevel over its colour. */
85export const LIGHTS: Record<Kind, string> = {
86  I: '#a5f3fc',
87  O: '#fef08a',
88  T: '#d8b4fe',
89  S: '#86efac',
90  Z: '#fca5a5',
91  J: '#93c5fd',
92  L: '#fdba74',
93}
94
95const cellsOf = (shape: string[]): Point[] =>
96  shape.flatMap((line, y) => [...line].flatMap((c, x) => (c === 'X' ? [{ x, y }] : [])))
97
98/** A piece of `kind` at the top, centred. */
99export const spawn = (kind: Kind, width: number): Piece => {
100  const size = SHAPES[kind].length
101  return { kind, cells: cellsOf(SHAPES[kind]), size, x: Math.floor((width - size) / 2), y: 0 }
102}
103
104/** The piece turned a quarter clockwise within its square. */
105export const rotate = (p: Piece): Piece =>
106  p.kind === 'O' ? p : { ...p, cells: p.cells.map(c => ({ x: p.size - 1 - c.y, y: c.x })) }
107
108const blocksOf = (p: Piece): Point[] => p.cells.map(c => ({ x: p.x + c.x, y: p.y + c.y }))
109
110/** True when every block of the piece is inside the board and on an empty cell. */
111export const fits = (board: Board, p: Piece): boolean =>
112  blocksOf(p).every(b => b.x >= 0 && b.x < (board[0]?.length ?? 0) && b.y >= 0 && b.y < board.length && board[b.y]?.[b.x] === null)
113
114/** Seven kinds in a random order: each comes once per bag. */
115const shuffledBag = (rng: Rng): [Kind[], Rng] => {
116  let r = rng
117  const bag = [...KINDS]
118  for (let i = bag.length - 1; i > 0; i--) {
119    const [j, next] = nextInt(r, i + 1)
120    r = next
121    const swap = bag[i]!
122    bag[i] = bag[j]!
123    bag[j] = swap
124  }
125  return [bag, r]
126}
127
128/** The next kind out of the bag, refilling it when empty. */
129const draw = (bag: Kind[], rng: Rng): [Kind, Kind[], Rng] => {
130  const [full, r] = bag.length > 0 ? [bag, rng] : shuffledBag(rng)
131  return [full[0]!, full.slice(1), r]
132}
133
134export const emptyBoard = (width: number, height: number): Board =>
135  Array.from({ length: height }, () => Array<Kind | null>(width).fill(null))
136
137export const newTetris = (width: number, height: number, rng: Rng, columns = 0): Tetris => {
138  const [first, bag1, r1] = draw([], rng)
139  const [next, bag2, r2] = draw(bag1, r1)
140  return {
141    width,
142    height,
143    board: emptyBoard(width, height),
144    piece: spawn(first, width),
145    next,
146    bag: bag2,
147    rng: r2,
148    score: 0,
149    lines: 0,
150    level: 1,
151    fallMs: 0,
152    isOver: false,
153    columns,
154    flash: null,
155    trail: null,
156    banner: null,
157  }
158}
159
160export const fallInterval = (level: number): number => Math.max(MIN_FALL_MS, BASE_FALL_MS - (level - 1) * FALL_STEP_MS)
161
162/** The piece fixed into the board, full rows cleared and scored, the next piece in play (or the end). */
163export const lock = (t: Tetris): Tetris => {
164  const blocks = blocksOf(t.piece)
165  const merged = t.board.map((row, y) => row.map((cell, x) => (blocks.some(b => b.x === x && b.y === y) ? t.piece.kind : cell)))
166  const kept = merged.filter(row => row.some(cell => cell === null))
167  const full = merged.flatMap((row, y) => (row.every(cell => cell !== null) ? [y] : []))
168  const cleared = full.length
169  const board = [...emptyBoard(t.width, cleared), ...kept]
170  const lines = t.lines + cleared
171  const points = (LINE_POINTS[cleared] ?? 0) * t.level
172  const score = t.score + points
173  const [next, bag, rng] = draw(t.bag, t.rng)
174  const piece = spawn(t.next, t.width)
175  return {
176    ...t,
177    board,
178    piece,
179    next,
180    bag,
181    rng,
182    score,
183    lines,
184    level: Math.floor(lines / LINES_PER_LEVEL) + 1,
185    fallMs: 0,
186    isOver: !fits(board, piece),
187    flash: cleared > 0 ? { board: merged, rows: full, ms: FLASH_MS } : null,
188    banner: cleared > 0 ? { text: `+${points} ${CLEAR_NAMES[cleared]}`, ms: BANNER_MS } : t.banner,
189  }
190}
191
192const moved = (t: Tetris, dx: number, dy: number): Piece | null => {
193  const p = { ...t.piece, x: t.piece.x + dx, y: t.piece.y + dy }
194  return fits(t.board, p) ? p : null
195}
196
197/** One row down; a piece that cannot fall locks. */
198const fall = (t: Tetris): Tetris => {
199  const p = moved(t, 0, 1)
200  return p === null ? lock(t) : { ...t, piece: p }
201}
202
203const turn = (t: Tetris): Tetris => {
204  const turned = rotate(t.piece)
205  const kicked = KICKS.map(dx => ({ ...turned, x: turned.x + dx })).find(p => fits(t.board, p))
206  return kicked === undefined ? t : { ...t, piece: kicked }
207}
208
209/** Where the piece would come to rest, dropped straight down. */
210const landing = (board: Board, piece: Piece): Piece => {
211  let p = piece
212  while (fits(board, { ...p, y: p.y + 1 })) p = { ...p, y: p.y + 1 }
213  return p
214}
215
216const hardDrop = (t: Tetris): Tetris => {
217  const piece = landing(t.board, t.piece)
218  const rows = piece.y - t.piece.y
219  const cells = blocksOf(t.piece).flatMap(b => Array.from({ length: rows }, (_, dy) => ({ x: b.x, y: b.y + dy })))
220  const trail = rows > 0 ? { kind: t.piece.kind, cells, ms: TRAIL_MS } : t.trail
221  return lock({ ...t, piece, score: t.score + rows * HARD_DROP_POINTS, trail })
222}
223
224export const keyTetris = (t: Tetris, key: string): Tetris => {
225  if (t.isOver) return t
226  if (isSpace(key)) return hardDrop(t)
227  const dir = directionOf(key)
228  if (dir === 'left' || dir === 'right') {
229    const p = moved(t, dir === 'left' ? -1 : 1, 0)
230    return p === null ? t : { ...t, piece: p }
231  }
232  if (dir === 'up') return turn(t)
233  if (dir === 'down') {
234    const p = moved(t, 0, 1)
235    return p === null ? lock(t) : { ...t, piece: p, score: t.score + SOFT_DROP_POINTS, fallMs: 0 }
236  }
237  return t
238}
239
240/** An effect `ms` older, gone once its time is up. */
241const aged = <E extends { ms: number }>(effect: E | null, ms: number): E | null =>
242  effect === null || effect.ms <= ms ? null : { ...effect, ms: effect.ms - ms }
243
244/** Gravity: the piece falls a row each interval, faster at each level. */
245export const tickTetris = (t: Tetris, ms: number): Tetris => {
246  let state = { ...t, fallMs: t.fallMs + ms, flash: aged(t.flash, ms), trail: aged(t.trail, ms), banner: aged(t.banner, ms) }
247  while (!state.isOver && state.fallMs >= fallInterval(state.level)) {
248    const rest = state.fallMs - fallInterval(state.level)
249    state = { ...fall(state), fallMs: rest }
250  }
251  return state
252}
253
254/** A block, two columns: its colour under a lit top edge, like a bevel. */
255const BLOCK = '▆▆'
256/** Where the piece will land, its shadow. */
257const GHOST = '░░'
258/** A hard drop's streak, a thin line down the middle of each column. */
259const STREAK = '▕▏'
260/** The blocks of a lost game, greyed. */
261const DEAD = { fg: '#52525b', bg: '#71717a' }
262/** The jammed piece of a lost game: white crosses on red, unlike any block. */
263export const JAMMED: Span = { text: '╳╳', fg: PALETTE.white, bg: PALETTE.danger, bold: true }
264const EMPTY: Span = { text: ' ·', fg: PALETTE.faint }
265const CLEARED: Span = { text: '██', fg: PALETTE.white, bold: true }
266/** Inside of the side boxes; the blank between the well and them. */
267const PANEL_INNER = 13
268const GAP = 2
269/** Free rows over the stack below which the well turns amber, then red: a share of its height. */
270const HURRY_SHARE = 1 / 3
271const DANGER_SHARE = 1 / 6
272
273const blockOf = (kind: Kind): Span => ({ text: BLOCK, fg: COLORS[kind], bg: LIGHTS[kind] })
274
275/** What the well shows at this moment: the board, the lit rows, the piece and its shadow, a streak. */
276type View = { board: Board; lit: number[]; piece: Point[]; pieceSpan: Span; ghost: Point[]; trail: Point[] }
277
278const has = (points: readonly Point[], x: number, y: number) => points.some(p => p.x === x && p.y === y)
279
280const viewOf = (t: Tetris): View => {
281  if (t.isOver) return { board: t.board, lit: [], piece: blocksOf(t.piece), pieceSpan: JAMMED, ghost: [], trail: [] }
282  const trail = t.trail?.cells ?? []
283  if (t.flash !== null) return { board: t.flash.board, lit: t.flash.rows, piece: [], pieceSpan: EMPTY, ghost: [], trail }
284  const ghost = blocksOf(landing(t.board, t.piece))
285  return { board: t.board, lit: [], piece: blocksOf(t.piece), pieceSpan: blockOf(t.piece.kind), ghost, trail }
286}
287
288const cellOf = (t: Tetris, v: View, x: number, y: number): Span => {
289  if (v.lit.includes(y)) return CLEARED
290  if (has(v.piece, x, y)) return v.pieceSpan
291  const kind = v.board[y]?.[x] ?? null
292  if (kind !== null) return t.isOver ? { text: BLOCK, ...DEAD } : blockOf(kind)
293  if (has(v.ghost, x, y)) return { text: GHOST, fg: COLORS[t.piece.kind] }
294  if (has(v.trail, x, y)) return { text: STREAK, fg: LIGHTS[t.trail!.kind] }
295  return EMPTY
296}
297
298/** Rows free over the highest block. */
299const freeRows = (board: Board): number => {
300  const top = board.findIndex(row => row.some(cell => cell !== null))
301  return top === -1 ? board.length : top
302}
303
304/** The well's frame: red when lost, green as rows clear, amber then red as the stack nears the top. */
305const wellColor = (t: Tetris): string => {
306  if (t.isOver) return PALETTE.danger
307  if (t.flash !== null) return PALETTE.good
308  const free = freeRows(t.board)
309  return free <= t.height * DANGER_SHARE ? PALETTE.danger : free <= t.height * HURRY_SHARE ? PALETTE.hurry : PALETTE.frame
310}
311
312/** The bottom edge: a clear's points while they show, the end, or a plain line. */
313const wellFooter = (t: Tetris): Span[] => {
314  if (t.isOver) return [{ text: ' pozzo pieno ', fg: PALETTE.danger, bold: true }]
315  return t.banner === null ? [] : [{ text: ` ${t.banner.text} `, fg: PALETTE.good, bold: true }]
316}
317
318const wellRows = (t: Tetris): Row[] => {
319  const v = viewOf(t)
320  const rows = v.board.map((row, y) => row.map((_, x) => cellOf(t, v, x, y)))
321  return framed(rows, t.width * 2, { color: wellColor(t), footer: wellFooter(t) })
322}
323
324/** The next piece in a box, centered, its blocks as in the well. */
325const nextBox = (kind: Kind): Row[] => {
326  const shape = SHAPES[kind].filter(line => line.includes('X')).map(line => line.replace(/\.+$/, ''))
327  const wide = Math.max(...shape.map(line => line.length)) * 2
328  const rows = shape.map(line => [centerPad(PANEL_INNER, wide), ...[...line].map((c): Span => (c === 'X' ? blockOf(kind) : { text: '  ' }))])
329  return framed([[], ...rows, ...(rows.length < 2 ? [[]] : []), []], PANEL_INNER, { title: 'Prossimo' })
330}
331
332/** The level in a box: rows cleared, and a meter of the way to the next level. */
333const levelBox = (t: Tetris): Row[] => {
334  const share = (t.lines % LINES_PER_LEVEL) / LINES_PER_LEVEL
335  return framed(
336    [
337      [{ text: ' righe ', fg: PALETTE.label }, { text: String(t.lines), fg: PALETTE.white, bold: true }],
338      [{ text: ' ' }, ...meter(share, PANEL_INNER - 3, PALETTE.sky)],
339    ],
340    PANEL_INNER,
341    { title: `livello ${t.level}` },
342  )
343}
344
345/** The well, and beside it the next piece and the level when there is room, centered in the columns. */
346export const drawTetris = (t: Tetris): Row[] => {
347  const well = wellRows(t)
348  const wellWidth = t.width * 2 + 2
349  const fullWidth = wellWidth + GAP + PANEL_INNER + 2
350  const hasPanel = t.columns === 0 || t.columns >= fullWidth
351  const panel = hasPanel ? [...nextBox(t.next), [], ...levelBox(t)] : []
352  const pad = centerPad(t.columns, hasPanel ? fullWidth : wellWidth)
353  return well.map((row, i) => pack([pad, ...row, ...(panel[i] === undefined ? [] : [{ text: ' '.repeat(GAP) }, ...panel[i]!])]))
354}
355
356export const TETRIS: GameDef<Tetris> = {
357  id: 'tetris',
358  name: 'Tetris',
359  keys: '←→ muovi · ↑ ruota · ↓ scendi · spazio fa cadere',
360  create: ctx => newTetris(WIDTH, Math.max(MIN_HEIGHT, Math.min(MAX_HEIGHT, ctx.rows - FRAME_ROWS)), ctx.rng, ctx.columns),
361  key: keyTetris,
362  tick: tickTetris,
363  isOver: t => t.isOver,
364  score: t => t.score,
365  draw: drawTetris,
366  status: t => `livello ${t.level} · righe ${t.lines}`,
367}
368
hooks/games/types.ts 49 lines
1import type { GameId } from '../../types'
2import type { Rng } from './rng'
3
4/** A run of text drawn alike: what one `Text` draws. */
5export type Span = { text: string; fg?: string; bg?: string; bold?: boolean }
6export type Row = Span[]
7
8/** What a game starts from: the room it has, a random source, the words of the code Claude is editing. */
9export type GameContext = {
10  /** Cells across the board may use. */
11  columns: number
12  /** Rows the board may use, below the score line and above the help line. */
13  rows: number
14  rng: Rng
15  /** Names from the files Claude edited lately, most recent first; may be empty. */
16  words: readonly string[]
17}
18
19/**
20 * One game of the arcade: plain functions over its own state, never mutated.
21 * The arcade owns the menu, pause, record and game-over screens; the game
22 * only plays.
23 */
24export type GameDef<S> = {
25  id: GameId
26  name: string
27  /** One line of help shown under the board. */
28  keys: string
29  /**
30   * The game reads letters as moves (typing): the arcade then pauses on Tab
31   * and leaves `p` and `m` to the game.
32   */
33  isTyping?: boolean
34  create: (ctx: GameContext) => S
35  /** A key: a special key's name (`up`, `return`, `tab`, `backspace`…) or the character typed (`" "` for space). */
36  key: (state: S, key: string) => S
37  /** Time passing, in ms; absent for a game that moves on keys only. */
38  tick?: (state: S, ms: number) => S
39  isOver: (state: S) => boolean
40  score: (state: S) => number
41  draw: (state: S) => Row[]
42  /** A word or two beside the score (`livello 3`, `vite ♥♥`); absent, nothing. */
43  status?: (state: S) => string
44}
45
46/** A game of any state type, as the arcade holds it. */
47// eslint-disable-next-line @typescript-eslint/no-explicit-any
48export type AnyGame = GameDef<any>
49