A falling-block line-clearing game to play in a pane while Claude works.

A falling-block game for the 40 seconds Claude spends thinking.
You asked for a refactor. Claude is reading 31 files. You could watch the spinner. Or you could clear four rows at once while it works.
![]()
A Claude Code mod. Seven four-cell pieces fall into a well ten wide and eighteen deep; fill a row and it goes. The rules are the ones the modern games share, spins, back to back and combos included. The look is the game's own: its own colors, its own well, its own words for what you just did.
Nothing is sent anywhere, nothing touches your files, and the only thing it remembers is your best score. Your productivity remains your own business.
Type /line-clear to open the pane. Typed while Claude is working, the command waits until the turn ends, so open the pane first and keep it open: the game plays while Claude generates and runs tools.
There are two ways to give the game the keys.
a d w q s x c p. Escape gives the keys back.| Move | Clicked well | Pane hotkey |
|---|---|---|
| left, right | left, right arrow, or a, d | a, d |
| slide to the wall | shift with left or right, or A, D | |
| turn clockwise | up arrow or w | w |
| turn back | z or q | q |
| soft drop | down arrow or s | s |
| hard drop | Space or x | x |
| hold | c | c |
| pause | p | p |
Hold left, right or down and the piece keeps going. A terminal never says when a key is let go, only that it came again, so the game counts a key as held when it comes again within 120 ms. Your keyboard waits a few hundred milliseconds before it starts repeating a held key, so a held drop, turn, hold or pause acts twice (on the press, then once when the repeat starts) and then stops: holding the drop key never drops piece after piece.
A click starts a game and, after game over, starts the next one; with the pane focused, p does.
The status line under the well always says who has the keys: playing · Esc gives keys back (the clicked well), keys: w a s d · Esc gives keys back (the focused pane), paused · p resumes (you paused it), or, in the warning color, click to play · or ctrl+x tab, then w a s d, with a card over the well, when the keys go to the prompt.
Beat your best and the game-over card turns gold and twinkles. The best label says new! the moment you pass it, so you know to stop playing safe. You will not stop playing safe.
Claude Code gives a mod's game region the keyboard only after a click on it: no command, focus request or keybinding gives it the keys, and Escape never reaches it (it hands the keys back). So the game cannot see when it loses the keys. It infers it: two seconds with no key and the well takes itself as unfocused, pauses, and shows click to play again; any key or click resumes it. That pause is a guess from silence, not a signal.
Without a mouse, ctrl+x tab focuses the pane, where a Button's hotkey is one letter or digit. The arrows and Tab belong to the pane there (they scroll and move between Buttons), so a keyboard-only player steers with letters and cannot send the game arrows or Space.
A key the game does not have lands on the prompt, and mid-turn some of those keys act: Escape at the prompt interrupts Claude, Left opens the background agents view, Up pulls back a queued message. Check the status line before pressing keys. Press Escape once to leave the game, and do not Tab around the focused pane mid-turn: Tab can move the focus onto Claude Code's own stop control, where Escape may cancel the turn.
The pane needs 46 columns and 23 rows. Below 46 columns it asks for room.
| Setting | Default | What it does |
|---|---|---|
openOnTurn | off | Opens the pane, without taking the keys, each time a turn starts. Claude Code places a pane opened this way only on a terminal 144 columns wide (110 once you have opened line-clear yourself in a session). |
startLevel | 1 | The level each game starts at, a whole number from 1 to 15; anything else reads as 1. |
Change it in /config, or under pluginConfigs["line-clear"].options in settings.
Nothing leaves the machine. The mod makes no network call and reads no files. It keeps one value across sessions, the best score, as a number in the plugin's own store. It hooks no tool, prompt, model or permission event, and adds nothing to the transcript.
The rules follow the widely documented modern guideline mechanics, as the Hard Drop wiki describes them: SRS turns and kicks, spins by the three-corner rule, scoring with back to back and combos, and move reset lock delay. The well is 18 rows deep rather than 20, and the look and the words are the game's own.
The engine in hooks/game/ is pure: no clock, no randomness, no I/O. newGame(seed, options) starts a game and step(state, input, nowMs) returns the next one, applying the time first (gravity rows and locks due by nowMs) and then the input. The same seed and inputs always replay the same game.
hooks/game/pieces.ts). The square never kicks.(0.8 - (level - 1) * 0.007) ^ (level - 1) seconds a row: 1000 ms at level 1, 355 ms at level 5, 64 ms at level 10, and no faster than level 15's 7 ms.paused · p resumes.The numbers live in hooks/game/rules.ts.
In the pane, the game's time is the frame clock: each 50 ms frame moves the game on 50 ms. When Claude Code draws frames late (measured at about 55 ms a frame mid-turn), the game runs that much slower instead of skipping rows. The drawing thread does have performance.now(), but it reads the wall clock, which the test harness's frame clock does not move, so the fixed step is what keeps every timing test deterministic. Each game's seed comes from the clock when it starts.
Not in this game:
Requires Claude Code 2.1.287 or later (mods). Developed and tested on 2.1.294. The game is designed for a dark Claude Code theme; on a light theme some of it is hard to read.
From the ilovepixelart marketplace, which pins each mod to its latest release:
/plugin marketplace add ilovepixelart/claude-code-mods
/plugin install line-clear@ilovepixelart
Or in one line, straight from this repository, following main:
/plugin install line-clear --marketplace ilovepixelart/line-clear-mod
To stay on one release, add this repository at its tag instead, then install from it:
/plugin marketplace add ilovepixelart/line-clear-mod#line-clear--v0.1.0
/plugin install line-clear@line-clear-mod
Run /reload-plugins (or start a new session) after installing. To take a new release later, run claude plugin update line-clear@ilovepixelart in your shell, or line-clear@line-clear-mod if you installed from this repository. Each release also carries a zip of the plugin for claude --plugin-url, and CHANGELOG.md lists what each one changed.
To try it from a clone without installing: claude --plugin-dir /path/to/line-clear-mod.
Releases follow Semantic Versioning. While the version is 0.x, any release may change behaviour. The one value the mod keeps, the best score, is a plain number in the plugin's own store.
What claude plugin validate . reports the module hooks and calls:
session.start (registers /line-clear), command.run (line-clear), turn.start (only with openOnTurn on), ui.message and ui.render (its own pane only);$.command.register, $.ui.open, $.ui.resolve, $.ui.invalidate, $.clock.now, $.store.get and $.store.set (the best score), and $.state for the pane's own Button presses this session.node scripts/gates.mjs # release check, validate, test, typecheck
node scripts/gates.mjs --load # also the tests eight times at once
Both workflows run the same script, so a green local run is the check CI makes.
tsc needs the type declarations Claude Code writes into .claude-plugin/types/ when it loads the plugin; the gates write them with a claude --plugin-dir . -p run when they are missing, which works even when it stops at "Not logged in".
The demo is recorded with vhs in a scratch project, with Claude Code running in tmux so a script can read the well and play.
To release, add the version's section to CHANGELOG.md, set the version in .claude-plugin/plugin.json (its only home), merge, then run claude plugin tag --push on main. The pushed line-clear--v<version> tag starts the release workflow, which runs the gates again with the tag checked against the version and publishes the GitHub release with the CHANGELOG section as notes, line-clear-<version>.zip for claude --plugin-url, and the zip's .sha256.
MIT
hooks/register.tsx 99 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import Play from './play'
5import type { WellPost, WellProps } from './clients/well'
6
7const PANE = 'line-clear'
8const TITLE = 'line-clear'
9
10/** The store key of the best score: a number, and the only thing the mod keeps. */
11const BEST = 'best'
12
13/** How many recent presses the game region is handed; it applies each once by its number. */
14const PRESSES_KEPT = 16
15
16/** Body rows the game wants inline (the region and the two legend rows), and columns docked. */
17const PANE_ROWS = Play.GAME_ROWS + 2
18const PANE_COLUMNS = Play.GAME_COLUMNS + 4
19
20const presses = atom({ plugin: 'line-clear', key: 'presses' } as const, [])
21
22const open = ($: EngineInterface) => $.ui.open({ id: PANE, title: TITLE, rows: PANE_ROWS, columns: PANE_COLUMNS })
23
24async function bestOf($: EngineInterface): Promise<number> {
25 const stored = await $.store.get(BEST)
26
27 return typeof stored === 'number' && Number.isFinite(stored) && stored > 0 ? Math.floor(stored) : 0
28}
29
30const isOver = (data: unknown): data is WellPost =>
31 typeof data === 'object' && data !== null && (data as WellPost).kind === 'over' && Number.isSafeInteger((data as WellPost).score)
32
33/** The two legend rows of hotkey Buttons: what the keyboard path plays with. */
34const LEGEND = [Play.HOTKEYS.slice(0, 4), Play.HOTKEYS.slice(4)]
35
36export const register: Register = (on, options) => {
37 on('session.start', async ($, e, next) => {
38 await $.command.register({ name: 'line-clear', description: 'Open the line-clear game pane: click the well to play' })
39
40 return next(e)
41 })
42
43 on('command.run', { command: 'line-clear' }, async $ => {
44 await open($)
45
46 return {}
47 })
48
49 if (options.openOnTurn === true) {
50 on('turn.start', async ($, e, next) => {
51 // unasked, so the pane is only placed on a wide terminal; it never takes the keys
52 void open($)
53
54 return next(e)
55 })
56 }
57
58 on('ui.message', { requestId: PANE }, async ($, e) => {
59 if (isOver(e.data) && e.data.score > (await bestOf($))) {
60 await $.store.set(BEST, e.data.score)
61 $.ui.invalidate('ui.render')
62 }
63
64 return {}
65 })
66
67 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
68 const els = $.ui.resolve(e)
69 const { Box, Text, Button } = els
70 // the other surfaces' tables carry no running Client today
71 if (!(e.surface === 'terminal' || e.surface === 'desktop') || !('Client' in els)) {
72 return <Text dimColor>line-clear plays in the terminal and the desktop app.</Text>
73 }
74 const props: WellProps = {
75 best: await bestOf($),
76 seedBase: await $.clock.now(),
77 paneFocused: e.props.isFocused,
78 columns: e.props.bodyColumns,
79 presses: await read($, presses),
80 startLevel: Play.startLevelOf(options.startLevel),
81 }
82 const margin = Math.max(0, Math.floor((e.props.bodyColumns - Play.GAME_COLUMNS) / 2))
83 const press = (key: string) => () => update($, presses, list => [...list, { seq: (list.at(-1)?.seq ?? 0) + 1, key }].slice(-PRESSES_KEPT))
84
85 return (
86 <Box flexDirection="column">
87 <els.Client key="well" module="./clients/well.ts" width="100%" props={props} />
88 {LEGEND.map((row, at) => (
89 <Box key={`legend-${at}`} flexDirection="row" gap={2} paddingLeft={margin}>
90 {row.map(({ key, label }) => (
91 <Button key={`hotkey-${key}`} hotkey={key} label={label} plain dimColor onPress={press(key)} />
92 ))}
93 </Box>
94 ))}
95 </Box>
96 )
97 })
98}
99hooks/play/index.ts 7 lines1export * from './keys'
2export * from './palette'
3export * from './play'
4export * from './view'
5
6export * as default from '.'
7hooks/clients/well.ts 69 lines1import type { ClientModule } from 'claude-code'
2
3import Play from '../play'
4import type { Outside, Play as PlayState, Press } from '../play'
5
6/** What the hooks module hands the game region. */
7export type WellProps = {
8 /** The best score kept, to show beside this game's. */
9 best: number
10 /** A base for each game's seed, from the hooks module's clock. */
11 seedBase: number
12 /** Whether the pane holds the keys (its Buttons' hotkeys), as the pane says. */
13 paneFocused: boolean
14 /** The pane body's width, used until the region has been laid out. */
15 columns: number
16 /** The latest pane Button presses, numbered. */
17 presses: Press[]
18 /** The level each game starts at, from the startLevel setting. */
19 startLevel: number
20}
21
22/** What the hooks module hears from the region: an ended game's score. */
23export type WellPost = { kind: 'over'; score: number }
24
25/** Each instance's latest props, for its handlers: they were set up on the first call, with that call's props. */
26const latestProps = new WeakMap<object, WellProps>()
27
28const lastSeq = (presses: readonly Press[]) => presses.reduce((seq, press) => Math.max(seq, press.seq), 0)
29
30/**
31 * The game region: the play state on the frame clock, clicks and keys from
32 * the region, presses from the pane's Buttons through props. Each change
33 * is one setState; an ended game's score is posted once.
34 */
35const Well: ClientModule<WellProps, PlayState> = (props, surface) => {
36 latestProps.set(surface, props)
37 const outside = (): Outside => {
38 const latest = latestProps.get(surface) ?? props
39
40 return { paneFocused: latest.paneFocused, seedBase: latest.seedBase, best: latest.best, startLevel: latest.startLevel }
41 }
42 const commit = (next: PlayState) => {
43 const score = Play.scoreToReport(next)
44 if (score !== null) {
45 surface.post({ kind: 'over', score } satisfies WellPost)
46 }
47 surface.setState(score === null ? next : { ...next, reported: true })
48 }
49
50 if (surface.state === undefined) {
51 surface.setState(Play.startPlay(lastSeq(props.presses)))
52 surface.every(Play.TICK_MS, () => surface.state && commit(Play.ticked(surface.state, outside())))
53 surface.onPointer(event => surface.state && commit(Play.pointed(surface.state, event, outside())))
54 surface.onKey(event => surface.state && commit(Play.keyed(surface.state, event, outside())))
55 }
56
57 const current = surface.state ?? Play.startPlay(lastSeq(props.presses))
58 // new props are the only way presses arrive: applied here, once each
59 const play = Play.pressed(current, props.presses, outside())
60 if (play !== current) {
61 commit(play)
62 }
63 const columns = surface.columns > 0 ? surface.columns : props.columns
64
65 return Play.screenTree(surface.elements, Play.screenOf(play, outside(), columns))
66}
67
68export default Well
69hooks/play/keys.ts 125 lines1import type { ClientKeyEvent } from 'claude-code'
2
3import type { Input } from '../game'
4
5/**
6 * The keyboard controls, one lowercase letter each: what a pane Button's
7 * `hotkey` may be, so the same letters work with the pane focused (ctrl+x
8 * tab) and with the game region clicked. In legend order.
9 */
10export const HOTKEYS: readonly { readonly key: string; readonly input: Input; readonly label: string }[] = [
11 { key: 'a', input: 'left', label: 'left' },
12 { key: 'd', input: 'right', label: 'right' },
13 { key: 'w', input: 'rotateCw', label: 'turn' },
14 { key: 'q', input: 'rotateCcw', label: 'turn back' },
15 { key: 's', input: 'softDrop', label: 'down' },
16 { key: 'x', input: 'hardDrop', label: 'drop' },
17 { key: 'c', input: 'hold', label: 'hold' },
18 { key: 'p', input: 'pause', label: 'pause' },
19]
20
21/** The keys only a clicked game region receives: arrows and Space, which a focused pane keeps for itself. */
22const REGION_KEYS: Readonly<Record<string, Input>> = {
23 left: 'left',
24 right: 'right',
25 up: 'rotateCw',
26 down: 'softDrop',
27 space: 'hardDrop',
28 ' ': 'hardDrop',
29 z: 'rotateCcw',
30}
31
32const LETTERS: Readonly<Record<string, Input>> = Object.fromEntries(HOTKEYS.map(({ key, input }) => [key, input]))
33
34/**
35 * The special keys the surface reports by name: each is one key, never a
36 * burst of letters (`tab` is not t, a, b, which would move the piece left).
37 */
38const NAMED = new Set([
39 'left', 'right', 'up', 'down', 'space', 'return', 'enter', 'tab', 'backspace', 'delete', 'insert', 'escape',
40 'pageup', 'pagedown', 'home', 'end',
41 ...Array.from({ length: 24 }, (_, at) => `f${at + 1}`),
42])
43
44const CONTROL = /[\u0000-\u001f\u007f-\u009f]/
45
46/** Shifted, these slide to the wall instead of moving one column: shift with an arrow, or a capital A or D. */
47const SLIDES: Readonly<Record<string, Input>> = { left: 'slideLeft', right: 'slideRight', A: 'slideLeft', D: 'slideRight' }
48
49/** One key's input: a named key, or a letter in either case; undefined for every other key. */
50function inputOfOne(key: string, isShifted = false): Input | undefined {
51 const lower = key.toLowerCase()
52 const slide = isShifted || key !== lower ? SLIDES[key] : undefined
53
54 return slide ?? REGION_KEYS[key] ?? REGION_KEYS[lower] ?? LETTERS[lower]
55}
56
57/**
58 * The inputs a key event to the clicked game region stands for, in order.
59 * Keys typed fast can arrive as one event (`key: "wasd"`), so a key that is
60 * not a named key is read one character at a time. A key held with ctrl or
61 * meta is a shortcut, not a move, and stands for nothing; so does a key
62 * holding a control character, and any key the game has no use for.
63 */
64export function inputsOfKey(event: ClientKeyEvent): Input[] {
65 // a control character means a raw escape sequence: its printable tail (`[A`) is not a burst of letters
66 if (event.ctrl === true || event.meta === true || CONTROL.test(event.key)) {
67 return []
68 }
69 const keys = NAMED.has(event.key) ? [event.key] : [...event.key]
70
71 return keys.flatMap(key => inputOfOne(key, event.shift === true) ?? [])
72}
73
74/**
75 * How close repeats of one key come when it is held down: a terminal repeats
76 * a held key every 30 to 50 ms, and a person tapping one key twice takes
77 * longer than this between taps.
78 */
79export const HELD_MS = 120
80
81/** The last key the game saw and when, on the frame clock; null before any. */
82export type LastKey = { readonly key: string; readonly at: number } | null
83
84/** The inputs a held key keeps repeating; every other input acts once per press. */
85const REPEATING = new Set<Input>(['left', 'right', 'softDrop'])
86
87/** One key as itself, whatever its case or spelling: `X` is `x`, `' '` is `space`. */
88const identityOf = (key: string) => (key === ' ' ? 'space' : key.toLowerCase())
89
90/**
91 * The inputs of key events read in order: `inputsOfKey`, less the repeats of
92 * a held key. A key that comes again within HELD_MS of the same key is a
93 * repeat; a repeat still moves and soft drops, but a drop, a turn, hold and
94 * pause act only on the first press, so holding a key never drops piece
95 * after piece. Any other key in between ends the hold.
96 */
97export function pressesOf(event: ClientKeyEvent, last: LastKey, now: number): { inputs: Input[]; last: LastKey } {
98 if (event.ctrl === true || event.meta === true || CONTROL.test(event.key)) {
99 return { inputs: [], last: null }
100 }
101 const keys = NAMED.has(event.key) ? [event.key] : [...event.key]
102 let previous = last
103 const inputs: Input[] = []
104 for (const key of keys) {
105 const one = pressOf(key, inputOfOne(key, event.shift === true), previous, now)
106 inputs.push(...(one.input === undefined ? [] : [one.input]))
107 previous = one.last
108 }
109
110 return { inputs, last: previous }
111}
112
113/** One key's `input` at `now`, or undefined when it is a held key's repeat that does not repeat; and the key, as the last one seen. */
114export function pressOf(key: string, input: Input | undefined, last: LastKey, now: number): { input: Input | undefined; last: LastKey } {
115 const identity = identityOf(key)
116 const isRepeat = last !== null && last.key === identity && now - last.at <= HELD_MS
117
118 return { input: input !== undefined && (!isRepeat || REPEATING.has(input)) ? input : undefined, last: { key: identity, at: now } }
119}
120
121/** The input a pane Button's hotkey stands for; undefined for a key that is not one. */
122export function inputOfHotkey(key: string): Input | undefined {
123 return LETTERS[key]
124}
125hooks/play/palette.ts 70 lines1import type { Color } from 'claude-code'
2
3import type { Kind } from '../game'
4
5/**
6 * The game's own colors, for a dark terminal: one per piece, apart in hue
7 * and lightness so they stay distinct for the three common color vision
8 * deficiencies and on a 256-color terminal, and none in the hue the common
9 * convention gives its piece. Chrome uses the person's theme keys where one
10 * fits.
11 */
12export const PIECE_COLORS: Readonly<Record<Kind, Color>> = {
13 I: '#EE5588',
14 O: '#BB66EE',
15 T: '#EEFF22',
16 S: '#AABBFF',
17 Z: '#77FFCC',
18 J: '#889911',
19 L: '#33AAAA',
20}
21
22/** The dark well a ghost is mixed toward. */
23const WELL_DARK = '#1E1E2E'
24/** How far a ghost color is from the dark well toward its piece color. */
25const GHOST_STRENGTH = 0.55
26
27const channelsOf = (color: string) => [1, 3, 5].map(at => Number.parseInt(color.slice(at, at + 2), 16))
28const hex = (channels: number[]) => `#${channels.map(channel => channel.toString(16).padStart(2, '0')).join('').toUpperCase()}`
29
30/** `color` mixed toward `base`: 0 is the base, 1 the color. */
31function toward(color: string, base: string, strength: number): Color {
32 const from = channelsOf(base)
33
34 return hex(channelsOf(color).map((channel, at) => Math.round(from[at]! + (channel - from[at]!) * strength))) as Color
35}
36
37/**
38 * Each piece's ghost color: its own color at reduced intensity, so the ghost
39 * reads as that piece but never as a locked one. Not dimColor: Claude Code
40 * draws dim text grey, whatever its color.
41 */
42export const GHOST_COLORS: Readonly<Record<Kind, Color>> = Object.fromEntries(
43 Object.entries(PIECE_COLORS).map(([kind, color]) => [kind, toward(String(color), WELL_DARK, GHOST_STRENGTH)]),
44) as Record<Kind, Color>
45
46export const COLORS = {
47 /** The well's frame and the side boxes: the game's own accent. */
48 frame: '#7A6FB0',
49 /** The dots of an empty cell. */
50 grid: 'subtle',
51 /** Panel labels: hold, next, score. */
52 label: 'inactive',
53 /** Panel numbers. */
54 value: 'text',
55 /** A card over the well: its background and its text. */
56 card: '#2A2540',
57 cardText: 'text',
58 /** The card over the well when a game beats the best: gold, and its text. */
59 bestCard: '#FFD479',
60 bestText: '#2A2540',
61 /** Cleared rows, as they flash before they go. */
62 flash: '#FFF4DC',
63 /** The word a clear is called in the well's top edge. */
64 callout: '#FFD479',
65 /** The status line while the game has the keys. */
66 keys: 'success',
67 /** The status line while the keys go to the prompt: the warning that Escape there interrupts Claude. */
68 away: 'warning',
69} as const satisfies Record<string, Color>
70hooks/play/play.ts 193 lines1import type { ClientKeyEvent, ClientPointerEvent } from 'claude-code'
2
3import Game from '../game'
4import type { Action, GameState, Input } from '../game'
5import { inputOfHotkey, pressOf, pressesOf } from './keys'
6import type { LastKey } from './keys'
7
8/** The frame clock's period: the game moves on this much engine time each frame. */
9export const TICK_MS = 50
10
11/**
12 * How long a clicked game region goes without a key before the game takes
13 * it as unfocused and pauses. Nothing tells the region it lost the keys
14 * (Escape never reaches it), so silence is the only sign.
15 */
16export const IDLE_MS = 2_000
17
18/** One press of a pane Button, by its hotkey letter, numbered so a redraw never applies it twice. */
19export type Press = { readonly seq: number; readonly key: string }
20
21/**
22 * Who has the keyboard: the clicked game region (inferred), the pane's
23 * Buttons (the pane says so), or nobody, when the keys go to the prompt.
24 */
25export type Focus = 'region' | 'pane' | 'none'
26
27/** What the hooks module tells the game: whether the pane holds the keys, a seed base from its clock, and the best score kept. */
28export type Outside = { readonly paneFocused: boolean; readonly seedBase: number; readonly best: number; readonly startLevel?: number }
29
30/** The highest level a game may start at: gravity stops speeding up there. */
31export const MAX_START_LEVEL = 15
32
33/** The startLevel setting as a level a game can start at: a whole number from 1 to MAX_START_LEVEL, else 1. */
34export function startLevelOf(value: unknown): number {
35 return typeof value === 'number' && Number.isInteger(value) && value >= 1 && value <= MAX_START_LEVEL ? value : 1
36}
37
38/** The last lock worth calling out: when, on the frame clock, what it did, and the level it took the game to, if a new one. */
39export type Clear = { readonly at: number; readonly action: Action; readonly level: number | null }
40
41/** The game region's own state, kept by the Client between frames. */
42export type Play = {
43 /** Engine time: TICK_MS per frame of the frame clock. */
44 readonly now: number
45 /** The game; null before the first one starts. */
46 readonly game: GameState | null
47 /** Whether the region holds the keys, as inferred from a click and the keys since. */
48 readonly region: boolean
49 /** When the region last saw a key or a click. */
50 readonly lastKeyAt: number
51 /** Whether the game was paused because nobody had the keys, so getting them back resumes it. */
52 readonly autoPaused: boolean
53 /** The last pane press applied. */
54 readonly seq: number
55 /** Whether the ended game's score was handed to the hooks module. */
56 readonly reported: boolean
57 /** The last clear in this game, for the flash and the callout; null before one. */
58 readonly clear: Clear | null
59 /** The best score kept when this game started: the one it has to beat, which saving its own score does not move. */
60 readonly bestBefore: number
61 /** The last key or hotkey seen, to tell a held key's repeats from new presses. */
62 readonly lastKey: LastKey
63}
64
65/** A region that has seen nothing yet; presses up to `seq` came before it and are not its to apply. */
66export function startPlay(seq: number): Play {
67 return { now: 0, game: null, region: false, lastKeyAt: 0, autoPaused: false, seq, reported: false, clear: null, bestBefore: 0, lastKey: null }
68}
69
70/** The one place that decides who has the keys: a clicked region first, then the focused pane. */
71export function focusOf(play: Play, outside: Outside): Focus {
72 if (play.region) {
73 return 'region'
74 }
75
76 return outside.paneFocused ? 'pane' : 'none'
77}
78
79const isRunning = (game: GameState | null): game is GameState => game !== null && game.phase !== 'over'
80
81/** The game paused while nobody has the keys, and resumed when somebody has them again, unless the person paused it. */
82function synced(play: Play, outside: Outside): Play {
83 const { game } = play
84 if (!isRunning(game)) {
85 return play
86 }
87 const hasKeys = focusOf(play, outside) !== 'none'
88 if (!hasKeys && game.phase === 'playing') {
89 return { ...play, game: Game.step(game, 'pause', play.now), autoPaused: true }
90 }
91 if (hasKeys && play.autoPaused) {
92 return { ...play, game: game.phase === 'paused' ? Game.step(game, 'pause', play.now) : game, autoPaused: false }
93 }
94
95 return play
96}
97
98/** A new game, its seed from the clock: the hooks module's base and the frame clock's time. */
99function started(play: Play, outside: Outside): Play {
100 const seed = (outside.seedBase + play.now) >>> 0
101
102 return { ...play, game: Game.newGame(seed, { startMs: play.now, startLevel: startLevelOf(outside.startLevel) }), autoPaused: false, reported: false, clear: null, bestBefore: outside.best }
103}
104
105/** `after` with its last lock noted when the game in it made one worth calling out since `before`: a clear or a spin. */
106function noted(before: Play, after: Play): Play {
107 const was = before.game
108 const { game } = after
109 const action = game?.lastAction
110 if (was === null || game === null || action === null || action === undefined || action === was.lastAction) {
111 return after
112 }
113 if (action.rows === 0 && action.spin === 'none') {
114 return after
115 }
116
117 return { ...after, clear: { at: after.now, action, level: game.level > was.level ? game.level : null } }
118}
119
120function applied(play: Play, inputs: readonly Input[]): Play {
121 const game = inputs.reduce<GameState | null>((current, input) => (current === null ? null : Game.step(current, input, play.now)), play.game)
122
123 return { ...play, game }
124}
125
126/** One frame of the frame clock: time moves on, a silent region lets go of the keys, gravity runs. */
127export function ticked(play: Play, outside: Outside): Play {
128 const now = play.now + TICK_MS
129 const isIdle = play.region && now - play.lastKeyAt >= IDLE_MS
130 const moved = synced({ ...play, now, region: play.region && !isIdle }, outside)
131
132 return noted(play, moved.game?.phase === 'playing' ? { ...moved, game: Game.step(moved.game, 'tick', now) } : moved)
133}
134
135/** A click on the region: it has the keys now, and a click with no game running starts one. */
136export function pointed(play: Play, event: ClientPointerEvent, outside: Outside): Play {
137 if (event.type !== 'down') {
138 return play
139 }
140 const focused = { ...play, region: true, lastKeyAt: play.now }
141
142 return isRunning(play.game) ? synced(focused, outside) : started(focused, outside)
143}
144
145/**
146 * A key on the region. Any key shows the region has the keys; the first one
147 * after the region went idle only takes them back (and resumes), so a drop
148 * pressed into a paused game does not land unseen. Keys do nothing while no
149 * game runs: a click starts one.
150 */
151export function keyed(play: Play, event: ClientKeyEvent, outside: Outside): Play {
152 const wasRegion = play.region
153 const { inputs, last } = pressesOf(event, play.lastKey, play.now)
154 const focused = synced({ ...play, region: true, lastKeyAt: play.now, lastKey: last }, outside)
155 if (!wasRegion || !isRunning(focused.game)) {
156 return focused
157 }
158
159 return noted(play, applied(focused, inputs))
160}
161
162/**
163 * The pane Buttons' presses not yet applied, in order; a key that is no
164 * hotkey does nothing. With no game running,
165 * pause starts one (the keyboard's way to play again) and the rest do nothing.
166 */
167export function pressed(play: Play, presses: readonly Press[], outside: Outside): Play {
168 const fresh = presses.filter(press => press.seq > play.seq)
169 if (fresh.length === 0) {
170 return play
171 }
172 let current: Play = synced({ ...play, seq: Math.max(...fresh.map(press => press.seq)) }, outside)
173 for (const press of fresh) {
174 const { input, last } = pressOf(press.key, inputOfHotkey(press.key), current.lastKey, current.now)
175 current = { ...current, lastKey: last }
176 if (input === undefined) {
177 continue
178 }
179 if (isRunning(current.game)) {
180 current = applied(current, [input])
181 } else if (input === 'pause') {
182 current = started(current, outside)
183 }
184 }
185
186 return noted(play, current)
187}
188
189/** The ended game's score while it has not been handed over yet; null otherwise. */
190export function scoreToReport(play: Play): number | null {
191 return play.game?.phase === 'over' && !play.reported ? play.game.score : null
192}
193hooks/play/view.ts 368 lines1import type { ClientElements, Color, RenderElement } from 'claude-code'
2
3import Game from '../game'
4import type { Action, GameState, Kind, Point } from '../game'
5import { COLORS, GHOST_COLORS, PIECE_COLORS } from './palette'
6import { TICK_MS, focusOf } from './play'
7import type { Clear, Focus, Outside, Play } from './play'
8
9/** A run of text drawn in one style. */
10export type Segment = { readonly text: string; readonly color?: Color; readonly backgroundColor?: Color; readonly dimColor?: true; readonly bold?: true }
11
12/** One terminal line: segments side by side. */
13export type Line = readonly Segment[]
14
15/** A filled cell: two full blocks, square in most terminal fonts (half blocks draw as thin bars in some). */
16export const CELL = '██'
17/** Where a hard drop would land the falling piece: shaded, so it never reads as a locked cell. */
18export const GHOST = '▓▓'
19/** An empty cell of the well. */
20export const EMPTY = '· '
21
22const PANEL = 10
23const GAP = 2
24const WELL_INNER = Game.WIDTH * CELL.length
25const WELL = WELL_INNER + 2
26
27/** How long a clear is called out in the well's top edge. */
28export const CALLOUT_MS = 1_500
29/** What a clear of one to four rows is called; with a spin, the word follows the spin's. */
30export const CALLOUTS: Readonly<Record<number, string>> = { 1: 'single', 2: 'double', 3: 'triple', 4: 'four at once!' }
31
32/** The words a lock is called by in the well's top edge: all clear, a spin and its rows, or the rows alone. */
33function calloutOf(action: Action): string {
34 if (action.perfect) {
35 return 'all clear!'
36 }
37 const spin = { none: [], mini: ['mini spin'], spin: ['spin'] }[action.spin]
38
39 return [...spin, ...(action.rows > 0 ? [CALLOUTS[action.rows]!] : [])].join(' ')
40}
41
42/** The extras of a lock, for the bottom edge, in full and short: back to back, the combo count, a new level. */
43function extrasOf(clear: Clear): { full: string[]; short: string[] } {
44 const { action, level } = clear
45 const combo = action.combo > 0 ? [`combo ${action.combo}`] : []
46 const levelUp = level === null ? [] : [`level ${level}`]
47
48 return {
49 full: [...(action.backToBack ? ['back to back'] : []), ...combo, ...levelUp],
50 short: [...(action.backToBack ? ['b2b'] : []), ...combo, ...levelUp],
51 }
52}
53
54/** The new best card's title, in the two looks it twinkles between. */
55export const NEW_BEST = ['✦ new best ✦', '✧ new best ✧'] as const
56/** How long the new best card's title holds each look. */
57export const TWINKLE_MS = 5 * TICK_MS
58
59/** The columns the game takes: hold panel, well, next panel and the gaps between. */
60export const GAME_COLUMNS = PANEL + GAP + WELL + GAP + PANEL
61
62/** The rows the game takes: the well with its frame, and the status line. */
63export const GAME_ROWS = Game.VISIBLE_ROWS + 3
64
65/** What each focus says on the status line: who has the keys, and how to give them back or get them. */
66export const STATUS: Readonly<Record<Focus, string>> = {
67 region: 'playing · Esc gives keys back',
68 pane: 'keys: w a s d · Esc gives keys back',
69 none: 'click to play · or ctrl+x tab, then w a s d',
70}
71
72/** The status line while the person has paused the game: the board stays in view, so no card says it. */
73export const PAUSED = 'paused · p resumes'
74
75const CONTROL = /[\u0000-\u001f\u007f-\u009f]/g
76
77/** Text safe to draw: a control character in a Text child unmounts the region for good. */
78export const safe = (text: string): string => text.replace(CONTROL, '')
79
80const plain = (text: string, color?: Color): Segment => (color === undefined ? { text } : { text, color })
81const pad = (text: string, width: number) => text + ' '.repeat(Math.max(0, width - text.length))
82const centred = (text: string, width: number) => pad(' '.repeat(Math.max(0, Math.floor((width - text.length) / 2))) + text, width)
83
84/** A kind's spawn cells, moved to the top left and centred in a four-cell box: two rows of segments. */
85function miniPiece(kind: Kind | null): Line[] {
86 if (kind === null) {
87 return [[plain(' '.repeat(8))], [plain(' '.repeat(8))]]
88 }
89 const cells = Game.cellsOf(kind, 0)
90 const left = Math.min(...cells.map(cell => cell.x))
91 const top = Math.min(...cells.map(cell => cell.y))
92 const width = Math.max(...cells.map(cell => cell.x)) - left + 1
93 const offset = Math.floor((4 - width) / 2)
94 const tile: Segment = { text: CELL, color: PIECE_COLORS[kind] }
95
96 return [0, 1].map(row =>
97 [0, 1, 2, 3].map(column =>
98 cells.some(cell => cell.x - left === column - offset && cell.y - top === row) ? tile : plain(' '),
99 ),
100 )
101}
102
103const boxTop = (inner: number, color: Color = COLORS.frame) => plain(`╭${'─'.repeat(inner)}╮`, color)
104const boxBottom = (inner: number, color: Color = COLORS.frame) => plain(`╰${'─'.repeat(inner)}╯`, color)
105const boxed = (line: Line, color: Color = COLORS.frame): Line => [plain('│', color), ...line, plain('│', color)]
106
107/**
108 * The hold box and the numbers under it, PANEL wide. After a hold the piece
109 * keeps its color (grey read as broken); the label says used and the frame
110 * goes quiet until the next piece enters.
111 */
112function holdPanel(play: Play, best: number): Line[] {
113 const { game } = play
114 const kind = game?.hold ?? null
115 const isUsed = game !== null && !game.canHold
116 const frame = isUsed ? COLORS.label : COLORS.frame
117 const score = game?.score ?? 0
118 const stat = (label: string, value: number): Line[] => [[plain(pad(label, PANEL), COLORS.label)], [{ text: pad(String(value), PANEL), color: COLORS.value, bold: true }], [plain(' '.repeat(PANEL))]]
119
120 return [
121 [plain(pad(isUsed ? 'hold used' : 'hold', PANEL), COLORS.label)],
122 [boxTop(8, frame)],
123 ...miniPiece(kind).map(line => boxed(line, frame)),
124 [boxBottom(8, frame)],
125 [plain(' '.repeat(PANEL))],
126 ...stat('score', score),
127 ...stat('level', game?.level ?? 1),
128 ...stat('lines', game?.lines ?? 0),
129 ...stat(isNewBest(play, best) ? 'best new!' : 'best', Math.max(best, score)),
130 ]
131}
132
133/** The next PREVIEW_SIZE pieces, PANEL wide; none once the game is over. */
134function nextPanel(game: GameState | null): Line[] {
135 const next = game === null || game.phase === 'over' ? [] : Game.nextOf(game)
136 const blank: Line = [plain(' '.repeat(8))]
137 const pieces = Array.from({ length: Game.PREVIEW_SIZE }, (_, at) => [...miniPiece(next[at] ?? null), ...(at < Game.PREVIEW_SIZE - 1 ? [blank] : [])]).flat()
138
139 return [[plain(pad('next', PANEL), COLORS.label)], [boxTop(8)], ...pieces.map(line => boxed(line)), [boxBottom(8)]]
140}
141
142/**
143 * Whether the game in play scores more than the best it started against,
144 * and no less than the best kept now (which its own saved score reaches,
145 * and a game elsewhere may have passed).
146 */
147const isNewBest = (play: Play, best: number) => play.game !== null && play.game.score > play.bestBefore && play.game.score >= best
148
149/** A card over the well: its lines, and whether it is the gold one of a new best. */
150type Card = { readonly lines: string[]; readonly isBest: boolean }
151
152/** The game-over card: a gold one that twinkles when the game beat the best it started against. */
153function overCard(play: Play, game: GameState, best: number, again: string): Card {
154 if (!isNewBest(play, best)) {
155 return { lines: ['game over', `score ${game.score}`, '', again], isBest: false }
156 }
157 const title = NEW_BEST[Math.floor(play.now / TWINKLE_MS) % NEW_BEST.length]!
158 const by = play.bestBefore > 0 ? [`up ${game.score - play.bestBefore} on ${play.bestBefore}`] : []
159
160 return { lines: [title, `score ${game.score}`, ...by, '', again], isBest: true }
161}
162
163/** What the well shows over the board: a card, or none. */
164function cardOf(play: Play, outside: Outside): Card | null {
165 const focus = focusOf(play, outside)
166 const { game } = play
167 if (game?.phase === 'over') {
168 return overCard(play, game, outside.best, focus === 'pane' ? 'p to play again' : 'click to play again')
169 }
170 const card = (...lines: string[]): Card => ({ lines, isBest: false })
171 if (focus === 'none') {
172 return card('click to play')
173 }
174 if (game === null) {
175 return card(focus === 'pane' ? 'p to play' : 'click to play')
176 }
177
178 return null
179}
180
181const has = (points: readonly Point[], x: number, y: number) => points.some(point => point.x === x && point.y === y)
182
183/** A cell of a cleared row, still showing. */
184type Lit = 'lit'
185
186/** The rows going while the engine holds the next piece back, and how many pairs of columns have gone; null otherwise. */
187function flashOf(play: Play, game: GameState): { rows: readonly number[]; swept: number } | null {
188 const clearing = Game.clearingOf(game)
189 if (clearing === null) {
190 return null
191 }
192 const pairs = Game.WIDTH / 2
193
194 return { rows: clearing.rows, swept: Math.floor(((play.now - clearing.startedAt) * pairs) / (clearing.until - clearing.startedAt)) }
195}
196
197/** The visible board as it was just before the clear: the kept rows back in place around the cleared ones, the falling piece over it. */
198function unclearedOf(game: GameState, rows: readonly number[]): (Kind | Lit | null)[][] {
199 const kept = game.board.slice(rows.length)
200 let next = 0
201 const whole = Array.from({ length: Game.ROWS }, (_, y): (Kind | Lit | null)[] => (rows.includes(y) ? Array.from({ length: Game.WIDTH }, (): Lit => 'lit') : [...kept[next++]!]))
202 const visible = whole.slice(Game.HIDDEN_ROWS)
203 for (const { x, y } of Game.activeOf(game)) {
204 visible[y]![x] ??= game.active!.kind
205 }
206
207 return visible
208}
209
210/** Whether a cleared row's cell at column `x` has gone once `swept` pairs have: the middle pair first, then outward. */
211const isSwept = (x: number, swept: number) => (x < Game.WIDTH / 2 ? Game.WIDTH / 2 - 1 - x : x - Game.WIDTH / 2) < swept
212
213/**
214 * The well's visible rows: locked cells and the falling piece as tiles, the
215 * ghost shaded under it. Just after a clear, the board as it was, its
216 * cleared rows lit and sweeping out.
217 */
218function boardRows(play: Play): Line[] {
219 const { game } = play
220 const flash = game === null ? null : flashOf(play, game)
221 const board = game === null ? null : flash === null ? Game.boardOf(game) : unclearedOf(game, flash.rows)
222 const ghost = game === null || flash !== null ? [] : Game.ghostOf(game)
223 const kind = game?.active?.kind
224 // paused, the board stays in view, dimmed, rather than under a card
225 const dim = game?.phase === 'paused' ? { dimColor: true as const } : {}
226
227 return Array.from({ length: Game.VISIBLE_ROWS }, (_, y) =>
228 Array.from({ length: Game.WIDTH }, (_, x): Segment => ({ ...cellAt(x, y), ...dim })),
229 )
230
231 function cellAt(x: number, y: number): Segment {
232 const cell = board?.[y]?.[x] ?? null
233 if (cell === 'lit') {
234 return isSwept(x, flash!.swept) ? plain(EMPTY, COLORS.grid) : { text: CELL, color: COLORS.flash }
235 }
236 if (cell !== null) {
237 return { text: CELL, color: PIECE_COLORS[cell] }
238 }
239
240 return kind !== undefined && has(ghost, x, y) ? { text: GHOST, color: GHOST_COLORS[kind] } : plain(EMPTY, COLORS.grid)
241 }
242}
243
244/** The well's top edge, with the last clear called out in it for CALLOUT_MS. */
245function wellTop(play: Play): Line {
246 const clear = shownClear(play)
247
248 return clear === null ? [boxTop(WELL_INNER)] : edge('╭', '╮', calloutOf(clear.action))
249}
250
251/** The well's bottom edge, with the last lock's extras called out in it for CALLOUT_MS: as many as fit, full words when all do. */
252function wellBottom(play: Play): Line {
253 const clear = shownClear(play)
254 if (clear === null) {
255 return [boxBottom(WELL_INNER)]
256 }
257 const fits = (words: string[]) => words.join(' · ').length + 2 <= WELL_INNER
258 const { full, short } = extrasOf(clear)
259 const words = fits(full) ? full : short.filter((_, at) => fits(short.slice(0, at + 1)))
260
261 return words.length === 0 ? [boxBottom(WELL_INNER)] : edge('╰', '╯', words.join(' · '))
262}
263
264/** The last lock worth calling out, while it is called out; null otherwise. */
265const shownClear = (play: Play) => (play.clear === null || play.now - play.clear.at >= CALLOUT_MS ? null : play.clear)
266
267/** A well edge with `text` in its middle, in the callout color. */
268function edge(left: string, right: string, text: string): Line {
269 const label = ` ${text} `
270 const before = Math.floor((WELL_INNER - label.length) / 2)
271
272 return [plain(`${left}${'─'.repeat(before)}`, COLORS.frame), { text: label, color: COLORS.callout, bold: true }, plain(`${'─'.repeat(WELL_INNER - before - label.length)}${right}`, COLORS.frame)]
273}
274
275/** The board with a card laid across its middle rows: a blank row above and below the text. */
276function withCard(rows: Line[], card: Card | null): Line[] {
277 if (card === null) {
278 return rows
279 }
280 const lines = ['', ...card.lines, '']
281 const [background, color] = card.isBest ? [COLORS.bestCard, COLORS.bestText] : [COLORS.card, COLORS.cardText]
282 const top = Math.floor((rows.length - lines.length) / 2)
283
284 return rows.map((row, y) => {
285 const text = lines[y - top]
286 if (text === undefined) {
287 return row
288 }
289 const isTitle = y - top === 1
290
291 return [{ text: centred(text, WELL_INNER), color, backgroundColor: background, ...(isTitle ? { bold: true as const } : {}) }]
292 })
293}
294
295function well(play: Play, outside: Outside): Line[] {
296 return [wellTop(play), ...withCard(boardRows(play), cardOf(play, outside)).map(line => boxed(line)), wellBottom(play)]
297}
298
299const widthOf = (line: Line) => line.reduce((sum, segment) => sum + segment.text.length, 0)
300const filled = (line: Line | undefined, width: number): Line => {
301 const row = line ?? []
302
303 return [...row, plain(' '.repeat(Math.max(0, width - widthOf(row))))]
304}
305
306/**
307 * What the game region draws, line by line, at `columns` wide: the hold
308 * panel, the well and the next panel side by side, centred, then the status
309 * line. Narrower than the game, one line asks for room instead.
310 */
311export function screenOf(play: Play, outside: Outside, columns: number): Line[] {
312 if (columns > 0 && columns < GAME_COLUMNS) {
313 return [[plain(`line-clear needs ${GAME_COLUMNS} columns: widen the pane`, COLORS.away)]]
314 }
315 const margin = plain(' '.repeat(Math.max(0, Math.floor((columns - GAME_COLUMNS) / 2))))
316 const left = holdPanel(play, outside.best)
317 const middle = well(play, outside)
318 const right = nextPanel(play.game)
319 const gap = plain(' '.repeat(GAP))
320 const rows = middle.map((line, y) => [margin, ...filled(left[y], PANEL), gap, ...line, gap, ...filled(right[y], PANEL)])
321 const focus = focusOf(play, outside)
322 const isPaused = play.game?.phase === 'paused' && focus !== 'none'
323 const status = { text: isPaused ? PAUSED : STATUS[focus], color: focus === 'none' ? COLORS.away : COLORS.keys, bold: true as const }
324
325 return [...rows, [margin, status]].map(trimmed)
326}
327
328/** The line with its trailing spaces cut and its neighbouring segments of one style joined. */
329function trimmed(line: Line): Line {
330 const joined: Segment[] = []
331 for (const segment of line) {
332 const last = joined.at(-1)
333 if (last !== undefined && sameStyle(last, segment)) {
334 joined[joined.length - 1] = { ...last, text: last.text + segment.text }
335 } else {
336 joined.push(segment)
337 }
338 }
339 // trailing spaces show nothing unless a background paints them
340 while (joined.length > 0 && joined.at(-1)!.backgroundColor === undefined) {
341 const end = joined.at(-1)!
342 const text = end.text.trimEnd()
343 if (text !== '') {
344 joined[joined.length - 1] = { ...end, text }
345 break
346 }
347 joined.pop()
348 }
349
350 return joined
351}
352
353const sameStyle = (a: Segment, b: Segment) =>
354 a.color === b.color && a.backgroundColor === b.backgroundColor && a.dimColor === b.dimColor && a.bold === b.bold
355
356/** A line as the text it shows. */
357export const textOf = (line: Line): string => line.map(segment => segment.text).join('')
358
359/** The lines as the tree the region draws: a column of one Text per line, a Text per segment, every text made safe. */
360export function screenTree(elements: Pick<ClientElements, 'Box' | 'Text'>, lines: readonly Line[]): RenderElement {
361 const { Box, Text } = elements
362
363 return Box({
364 flexDirection: 'column',
365 children: lines.map(line => Text({ children: line.map(({ text, ...style }) => Text({ ...style, children: safe(text) })) })),
366 })
367}
368hooks/game/index.ts 12 lines1export * from './bag'
2export * from './board'
3export * from './game'
4export * from './moves'
5export * from './pieces'
6export * from './random'
7export * from './rules'
8export * from './spin'
9export * from './types'
10
11export * as default from '.'
12hooks/game/bag.ts 20 lines1import { nextRandom } from './random'
2import type { Kind } from './types'
3
4/** Every kind once, in a fixed order the shuffle starts from. */
5export const KINDS: readonly Kind[] = ['I', 'O', 'T', 'S', 'Z', 'J', 'L']
6
7/** One bag of all seven kinds in a seeded random order (Fisher-Yates), and the generator state after it. */
8export function shuffledBag(state: number): { bag: Kind[]; state: number } {
9 const bag = [...KINDS]
10 let current = state
11 for (let i = bag.length - 1; i > 0; i--) {
12 const draw = nextRandom(current)
13 current = draw.state
14 const j = Math.floor(draw.value * (i + 1))
15 ;[bag[i], bag[j]] = [bag[j]!, bag[i]!]
16 }
17
18 return { bag, state: current }
19}
20hooks/game/board.ts 38 lines1import type { Kind } from './types'
2
3/** The well is 10 columns wide. */
4export const WIDTH = 10
5
6/** Rows the person sees. */
7export const VISIBLE_ROWS = 18
8
9/** Rows above the visible ones, where pieces spawn: row 0 is the top hidden row. */
10export const HIDDEN_ROWS = 4
11
12/** Every row of the board, hidden ones first. */
13export const ROWS = HIDDEN_ROWS + VISIBLE_ROWS
14
15/** The locked cells, rows top to bottom, each cell the kind that locked there or null. */
16export type Board = readonly (readonly (Kind | null)[])[]
17
18const emptyRow = (): (Kind | null)[] => Array.from({ length: WIDTH }, () => null)
19
20/** A board with nothing locked. */
21export function emptyBoard(): Board {
22 return Array.from({ length: ROWS }, emptyRow)
23}
24
25/** Whether a cell is inside the walls, the floor and the top hidden row, and not locked. */
26export function isOpen(board: Board, x: number, y: number): boolean {
27 return x >= 0 && x < WIDTH && y >= 0 && y < ROWS && board[y]![x] === null
28}
29
30/** The board with every full row removed and as many empty rows added on top, and which rows those were, top to bottom. */
31export function clearedRows(board: Board): { board: Board; cleared: number; rows: number[] } {
32 const isFull = (row: Board[number]) => row.every(cell => cell !== null)
33 const rows = board.flatMap((row, y) => (isFull(row) ? [y] : []))
34 const kept = board.filter(row => !isFull(row))
35
36 return { board: [...Array.from({ length: rows.length }, emptyRow), ...kept], cleared: rows.length, rows }
37}
38hooks/game/game.ts 471 lines1import { HIDDEN_ROWS, clearedRows, emptyBoard } from './board'
2import type { Board } from './board'
3import { KINDS, shuffledBag } from './bag'
4import { dropDistance, fits, placed, rotated, shifted } from './moves'
5import { pieceCells, spawnPiece } from './pieces'
6import type { Piece } from './pieces'
7import { seedRandom } from './random'
8import { spinOf } from './spin'
9import {
10 BACK_TO_BACK,
11 CLEAR_MS,
12 COMBO_POINTS,
13 HARD_DROP_POINTS,
14 LOCK_DELAY_MS,
15 LOCK_RESET_CAP,
16 SOFT_DROP_POINTS,
17 allClearScore,
18 clearScore,
19 isDifficult,
20 gravityMs,
21 levelFor,
22} from './rules'
23import type { Spin } from './rules'
24import type { Kind, Point } from './types'
25
26/** One thing that happens to a game: a press, or a tick that only lets time pass. */
27export type Input = 'tick' | 'left' | 'right' | 'slideLeft' | 'slideRight' | 'softDrop' | 'hardDrop' | 'rotateCw' | 'rotateCcw' | 'hold' | 'pause'
28
29export type Phase = 'playing' | 'paused' | 'over'
30
31export type GameOptions = {
32 /** The level to start at, a whole number from 1; 1 by default. */
33 startLevel?: number
34 /** The time the game starts, on the same clock `step` is given; 0 by default. */
35 startMs?: number
36}
37
38/** A whole game. Plain data: `step` returns a new one and never changes the one it is given. */
39export type GameState = {
40 readonly board: Board
41 /** The falling piece; null once the game is over. */
42 readonly active: Piece | null
43 /** The pieces to come, whole bags appended so it always holds more than 7. */
44 readonly queue: readonly Kind[]
45 readonly hold: Kind | null
46 /** False from a hold until the next piece locks. */
47 readonly canHold: boolean
48 /** The random generator's state, for the next bag. */
49 readonly random: number
50 readonly score: number
51 readonly lines: number
52 readonly level: number
53 readonly startLevel: number
54 readonly phase: Phase
55 /** Why the game ended: the next piece had no room (block out), or a piece locked wholly above the well (lock out). */
56 readonly over: 'block-out' | 'lock-out' | null
57 /** When the falling piece last moved down a row or appeared: gravity counts from here. */
58 readonly fallAt: number
59 /** When the lock delay last started, while the piece rests on something; null while it can fall. */
60 readonly lockAt: number | null
61 /** Moves and turns made while resting since the piece reached its lowest row. */
62 readonly lockResets: number
63 /** The lowest row any of the piece's cells has reached. */
64 readonly lowestY: number
65 /** When the game was paused, while it is. */
66 readonly pausedAt: number | null
67 /** The board rows the last lock cleared, top to bottom, numbered as they were before the clear; empty when it cleared none. */
68 readonly lastClear: readonly number[]
69 /**
70 * While cleared rows go, before the next piece enters: when that started and
71 * ends, and the last turn and the hold asked for meanwhile, done as it enters.
72 */
73 readonly clearing: Clearing | null
74 /** The kick of the falling piece's last successful move when that move was a turn; null after any other move. */
75 readonly turnKick: Point | null
76 /** What the last lock did; null before the first. */
77 readonly lastAction: Action | null
78 /** Whether the last lock that cleared rows made a difficult clear: the next difficult one scores half again. */
79 readonly backToBack: boolean
80 /** Clears in a row less one: -1 after a lock that cleared nothing, 0 after the first clear, 1 after the second. */
81 readonly combo: number
82}
83
84/** What a lock did, for scoring and for calling it out: the rows it cleared, its spin and the points it made. */
85export type Action = {
86 readonly rows: number
87 readonly spin: Spin
88 readonly points: number
89 readonly backToBack: boolean
90 readonly combo: number
91 /** Whether the clear emptied the well. */
92 readonly perfect: boolean
93}
94
95/** The pause between a lock that clears rows and the next piece. */
96export type Clearing = { readonly startedAt: number; readonly until: number; readonly turn: 1 | -1 | 0; readonly hold: boolean }
97
98/** How many of the pieces to come a preview shows. */
99export const PREVIEW_SIZE = 5
100
101/** The queue never runs this short: a bag is appended while it is. */
102const QUEUE_FLOOR = KINDS.length
103
104/** The queue with whole bags appended until it is longer than QUEUE_FLOOR, and the generator after them. */
105function refilled(queue: readonly Kind[], random: number): { queue: Kind[]; random: number } {
106 const next = [...queue]
107 let state = random
108 while (next.length <= QUEUE_FLOOR) {
109 const dealt = shuffledBag(state)
110 next.push(...dealt.bag)
111 state = dealt.state
112 }
113
114 return { queue: next, random: state }
115}
116
117/** The lowest row the piece's cells are in. */
118const bottomOf = (piece: Piece) => Math.max(...pieceCells(piece).map(({ y }) => y))
119
120const isResting = (board: Board, piece: Piece) => !fits(board, { ...piece, y: piece.y + 1 })
121
122function ended(state: GameState, over: 'block-out' | 'lock-out'): GameState {
123 return { ...state, active: null, phase: 'over', over, lockAt: null }
124}
125
126/**
127 * The game with `kind` entering at time `at`: it spawns, then drops one row
128 * at once when there is room, so it shows straight away. Over when there is
129 * no room to spawn.
130 */
131function spawned(state: GameState, kind: Kind, at: number): GameState {
132 const spawn = spawnPiece(kind)
133 if (!fits(state.board, spawn)) {
134 return ended(state, 'block-out')
135 }
136 const piece = shifted(state.board, spawn, 0, 1) ?? spawn
137
138 return {
139 ...state,
140 active: piece,
141 fallAt: at,
142 lockAt: isResting(state.board, piece) ? at : null,
143 lockResets: 0,
144 lowestY: bottomOf(piece),
145 }
146}
147
148/** The game with the next piece from the queue entering at time `at`. */
149function nextPiece(state: GameState, at: number): GameState {
150 const [kind, ...rest] = state.queue
151 const { queue, random } = refilled(rest, state.random)
152
153 return spawned({ ...state, queue, random, canHold: true }, kind!, at)
154}
155
156/** The falling piece locked into the board at time `at`: rows cleared, scored, and the next piece on. */
157function locked(state: GameState, at: number): GameState {
158 const piece = state.active!
159 if (pieceCells(piece).every(({ y }) => y < HIDDEN_ROWS)) {
160 return ended({ ...state, board: placed(state.board, piece) }, 'lock-out')
161 }
162 const spin = spinOf(state.board, piece, state.turnKick)
163 const { board, cleared, rows } = clearedRows(placed(state.board, piece))
164 const lines = state.lines + cleared
165 const difficult = isDifficult(cleared, spin)
166 const isBackToBack = difficult && state.backToBack
167 const combo = cleared === 0 ? -1 : state.combo + 1
168 const comboPoints = combo > 0 ? COMBO_POINTS * combo * state.level : 0
169 // a lock that clears nothing leaves its own cells, so only a clear can empty the well
170 const perfect = board.every(row => row.every(cell => cell === null))
171 const allClear = perfect ? allClearScore(cleared, state.level, isBackToBack) : 0
172 const points = Math.floor(clearScore(cleared, state.level, spin) * (isBackToBack ? BACK_TO_BACK : 1)) + comboPoints + allClear
173
174 const scored = {
175 ...state,
176 board,
177 score: state.score + points,
178 lines,
179 level: levelFor(state.startLevel, lines),
180 lastClear: rows,
181 turnKick: null,
182 lastAction: { rows: cleared, spin, points, backToBack: isBackToBack, combo, perfect },
183 backToBack: cleared === 0 ? state.backToBack : difficult,
184 combo,
185 }
186 if (cleared === 0) {
187 return nextPiece(scored, at)
188 }
189
190 return { ...scored, active: null, lockAt: null, clearing: { startedAt: at, until: at + CLEAR_MS, turn: 0, hold: false } }
191}
192
193/** The next piece entering once the clearing ends, with the hold and the turn asked for meanwhile. */
194function cleared(state: GameState, clearing: Clearing): GameState {
195 const at = clearing.until
196 let next = nextPiece({ ...state, clearing: null }, at)
197 if (clearing.hold && next.phase === 'playing') {
198 next = held(next, at)
199 }
200 if (clearing.turn !== 0 && next.phase === 'playing') {
201 next = pressed(next, clearing.turn === 1 ? 'rotateCw' : 'rotateCcw', at)
202 }
203
204 return next
205}
206
207/** An input while rows clear: a turn or a hold is kept for the next piece, anything else is dropped. */
208function buffered(state: GameState, clearing: Clearing, input: Input): GameState {
209 if (input === 'rotateCw' || input === 'rotateCcw') {
210 return { ...state, clearing: { ...clearing, turn: input === 'rotateCw' ? 1 : -1 } }
211 }
212
213 return input === 'hold' ? { ...state, clearing: { ...clearing, hold: true } } : state
214}
215
216/**
217 * The game with the falling piece moved to `piece` at time `at`. A piece at a
218 * new lowest row gets its lock resets back; a move made while resting spends
219 * one. A piece that rests afterwards restarts its lock delay, unless it has
220 * spent more than LOCK_RESET_CAP, when it locks at once.
221 */
222function movedTo(state: GameState, piece: Piece, at: number, isPress: boolean, turnKick: Point | null = null): GameState {
223 const bottom = bottomOf(piece)
224 const isLower = bottom > state.lowestY
225 const wasResting = state.lockAt !== null
226 const lockResets = isLower ? 0 : state.lockResets + (isPress && wasResting ? 1 : 0)
227 const moved = { ...state, active: piece, lowestY: Math.max(bottom, state.lowestY), lockResets, turnKick }
228 if (!isResting(state.board, piece)) {
229 // a piece that leaves a rest starts its gravity clock there, not at its last fall
230 return { ...moved, lockAt: null, fallAt: wasResting ? at : state.fallAt }
231 }
232
233 return lockResets > LOCK_RESET_CAP ? locked(moved, at) : { ...moved, lockAt: at }
234}
235
236/** One thing time does by `now`: a row of gravity or a lock, or the same state when nothing is due. */
237function timeStep(state: GameState, now: number): GameState {
238 if (state.clearing !== null) {
239 return now >= state.clearing.until ? cleared(state, state.clearing) : state
240 }
241 const piece = state.active!
242 if (state.lockAt !== null) {
243 const lockDue = state.lockAt + LOCK_DELAY_MS
244
245 return now >= lockDue ? locked(state, lockDue) : state
246 }
247 const fallDue = state.fallAt + gravityMs(state.level)
248 if (now < fallDue) {
249 return state
250 }
251
252 return movedTo({ ...state, fallAt: fallDue }, { ...piece, y: piece.y + 1 }, fallDue, false)
253}
254
255/** The game with everything time does up to `now` done, in order. */
256function caughtUp(state: GameState, now: number): GameState {
257 let current = state
258 while (current.phase === 'playing') {
259 const next = timeStep(current, now)
260 if (next === current) {
261 return current
262 }
263 current = next
264 }
265
266 return current
267}
268
269function softDropped(state: GameState, now: number): GameState {
270 const down = shifted(state.board, state.active!, 0, 1)
271 if (down === null) {
272 return state
273 }
274
275 return movedTo({ ...state, fallAt: now, score: state.score + SOFT_DROP_POINTS }, down, now, false)
276}
277
278function hardDropped(state: GameState, now: number): GameState {
279 const piece = state.active!
280 const rows = dropDistance(state.board, piece)
281
282 const turnKick = rows === 0 ? state.turnKick : null
283
284 return locked({ ...state, active: { ...piece, y: piece.y + rows }, score: state.score + rows * HARD_DROP_POINTS, turnKick }, now)
285}
286
287/** The game after a press that moves or turns the piece; the same game when it cannot. */
288function pressed(state: GameState, input: 'left' | 'right' | 'rotateCw' | 'rotateCcw', now: number): GameState {
289 const piece = state.active!
290 const moved =
291 input === 'left' || input === 'right'
292 ? shifted(state.board, piece, input === 'left' ? -1 : 1, 0)
293 : rotated(state.board, piece, input === 'rotateCw' ? 1 : -1)
294
295 if (moved === null) {
296 return state
297 }
298 const isTurn = input === 'rotateCw' || input === 'rotateCcw'
299
300 return movedTo(state, moved, now, true, isTurn ? { x: moved.x - piece.x, y: moved.y - piece.y } : null)
301}
302
303/** The game after a slide: the piece moved left or right as far as it goes, as one move. The same game when it cannot move. */
304function slid(state: GameState, input: 'slideLeft' | 'slideRight', now: number): GameState {
305 const dx = input === 'slideLeft' ? -1 : 1
306 let piece = state.active!
307 for (let next = shifted(state.board, piece, dx, 0); next !== null; next = shifted(state.board, next, dx, 0)) {
308 piece = next
309 }
310
311 return piece === state.active ? state : movedTo(state, piece, now, true)
312}
313
314/** The game after a hold: the falling piece is kept and the held one (or the next) enters. Once per piece. */
315function held(state: GameState, now: number): GameState {
316 if (!state.canHold) {
317 return state
318 }
319 const keeping = { ...state, hold: state.active!.kind }
320 const swapped = state.hold === null ? nextPiece(keeping, now) : spawned(keeping, state.hold, now)
321
322 return { ...swapped, canHold: false }
323}
324
325function applied(state: GameState, input: Input, now: number): GameState {
326 switch (input) {
327 case 'left':
328 case 'right':
329 case 'rotateCw':
330 case 'rotateCcw':
331 return pressed(state, input, now)
332 case 'slideLeft':
333 case 'slideRight':
334 return slid(state, input, now)
335 case 'softDrop':
336 return softDropped(state, now)
337 case 'hardDrop':
338 return hardDropped(state, now)
339 case 'hold':
340 return held(state, now)
341 default:
342 return state
343 }
344}
345
346/** The paused game playing again at `now`, its gravity and lock clocks moved on by the time it was paused. */
347function resumed(state: GameState, now: number): GameState {
348 const pausedFor = Math.max(0, now - (state.pausedAt ?? now))
349
350 return {
351 ...state,
352 phase: 'playing',
353 pausedAt: null,
354 fallAt: state.fallAt + pausedFor,
355 lockAt: state.lockAt === null ? null : state.lockAt + pausedFor,
356 clearing: state.clearing === null ? null : { ...state.clearing, startedAt: state.clearing.startedAt + pausedFor, until: state.clearing.until + pausedFor },
357 }
358}
359
360/** A new game from a seed: the same seed and options always give the same game. */
361export function newGame(seed: number, options: GameOptions = {}): GameState {
362 const startLevel = options.startLevel ?? 1
363 if (!Number.isInteger(startLevel) || startLevel < 1) {
364 throw new RangeError(`startLevel must be a whole number from 1, not ${startLevel}`)
365 }
366 const { queue, random } = refilled([], seedRandom(seed))
367 const empty: GameState = {
368 board: emptyBoard(),
369 active: null,
370 queue,
371 hold: null,
372 canHold: true,
373 random,
374 score: 0,
375 lines: 0,
376 level: startLevel,
377 startLevel,
378 phase: 'playing',
379 over: null,
380 fallAt: 0,
381 lockAt: null,
382 lockResets: 0,
383 lowestY: 0,
384 pausedAt: null,
385 lastClear: [],
386 clearing: null,
387 turnKick: null,
388 lastAction: null,
389 backToBack: false,
390 combo: -1,
391 }
392
393 return nextPiece(empty, options.startMs ?? 0)
394}
395
396/**
397 * The game after `input` at time `nowMs`. Time is applied first (gravity rows
398 * and locks due by `nowMs`, in order), then the input. Pure: the same state,
399 * input and time always give the same result.
400 */
401export function step(state: GameState, input: Input, nowMs: number): GameState {
402 if (state.phase === 'paused') {
403 return input === 'pause' ? resumed(state, nowMs) : state
404 }
405 if (state.phase === 'over') {
406 return state
407 }
408 const current = caughtUp(state, nowMs)
409 if (current.phase !== 'playing') {
410 return current
411 }
412
413 if (input === 'pause') {
414 return { ...current, phase: 'paused', pausedAt: nowMs }
415 }
416
417 return current.clearing === null ? applied(current, input, nowMs) : buffered(current, current.clearing, input)
418}
419
420const toVisible = ({ x, y }: Point): Point => ({ x, y: y - HIDDEN_ROWS })
421const isVisible = ({ y }: Point) => y >= 0
422
423/** The visible rows, top to bottom: locked cells and the falling piece, each cell its kind or null. */
424export function boardOf(state: GameState): (Kind | null)[][] {
425 const rows = state.board.slice(HIDDEN_ROWS).map(row => [...row])
426 if (state.active !== null) {
427 for (const { x, y } of pieceCells(state.active).map(toVisible).filter(isVisible)) {
428 rows[y]![x] = state.active.kind
429 }
430 }
431
432 return rows
433}
434
435/** The falling piece's visible cells. */
436export function activeOf(state: GameState): Point[] {
437 return state.active === null ? [] : pieceCells(state.active).map(toVisible).filter(isVisible)
438}
439
440/** Where a hard drop would put the falling piece: its visible cells. */
441export function ghostOf(state: GameState): Point[] {
442 const piece = state.active
443 if (piece === null) {
444 return []
445 }
446
447 return pieceCells({ ...piece, y: piece.y + dropDistance(state.board, piece) })
448 .map(toVisible)
449 .filter(isVisible)
450}
451
452/** The rows going while a clear holds the next piece back, and when that started and ends; null otherwise. */
453export function clearingOf(state: GameState): { rows: readonly number[]; startedAt: number; until: number } | null {
454 return state.clearing === null ? null : { rows: state.lastClear, startedAt: state.clearing.startedAt, until: state.clearing.until }
455}
456
457/** The next PREVIEW_SIZE pieces, the first one next. */
458export function nextOf(state: GameState): Kind[] {
459 return state.queue.slice(0, PREVIEW_SIZE)
460}
461
462/** The held piece, and whether a hold is allowed now. */
463export function holdOf(state: GameState): { kind: Kind | null; canHold: boolean } {
464 return { kind: state.hold, canHold: state.canHold && state.phase === 'playing' }
465}
466
467export const scoreOf = (state: GameState) => state.score
468export const levelOf = (state: GameState) => state.level
469export const linesOf = (state: GameState) => state.lines
470export const phaseOf = (state: GameState) => state.phase
471hooks/game/moves.ts 50 lines1import { isOpen } from './board'
2import type { Board } from './board'
3import { kicksFor, pieceCells } from './pieces'
4import type { Piece, Rotation } from './pieces'
5
6/** Whether every cell of the piece is open on the board. */
7export function fits(board: Board, piece: Piece): boolean {
8 return pieceCells(piece).every(({ x, y }) => isOpen(board, x, y))
9}
10
11/** The piece moved by (dx, dy), or null where it would not fit. */
12export function shifted(board: Board, piece: Piece, dx: number, dy: number): Piece | null {
13 const moved = { ...piece, x: piece.x + dx, y: piece.y + dy }
14
15 return fits(board, moved) ? moved : null
16}
17
18/** How many rows the piece can fall before it rests. */
19export function dropDistance(board: Board, piece: Piece): number {
20 let rows = 0
21 while (fits(board, { ...piece, y: piece.y + rows + 1 })) {
22 rows += 1
23 }
24
25 return rows
26}
27
28/** The piece turned a quarter (1 clockwise, -1 counterclockwise) at the first kick that fits, or null. */
29export function rotated(board: Board, piece: Piece, direction: 1 | -1): Piece | null {
30 const rotation = ((piece.rotation + direction + 4) % 4) as Rotation
31 for (const kick of kicksFor(piece.kind, piece.rotation, rotation)) {
32 const turned = { ...piece, rotation, x: piece.x + kick.x, y: piece.y + kick.y }
33 if (fits(board, turned)) {
34 return turned
35 }
36 }
37
38 return null
39}
40
41/** The board with the piece's cells locked as its kind. */
42export function placed(board: Board, piece: Piece): Board {
43 const rows = board.map(row => [...row])
44 for (const { x, y } of pieceCells(piece)) {
45 rows[y]![x] = piece.kind
46 }
47
48 return rows
49}
50