SLOPSHOPPER

slop-tetris

Tetris in a pane, played with letter hotkeys while the pane has focus. No wallet required.

newpanecommand
v0.1.0MITupdated 2026-10-07wobondar/claude-plugins/mods/slop-tetris
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · slop-tetris
│ ┃ Slop Tetris ✕ › fix the failing auth test and add an audit log call │ ┃ ▣ client module ./game.tsx │ ┃ a: ← d: → w: spin s: ↓ f: drop c: hold p: p ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /tetris │ ⎿ slop-tetris: Tetris is up. a/d move, w spins, s/f drop, c holds, │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Slop Tetris
▣ client module ./game.tsx a: ← d: → w: spin s: ↓ f: drop c: hold p: pause r: new
README

slop-tetris

Tetris in a Claude Code pane. No wallet, no economy, just blocks.

the pane: the well, hold and next, score, and the hotkey strip

/tetris opens the pane with the keyboard; /tetris close closes it. Esc hands the keys back to the prompt without closing the game, and the pane keeps running in the background.

Keys

The pane has to hold the keyboard (it does right after /tetris; ctrl+x tab or a click brings it back).

keydoes
a dmove
wrotate
ssoft drop, +1 a row
fhard drop, +2 a row
chold, once per piece
ppause
rnew game

Arrows walk the pane's focus ring, as in every pane; the letters are the controls.

Rules

10 by 20 well, 7-bag randomizer, three-piece preview, hold, ghost piece, full SRS rotation with wall kicks (the I piece has its own table), half a second of lock delay that resets on a move or a turn. Levels climb every ten lines and the fall speeds up on the guideline curve. A single pays 100, a double 300, a triple 500, a tetris 800, each times the level. The high score persists across sessions.

Integration

The game publishes to $.state under slop-tetris: live (phase, score, lines, level, pieces, whether the pane is open), lastGame (the last finished game) and highScore. Another plugin reads them, or hooks state.set on lastGame to react to a finished game. Tetris never reads anyone else's state.

Install

/plugin install slop-tetris --marketplace wobondar/claude-plugins

Answer y to add the marketplace, then pick a scope. To run from a checkout instead: claude --plugin-dir ./mods/slop-tetris.

Develop

claude plugin validate ./mods/slop-tetris
claude plugin test ./mods/slop-tetris

The pure game lives in hooks/tetris.ts, every move a function from a game to a game. hooks/game.tsx is a Client surface module: it owns the running game, ticks at 20 frames a second on surface.every, draws the well with coloured Text cells and posts a summary to the hooks module whenever the score, lines, level or phase change. hooks/register.tsx opens the pane, draws the control buttons under the Client and relays each press to the game as a numbered command in its props.

