SLOPSHOPPER

Line Clear

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

newpanecommand
v0.1.0MITupdated 2026-10-08ilovepixelart/line-clear-mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · line-clear
│ ┃ line-clear ✕ › fix the failing auth test and add an audit log call │ ┃ ▣ client module ./clients/well.ts │ ┃ a: left d: right w: turn q: turn bac ⏺ Read(src/auth.ts) │ ┃ s: down x: drop c: hold p: pause ⎿ 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 │ │ › /line-clear │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · line-clear
▣ client module ./clients/well.ts a: left d: right w: turn q: turn back s: down x: drop c: hold p: pause
README

line-clear

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.

line-clear in a Claude Code pane, played while Claude makes an orders import idempotent: pieces lock, rows clear with single and combo callouts, the level rises to 2 and the best score updates

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.

How to play

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.

  • Click the well. Arrows, Space and the letters below go to the game. Escape gives the keys back to the prompt.
  • Keyboard only: ctrl+x tab. The pane takes the focus and its Buttons' hotkeys play: a d w q s x c p. Escape gives the keys back.
MoveClicked wellPane hotkey
left, rightleft, right arrow, or a, da, d
slide to the wallshift with left or right, or A, D
turn clockwiseup arrow or ww
turn backz or qq
soft dropdown arrow or ss
hard dropSpace or xx
holdcc
pausepp

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.

Why click to play, and why the keyboard path has no arrows

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.

Mid-turn, watch the prompt

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.

Settings

SettingDefaultWhat it does
openOnTurnoffOpens 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).
startLevel1The 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.

Privacy

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

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.

  • Well. 10 columns by 18 visible rows, with 4 hidden rows above.
  • Entering. A piece spawns flat in the two hidden rows just above the well, centred (the 3-wide ones left of centre), and drops one row at once when there is room, so it shows straight away. A lock that clears nothing brings the next piece on at once.
  • Pieces. Seven four-cell pieces, dealt from a 7-bag (each run of seven holds one of each) shuffled by a seeded generator. The next five are shown.
  • Turning. Super Rotation System states and wall kicks, from its published kick table (hooks/game/pieces.ts). The square never kicks.
  • Hold. Once per piece; allowed again when a piece locks.
  • Clearing. A lock that clears rows holds the next piece back for 200 ms while the rows go. Moves and drops made meanwhile are dropped; the last turn and a hold are done as the next piece enters.
  • Gravity. (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.
  • Lock delay. A resting piece locks after 500 ms. Each of the first 15 moves or turns made while resting restarts the delay, and the 16th locks the piece at once; reaching a new lowest row with any cell restores all 15.
  • Score. A lock that clears 1, 2, 3 or 4 rows scores 100, 300, 500 or 800 times the level it was made at. A soft drop scores 1 a row, a hard drop 2.
  • Spins. A T whose last successful move was a turn, locking with three of the four cells diagonal to its centre taken (walls and floor count), spins: both cells on its pointing side taken, or a turn whose kick moved it one across and two rows, is a spin, else a mini. A spin scores 400, 800, 1200 or 1600 for none to three rows, a mini 100, 200 or 400 for none to two, times the level.
  • Back to back. A difficult clear (four rows at once, or a spin that clears rows) right after another scores half again. A single, double or triple ends the run; a lock that clears nothing does not.
  • Combos. Each clear right after another adds 50 times the run's count times the level: the second clear in a row is combo 1, the third combo 2. A lock that clears nothing ends the run.
  • All clear. A clear that empties the well adds 800, 1200, 1800 or 2000 for one to four rows, 3200 for a back to back four, times the level, on top of the clear's own points.
  • Levels. Fixed goal: up one every 10 cleared rows from the start level, called out as it happens. The game is endless; gravity stops speeding up at level 15.
  • Game over. Block out (the next piece has no room to enter) or lock out (a piece locks with every cell in the hidden rows).
  • Pause. Freezes gravity and the lock delay; ignores every other input. The board stays in view, dimmed, and the status line reads 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:

  • Key remapping. The letters are fixed; on AZERTY and other layouts use the arrows on the clicked well.
  • Ambiguous-width terminals. The blocks and box lines are East Asian ambiguous-width characters, which most terminals draw one column wide; a terminal set to draw them two wide breaks the layout.
  • Delayed auto shift settings. A held key repeats at your system's key repeat rate, not at tunable game rates.
  • A Marathon end, garbage and versus. One endless solo game.

Install

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.

Versioning

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.

Access

What claude plugin validate . reports the module hooks and calls:

  • hooks: session.start (registers /line-clear), command.run (line-clear), turn.start (only with openOnTurn on), ui.message and ui.render (its own pane only);
  • calls: $.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.

Development

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.

License

MIT

Source 18 files
hooks/register.tsx 99 lines
1import { 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}
99
hooks/play/index.ts 7 lines
1export * from './keys'
2export * from './palette'
3export * from './play'
4export * from './view'
5
6export * as default from '.'
7
hooks/clients/well.ts 69 lines
1import 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
69
hooks/play/keys.ts 125 lines
1import 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}
125
hooks/play/palette.ts 70 lines
1import 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>
70
hooks/play/play.ts 193 lines
1import 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}
193
hooks/play/view.ts 368 lines
1import 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}
368
hooks/game/index.ts 12 lines
1export * 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 '.'
12
hooks/game/bag.ts 20 lines
1import { 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}
20
hooks/game/board.ts 38 lines
1import 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}
38
hooks/game/game.ts 471 lines
1import { 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
471
hooks/game/moves.ts 50 lines
1import { 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