Source 4 files
hooks/register.tsx 143 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, ResolveInput } from 'claude-code'
3
4import type { TetrisCommand, TetrisGame, TetrisLive } from '../types'
5
6const PANE = 'slop-tetris'
7const PANE_ROWS = 24
8const PANE_COLUMNS = 48
9
10const idleLive = (): TetrisLive => ({ isOpen: false, phase: 'idle', score: 0, lines: 0, level: 1, pieces: 0 })
11
12const live = atom({ plugin: 'slop-tetris', key: 'live' } as const, idleLive())
13const lastGame = atom({ plugin: 'slop-tetris', key: 'lastGame' } as const, null as TetrisGame | null)
14const highScore = atom({ plugin: 'slop-tetris', key: 'highScore' } as const, 0)
15const command = atom({ plugin: 'slop-tetris', key: 'command' } as const, { seq: 0, action: 'pause' } as TetrisCommand)
16
17type Control = { readonly key: string; readonly hotkey: string; readonly label: string; readonly action: TetrisCommand['action'] }
18
19const CONTROLS: readonly Control[] = [
20  { key: 'left', hotkey: 'a', label: '←', action: 'left' },
21  { key: 'right', hotkey: 'd', label: '→', action: 'right' },
22  { key: 'cw', hotkey: 'w', label: 'spin', action: 'cw' },
23  { key: 'soft', hotkey: 's', label: '↓', action: 'soft' },
24  { key: 'hard', hotkey: 'f', label: 'drop', action: 'hard' },
25  { key: 'hold', hotkey: 'c', label: 'hold', action: 'hold' },
26  { key: 'pause', hotkey: 'p', label: 'pause', action: 'pause' },
27  { key: 'new', hotkey: 'r', label: 'new', action: 'new' },
28]
29
30const send = ($: EngineInterface, action: TetrisCommand['action']) => (): Promise<TetrisCommand> =>
31  update($, command, held => ({ seq: held.seq + 1, action }))
32
33type Summary = {
34  kind: 'tick' | 'gameOver'
35  score: number
36  lines: number
37  level: number
38  pieces: number
39  phase: 'playing' | 'paused' | 'over'
40}
41
42const isSummary = (data: unknown): data is Summary => {
43  if (typeof data !== 'object' || data === null) return false
44  const d = data as Record<string, unknown>
45  return (
46    (d.kind === 'tick' || d.kind === 'gameOver') &&
47    typeof d.score === 'number' && typeof d.lines === 'number' && typeof d.level === 'number' && typeof d.pieces === 'number' &&
48    (d.phase === 'playing' || d.phase === 'paused' || d.phase === 'over')
49  )
50}
51
52const loadBest = async ($: EngineInterface): Promise<number> => {
53  const stored = await $.store.get('highScore')
54  const best = typeof stored === 'number' ? stored : 0
55  await update($, highScore, () => best)
56  return best
57}
58
59const seedFrom = async ($: EngineInterface): Promise<number> => (Math.floor(await $.clock.now()) ^ 0x5bd1e995) >>> 0
60
61const draw = async ($: EngineInterface, e: ResolveInput) => {
62  const best = await read($, highScore)
63  if (e.surface !== 'terminal' && e.surface !== 'desktop') {
64    const { Box, Text } = $.ui.resolve(e)
65    return (
66      <Box flexDirection="column" paddingX={1}>
67        <Text bold>Slop Tetris</Text>
68        <Text dimColor>Runs in the terminal and the desktop app. Best so far: {best.toLocaleString('en-US')}.</Text>
69      </Box>
70    )
71  }
72  const { Box, Button, Client } = $.ui.resolve(e)
73  const seed = await seedFrom($)
74  const pending = await read($, command)
75  return (
76    <Box flexDirection="column" paddingX={1}>
77      <Client key="game" module="./game.tsx" props={{ seed, highScore: best, command: pending }} width={44} height={22} />
78      <Box flexDirection="row" columnGap={1}>
79        {CONTROLS.map(c => <Button key={c.key} hotkey={c.hotkey} plain onPress={send($, c.action)}>{c.label}</Button>)}
80      </Box>
81    </Box>
82  )
83}
84
85export const register: Register = on => {
86  on('session.start', async ($, e, next) => {
87    await loadBest($)
88    await update($, live, () => idleLive())
89    await $.command.register({
90      name: 'tetris',
91      description: 'Slop Tetris: open the game in a pane, or close it',
92      argumentHint: '[close]',
93      immediate: true,
94    })
95    return next(e)
96  })
97
98  on('command.run', { command: 'tetris' }, async ($, e) => {
99    const verb = e.args.trim()
100    if (verb === 'close') {
101      await $.ui.close({ id: PANE })
102      await update($, live, held => ({ ...held, isOpen: false }))
103      return { text: 'Tetris closed.' }
104    }
105    const opened = await $.ui.open({ id: PANE, title: 'Slop Tetris', focus: true, rows: PANE_ROWS, columns: PANE_COLUMNS })
106    if (!opened.isPlaced) return { text: 'Tetris is waiting for a wider terminal.' }
107    await update($, live, held => ({ ...held, isOpen: true }))
108    return { text: 'Tetris is up. a/d move, w spins, s/f drop, c holds, p pauses, r restarts. Esc hands the keys back to the prompt.' }
109  })
110
111  on('ui.close', { id: PANE }, async ($, e, next) => {
112    await update($, live, held => ({ ...held, isOpen: false }))
113    return next(e)
114  })
115
116  on('ui.render', { component: 'Pane', requestId: PANE }, ($, e) => draw($, e))
117
118  on('ui.message', async ($, e, next) => {
119    const data = e.data
120    if (!isSummary(data)) return next(e)
121    await update($, live, held => ({
122      ...held,
123      phase: data.phase,
124      score: data.score,
125      lines: data.lines,
126      level: data.level,
127      pieces: data.pieces,
128    }))
129    let best = await read($, highScore)
130    if (data.kind === 'gameOver') {
131      const endedAt = Math.floor(await $.clock.now())
132      await update($, lastGame, () => ({ score: data.score, lines: data.lines, level: data.level, pieces: data.pieces, endedAt }))
133      if (data.score > best) {
134        best = data.score
135        await update($, highScore, () => best)
136        await $.store.set('highScore', best)
137      }
138    }
139    await next(e)
140    return { props: { seed: await seedFrom($), highScore: best, command: await read($, command) } }
141  })
142}
143
hooks/game.tsx 234 lines
1import type { ClientKeyEvent, ClientModule, ClientSurface, RenderElement, RenderNode } from 'claude-code'
2
3import {
4  CLEAR_NAMES, COLS, PREVIEW, ROWS, act, cellsOf, createGame, ghost, nextSeed, tick,
5} from './tetris'
6import type { Action, Cell, Game, Phase, Piece, PieceKind } from './tetris'
7
8export type GameCommand = { readonly seq: number; readonly action: Action | 'new' }
9export type GameProps = { readonly seed: number; readonly highScore: number; readonly command?: GameCommand }
10
11export type GameSummary = {
12  readonly kind: 'tick' | 'gameOver'
13  readonly score: number
14  readonly lines: number
15  readonly level: number
16  readonly pieces: number
17  readonly phase: Phase
18}
19
20type State = {
21  game: Game
22  banner: string
23  bannerUntil: number
24  now: number
25}
26
27export const FRAME_MS = 50
28const BANNER_MS = 1200
29const WIDTH = 44
30
31const COLORS: Record<PieceKind, string> = {
32  I: '#00e5ff',
33  O: '#ffe600',
34  T: '#c74dff',
35  S: '#4cff5a',
36  Z: '#ff4b4b',
37  J: '#4d7cff',
38  L: '#ff9d1c',
39}
40
41const WELL = '#0b0b14'
42const FRAME = '#8a7cff'
43const GHOST = '#3a3a55'
44const INK = '#e6e6f0'
45const DIM = '#7a7a90'
46const GOLD = '#ffd700'
47
48const summarize = (g: Game): GameSummary => ({
49  kind: g.phase === 'over' ? 'gameOver' : 'tick', score: g.score, lines: g.lines, level: g.level, pieces: g.pieces, phase: g.phase,
50})
51
52const differs = (a: GameSummary | null, b: GameSummary): boolean =>
53  a === null || a.score !== b.score || a.lines !== b.lines || a.level !== b.level || a.phase !== b.phase || a.pieces !== b.pieces
54
55let lastSent: GameSummary | null = null
56
57const report = (surface: ClientSurface<State>, g: Game): void => {
58  const summary = summarize(g)
59  if (!differs(lastSent, summary)) return
60  lastSent = summary
61  surface.post(summary)
62}
63
64const commit = (surface: ClientSurface<State>, prev: State, game: Game, patch: Partial<State> = {}): void => {
65  let banner = prev.banner
66  let bannerUntil = prev.bannerUntil
67  if (game.pieces !== prev.game.pieces && game.lastClear > 0) {
68    banner = CLEAR_NAMES[game.lastClear] ?? ''
69    bannerUntil = prev.now + BANNER_MS
70  }
71  if (game.level > prev.game.level) {
72    banner = `LEVEL ${game.level}`
73    bannerUntil = prev.now + BANNER_MS
74  }
75  surface.setState({ ...prev, ...patch, game, banner, bannerUntil })
76}
77
78const ACTIONS: Record<string, Action> = {
79  left: 'left',
80  a: 'left',
81  right: 'right',
82  d: 'right',
83  up: 'cw',
84  w: 'cw',
85  x: 'cw',
86  z: 'ccw',
87  q: 'ccw',
88  e: 'flip',
89  down: 'soft',
90  s: 'soft',
91  ' ': 'hard',
92  space: 'hard',
93  f: 'hard',
94  c: 'hold',
95  p: 'pause',
96}
97
98const restart = (surface: ClientSurface<State>, s: State): void => {
99  surface.setState({ ...s, game: createGame(nextSeed(s.game)), banner: 'GO!', bannerUntil: s.now + BANNER_MS })
100}
101
102let lastSeq = 0
103
104const obey = (surface: ClientSurface<State>, s: State, command: GameCommand | undefined): void => {
105  if (command === undefined || command.seq === lastSeq) return
106  lastSeq = command.seq
107  if (command.action === 'new') return restart(surface, s)
108  commit(surface, s, act(s.game, command.action))
109}
110
111const onKey = (surface: ClientSurface<State>) => (e: ClientKeyEvent): void => {
112  const s = surface.state
113  if (s === undefined) return
114  const key = e.key.length === 1 ? e.key.toLowerCase() : e.key
115  if (key === 'r' || (s.game.phase === 'over' && key === 'return')) return restart(surface, s)
116  const action = ACTIONS[key]
117  if (action !== undefined) commit(surface, s, act(s.game, action))
118}
119
120const onTick = (surface: ClientSurface<State>) => (): void => {
121  const s = surface.state
122  if (s === undefined) return
123  const now = s.now + FRAME_MS
124  const game = tick(s.game, FRAME_MS)
125  commit(surface, { ...s, now }, game, s.banner !== '' && now >= s.bannerUntil ? { banner: '' } : {})
126}
127
128const boot = (props: GameProps, surface: ClientSurface<State>): State => {
129  const state: State = { game: createGame(props.seed), banner: 'GO!', bannerUntil: BANNER_MS, now: 0 }
130  lastSent = null
131  lastSeq = props.command?.seq ?? 0
132  surface.setState(state)
133  surface.every(FRAME_MS, onTick(surface))
134  surface.onKey(onKey(surface))
135  return state
136}
137
138type Grid = Array<Array<{ glyph: string; color: string }>>
139
140const buildGrid = (g: Game): Grid => {
141  const grid: Grid = g.board.map(row => row.map((c: Cell) => (c === null ? { glyph: '  ', color: WELL } : { glyph: '██', color: COLORS[c] })))
142  if (g.phase !== 'over') {
143    for (const [x, y] of cellsOf(ghost(g))) {
144      if (y >= 0 && y < ROWS && grid[y]?.[x]?.glyph === '  ') (grid[y] as Grid[number])[x] = { glyph: '░░', color: GHOST }
145    }
146    for (const [x, y] of cellsOf(g.current)) {
147      if (y >= 0 && y < ROWS) (grid[y] as Grid[number])[x] = { glyph: '██', color: COLORS[g.current.kind] }
148    }
149  }
150  return grid
151}
152
153const centered = (text: string, width: number): string => {
154  const pad = Math.max(0, width - text.length)
155  const left = Math.floor(pad / 2)
156  return ' '.repeat(left) + text + ' '.repeat(pad - left)
157}
158
159const miniPiece = (kind: PieceKind | null, elements: ClientSurface<State>['elements']): RenderElement[] => {
160  const { Box, Text } = elements
161  const rows: RenderElement[] = []
162  const occupied = new Set<string>()
163  if (kind !== null) {
164    const p: Piece = { kind, rotation: 0, x: 0, y: 0 }
165    for (const [x, y] of cellsOf(p)) occupied.add(`${x},${y}`)
166  }
167  const top = kind === 'I' ? 1 : 0
168  for (let y = top; y < top + 2; y++) {
169    const cells: RenderNode[] = []
170    for (let x = 0; x < 4; x++) {
171      const on = occupied.has(`${x},${y}`)
172      cells.push(<Text color={on && kind !== null ? COLORS[kind] : WELL}>{on ? '██' : '  '}</Text>)
173    }
174    rows.push(<Box flexDirection="row">{cells}</Box>)
175  }
176  return rows
177}
178
179const formatScore = (n: number): string => n.toLocaleString('en-US')
180
181const Tetris: ClientModule<GameProps, State> = (props, surface) => {
182  const s = surface.state ?? boot(props, surface)
183  const { Box, Text } = surface.elements
184  obey(surface, s, props.command)
185  const g = s.game
186  report(surface, g)
187  const grid = buildGrid(g)
188
189  const overlay: string[] =
190    g.phase === 'over' ? ['GAME OVER', formatScore(g.score), g.score > props.highScore && props.highScore > 0 ? 'NEW BEST' : '', 'r: again'] : []
191  const overlayTop = Math.floor((ROWS - overlay.length) / 2)
192  const status = g.phase === 'paused' ? 'PAUSED  p: resume' : g.phase === 'over' ? 'GAME OVER  r: new' : s.banner
193
194  const boardRows: RenderElement[] = grid.map((row, y) => {
195    const line = overlay[y - overlayTop]
196    if (line !== undefined) {
197      return (
198        <Box flexDirection="row">
199          <Text color="#ff4b4b" bold backgroundColor="#1a1a2e">{centered(line, COLS * 2)}</Text>
200        </Box>
201      )
202    }
203    return <Box flexDirection="row">{row.map(c => <Text color={c.color} backgroundColor={WELL}>{c.glyph}</Text>)}</Box>
204  })
205
206  const best = Math.max(props.highScore, g.score)
207
208  return (
209    <Box flexDirection="row" width={WIDTH} height={ROWS + 2}>
210      <Box flexDirection="column" borderStyle="double" borderColor={FRAME} width={COLS * 2 + 2}>
211        {boardRows}
212      </Box>
213      <Box flexDirection="column" paddingLeft={1} width={WIDTH - COLS * 2 - 2}>
214        <Text color={DIM}>HOLD{g.canHold ? '' : ' ·'}</Text>
215        {miniPiece(g.hold, surface.elements)}
216        <Text color={DIM}>NEXT</Text>
217        {g.queue.slice(0, PREVIEW).flatMap(k => miniPiece(k, surface.elements))}
218        <Box marginTop={1} flexDirection="column">
219          <Text color={DIM}>SCORE</Text>
220          <Text color={INK} bold>{formatScore(g.score)}</Text>
221          <Text color={DIM}>LINES <Text color={INK}>{g.lines}</Text>  LVL <Text color={INK}>{g.level}</Text></Text>
222          <Text color={DIM}>BEST  <Text color={best === g.score && g.score > 0 ? GOLD : INK}>{formatScore(best)}</Text></Text>
223        </Box>
224        <Box marginTop={1} flexDirection="column">
225          <Text color={GOLD} bold>{status === '' ? ' ' : status}</Text>
226          <Text color={DIM}>esc: back to prompt</Text>
227        </Box>
228      </Box>
229    </Box>
230  )
231}
232
233export default Tetris
234
hooks/tetris.ts 336 lines
1export const COLS = 10
2export const ROWS = 20
3export const PREVIEW = 3
4export const LOCK_MS = 500
5
6export type PieceKind = 'I' | 'O' | 'T' | 'S' | 'Z' | 'J' | 'L'
7export const KINDS: readonly PieceKind[] = ['I', 'O', 'T', 'S', 'Z', 'J', 'L']
8
9export type Rotation = 0 | 1 | 2 | 3
10export type Cell = PieceKind | null
11export type Row = readonly Cell[]
12export type Board = readonly Row[]
13
14export type Piece = { kind: PieceKind; rotation: Rotation; x: number; y: number }
15
16export type Phase = 'playing' | 'paused' | 'over'
17
18export type Game = {
19  board: Board
20  current: Piece
21  queue: readonly PieceKind[]
22  bag: readonly PieceKind[]
23  hold: PieceKind | null
24  canHold: boolean
25  seed: number
26  score: number
27  lines: number
28  level: number
29  phase: Phase
30  groundedMs: number
31  fallMs: number
32  lastClear: number
33  pieces: number
34}
35
36type Offset = readonly [x: number, y: number]
37type Shape = readonly [Offset, Offset, Offset, Offset]
38
39const SHAPES: Record<PieceKind, readonly [Shape, Shape, Shape, Shape]> = {
40  I: [
41    [[0, 1], [1, 1], [2, 1], [3, 1]],
42    [[2, 0], [2, 1], [2, 2], [2, 3]],
43    [[0, 2], [1, 2], [2, 2], [3, 2]],
44    [[1, 0], [1, 1], [1, 2], [1, 3]],
45  ],
46  O: [
47    [[1, 0], [2, 0], [1, 1], [2, 1]],
48    [[1, 0], [2, 0], [1, 1], [2, 1]],
49    [[1, 0], [2, 0], [1, 1], [2, 1]],
50    [[1, 0], [2, 0], [1, 1], [2, 1]],
51  ],
52  T: [
53    [[1, 0], [0, 1], [1, 1], [2, 1]],
54    [[1, 0], [1, 1], [2, 1], [1, 2]],
55    [[0, 1], [1, 1], [2, 1], [1, 2]],
56    [[1, 0], [0, 1], [1, 1], [1, 2]],
57  ],
58  S: [
59    [[1, 0], [2, 0], [0, 1], [1, 1]],
60    [[1, 0], [1, 1], [2, 1], [2, 2]],
61    [[1, 1], [2, 1], [0, 2], [1, 2]],
62    [[0, 0], [0, 1], [1, 1], [1, 2]],
63  ],
64  Z: [
65    [[0, 0], [1, 0], [1, 1], [2, 1]],
66    [[2, 0], [1, 1], [2, 1], [1, 2]],
67    [[0, 1], [1, 1], [1, 2], [2, 2]],
68    [[1, 0], [0, 1], [1, 1], [0, 2]],
69  ],
70  J: [
71    [[0, 0], [0, 1], [1, 1], [2, 1]],
72    [[1, 0], [2, 0], [1, 1], [1, 2]],
73    [[0, 1], [1, 1], [2, 1], [2, 2]],
74    [[1, 0], [1, 1], [0, 2], [1, 2]],
75  ],
76  L: [
77    [[2, 0], [0, 1], [1, 1], [2, 1]],
78    [[1, 0], [1, 1], [1, 2], [2, 2]],
79    [[0, 1], [1, 1], [2, 1], [0, 2]],
80    [[0, 0], [1, 0], [1, 1], [1, 2]],
81  ],
82}
83
84// SRS kick tables, y positive downwards (the guideline's tables are y-up, flipped here).
85type Kicks = Record<`${Rotation}${Rotation}`, readonly Offset[]>
86
87const KICKS: Kicks = {
88  '01': [[0, 0], [-1, 0], [-1, -1], [0, 2], [-1, 2]],
89  '10': [[0, 0], [1, 0], [1, 1], [0, -2], [1, -2]],
90  '12': [[0, 0], [1, 0], [1, 1], [0, -2], [1, -2]],
91  '21': [[0, 0], [-1, 0], [-1, -1], [0, 2], [-1, 2]],
92  '23': [[0, 0], [1, 0], [1, -1], [0, 2], [1, 2]],
93  '32': [[0, 0], [-1, 0], [-1, 1], [0, -2], [-1, -2]],
94  '30': [[0, 0], [-1, 0], [-1, 1], [0, -2], [-1, -2]],
95  '03': [[0, 0], [1, 0], [1, -1], [0, 2], [1, 2]],
96  '00': [[0, 0]], '11': [[0, 0]], '22': [[0, 0]], '33': [[0, 0]],
97  '02': [[0, 0], [0, -1], [1, -1], [-1, -1], [1, 0], [-1, 0]],
98  '20': [[0, 0], [0, 1], [-1, 1], [1, 1], [-1, 0], [1, 0]],
99  '13': [[0, 0], [1, 0], [1, -2], [1, -1], [0, -2], [0, -1]],
100  '31': [[0, 0], [-1, 0], [-1, -2], [-1, -1], [0, -2], [0, -1]],
101}
102
103const I_KICKS: Kicks = {
104  '01': [[0, 0], [-2, 0], [1, 0], [-2, 1], [1, -2]],
105  '10': [[0, 0], [2, 0], [-1, 0], [2, -1], [-1, 2]],
106  '12': [[0, 0], [-1, 0], [2, 0], [-1, -2], [2, 1]],
107  '21': [[0, 0], [1, 0], [-2, 0], [1, 2], [-2, -1]],
108  '23': [[0, 0], [2, 0], [-1, 0], [2, -1], [-1, 2]],
109  '32': [[0, 0], [-2, 0], [1, 0], [-2, 1], [1, -2]],
110  '30': [[0, 0], [1, 0], [-2, 0], [1, 2], [-2, -1]],
111  '03': [[0, 0], [-1, 0], [2, 0], [-1, -2], [2, 1]],
112  '00': [[0, 0]], '11': [[0, 0]], '22': [[0, 0]], '33': [[0, 0]],
113  '02': [[0, 0], [0, -1], [0, 1]],
114  '20': [[0, 0], [0, 1], [0, -1]],
115  '13': [[0, 0], [1, 0], [-1, 0]],
116  '31': [[0, 0], [-1, 0], [1, 0]],
117}
118
119export const cellsOf = (p: Piece): Offset[] => SHAPES[p.kind][p.rotation].map(([dx, dy]) => [p.x + dx, p.y + dy] as const)
120
121export const emptyBoard = (): Board => Array.from({ length: ROWS }, () => Array.from({ length: COLS }, (): Cell => null))
122
123export const collides = (board: Board, p: Piece): boolean =>
124  cellsOf(p).some(([x, y]) => x < 0 || x >= COLS || y >= ROWS || (y >= 0 && board[y]?.[x] !== null))
125
126const nextRandom = (seed: number): number => {
127  let s = seed >>> 0
128  s ^= s << 13
129  s >>>= 0
130  s ^= s >>> 17
131  s ^= s << 5
132  return s >>> 0
133}
134
135const shuffledBag = (seed: number): { bag: PieceKind[]; seed: number } => {
136  const bag = [...KINDS]
137  let s = seed
138  for (let i = bag.length - 1; i > 0; i--) {
139    s = nextRandom(s)
140    const j = s % (i + 1)
141    const a = bag[i] as PieceKind
142    bag[i] = bag[j] as PieceKind
143    bag[j] = a
144  }
145  return { bag, seed: s }
146}
147
148const draw = (bag: readonly PieceKind[], seed: number, count: number): { drawn: PieceKind[]; bag: PieceKind[]; seed: number } => {
149  let pool = [...bag]
150  let s = seed
151  const drawn: PieceKind[] = []
152  while (drawn.length < count) {
153    if (pool.length === 0) {
154      const refill = shuffledBag(s)
155      pool = refill.bag
156      s = refill.seed
157    }
158    drawn.push(pool.shift() as PieceKind)
159  }
160  return { drawn, bag: pool, seed: s }
161}
162
163export const spawn = (kind: PieceKind): Piece => ({ kind, rotation: 0, x: 3, y: kind === 'I' ? -1 : 0 })
164
165export const createGame = (seed: number): Game => {
166  const first = draw([], seed || 0x9e3779b9, PREVIEW + 1)
167  const [kind, ...queue] = first.drawn
168  return {
169    board: emptyBoard(),
170    current: spawn(kind as PieceKind),
171    queue,
172    bag: first.bag,
173    hold: null,
174    canHold: true,
175    seed: first.seed,
176    score: 0,
177    lines: 0,
178    level: 1,
179    phase: 'playing',
180    groundedMs: 0,
181    fallMs: 0,
182    lastClear: 0,
183    pieces: 1,
184  }
185}
186
187export const gravityMs = (level: number): number => {
188  const n = Math.min(level, 20) - 1
189  return Math.max(16, Math.round(1000 * Math.pow(0.8 - n * 0.007, n)))
190}
191
192export const levelFor = (lines: number): number => Math.floor(lines / 10) + 1
193
194const LINE_SCORE = [0, 100, 300, 500, 800] as const
195
196const isGrounded = (g: Game): boolean => collides(g.board, { ...g.current, y: g.current.y + 1 })
197
198const shifted = (g: Game, dx: number, dy: number): Game | null => {
199  const moved = { ...g.current, x: g.current.x + dx, y: g.current.y + dy }
200  return collides(g.board, moved) ? null : { ...g, current: moved, groundedMs: 0 }
201}
202
203export const moveLeft = (g: Game): Game => (g.phase !== 'playing' ? g : shifted(g, -1, 0) ?? g)
204export const moveRight = (g: Game): Game => (g.phase !== 'playing' ? g : shifted(g, 1, 0) ?? g)
205
206export const rotate = (g: Game, dir: 1 | -1 | 2): Game => {
207  if (g.phase !== 'playing' || g.current.kind === 'O') return g
208  const from = g.current.rotation
209  const to = (((from + dir) % 4) + 4) % 4 as Rotation
210  const table = g.current.kind === 'I' ? I_KICKS : KICKS
211  for (const [kx, ky] of table[`${from}${to}`]) {
212    const candidate: Piece = { ...g.current, rotation: to, x: g.current.x + kx, y: g.current.y + ky }
213    if (!collides(g.board, candidate)) return { ...g, current: candidate, groundedMs: 0 }
214  }
215  return g
216}
217
218const place = (board: Board, p: Piece): Board => {
219  const rows = board.map(r => [...r])
220  for (const [x, y] of cellsOf(p)) {
221    if (y >= 0) (rows[y] as Cell[])[x] = p.kind
222  }
223  return rows
224}
225
226const sweep = (board: Board): { board: Board; cleared: number } => {
227  const kept = board.filter(r => r.some(c => c === null))
228  const cleared = ROWS - kept.length
229  const fresh = Array.from({ length: cleared }, () => Array.from({ length: COLS }, (): Cell => null))
230  return { board: [...fresh, ...kept], cleared }
231}
232
233export const lock = (g: Game): Game => {
234  const placed = place(g.board, g.current)
235  const { board, cleared } = sweep(placed)
236  const lines = g.lines + cleared
237  const level = levelFor(lines)
238  const score = g.score + (LINE_SCORE[cleared] ?? 1200) * g.level
239  const pulled = draw(g.bag, g.seed, 1)
240  const [kind] = g.queue
241  const queue = [...g.queue.slice(1), ...pulled.drawn]
242  const current = spawn(kind as PieceKind)
243  const over = collides(board, current) || cellsOf(g.current).every(([, y]) => y < 0)
244  return {
245    ...g,
246    board,
247    current,
248    queue,
249    bag: pulled.bag,
250    seed: pulled.seed,
251    canHold: true,
252    score,
253    lines,
254    level,
255    phase: over ? 'over' : 'playing',
256    groundedMs: 0,
257    fallMs: 0,
258    lastClear: cleared,
259    pieces: g.pieces + 1,
260  }
261}
262
263export const softDrop = (g: Game): Game => {
264  if (g.phase !== 'playing') return g
265  const down = shifted(g, 0, 1)
266  return down ? { ...down, score: down.score + 1 } : g
267}
268
269export const dropDistance = (g: Game): number => {
270  let d = 0
271  while (!collides(g.board, { ...g.current, y: g.current.y + d + 1 })) d++
272  return d
273}
274
275export const ghost = (g: Game): Piece => ({ ...g.current, y: g.current.y + dropDistance(g) })
276
277export const hardDrop = (g: Game): Game => {
278  if (g.phase !== 'playing') return g
279  const d = dropDistance(g)
280  return lock({ ...g, current: { ...g.current, y: g.current.y + d }, score: g.score + d * 2 })
281}
282
283export const holdPiece = (g: Game): Game => {
284  if (g.phase !== 'playing' || !g.canHold) return g
285  const held = g.current.kind
286  if (g.hold === null) {
287    const pulled = draw(g.bag, g.seed, 1)
288    const [kind] = g.queue
289    const current = spawn(kind as PieceKind)
290    return { ...g, hold: held, canHold: false, current, queue: [...g.queue.slice(1), ...pulled.drawn], bag: pulled.bag, seed: pulled.seed, groundedMs: 0, fallMs: 0, phase: collides(g.board, current) ? 'over' : 'playing' }
291  }
292  const current = spawn(g.hold)
293  return { ...g, hold: held, canHold: false, current, groundedMs: 0, fallMs: 0, phase: collides(g.board, current) ? 'over' : 'playing' }
294}
295
296export const togglePause = (g: Game): Game =>
297  g.phase === 'playing' ? { ...g, phase: 'paused' } : g.phase === 'paused' ? { ...g, phase: 'playing' } : g
298
299export const tick = (g: Game, dtMs: number): Game => {
300  if (g.phase !== 'playing') return g
301  if (isGrounded(g)) {
302    const groundedMs = g.groundedMs + dtMs
303    return groundedMs >= LOCK_MS ? lock(g) : { ...g, groundedMs, fallMs: 0 }
304  }
305  let fallMs = g.fallMs + dtMs
306  const step = gravityMs(g.level)
307  let out: Game = g
308  while (fallMs >= step) {
309    fallMs -= step
310    const down = shifted(out, 0, 1)
311    if (!down) break
312    out = down
313  }
314  return { ...out, fallMs, groundedMs: 0 }
315}
316
317export type Action = 'left' | 'right' | 'cw' | 'ccw' | 'flip' | 'soft' | 'hard' | 'hold' | 'pause'
318
319export const act = (g: Game, action: Action): Game => {
320  switch (action) {
321    case 'left': return moveLeft(g)
322    case 'right': return moveRight(g)
323    case 'cw': return rotate(g, 1)
324    case 'ccw': return rotate(g, -1)
325    case 'flip': return rotate(g, 2)
326    case 'soft': return softDrop(g)
327    case 'hard': return hardDrop(g)
328    case 'hold': return holdPiece(g)
329    case 'pause': return togglePause(g)
330  }
331}
332
333export const nextSeed = (g: Game): number => nextRandom(g.seed ^ (g.score + 1) ^ (g.pieces << 8))
334
335export const CLEAR_NAMES = ['', 'single', 'double', 'triple', 'TETRIS'] as const
336
types/index.d.ts 35 lines
1export type TetrisPhase = 'idle' | 'playing' | 'paused' | 'over'
2
3export type TetrisLive = {
4  isOpen: boolean
5  phase: TetrisPhase
6  score: number
7  lines: number
8  level: number
9  pieces: number
10}
11
12export type TetrisGame = {
13  score: number
14  lines: number
15  level: number
16  pieces: number
17  endedAt: number
18}
19
20export type TetrisCommand = {
21  seq: number
22  action: 'left' | 'right' | 'cw' | 'ccw' | 'flip' | 'soft' | 'hard' | 'hold' | 'pause' | 'new'
23}
24
25declare module 'claude-code' {
26  interface PluginState {
27    'slop-tetris': {
28      live: TetrisLive
29      lastGame: TetrisGame | null
30      highScore: number
31      command: TetrisCommand
32    }
33  }
34}
35