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

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

/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.
The pane has to hold the keyboard (it does right after /tetris; ctrl+x tab or a click brings it back).
| key | does |
|---|---|
a d | move |
w | rotate |
s | soft drop, +1 a row |
f | hard drop, +2 a row |
c | hold, once per piece |
p | pause |
r | new game |
Arrows walk the pane's focus ring, as in every pane; the letters are the controls.
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.
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.
/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.
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.
hooks/register.tsx 143 lines1import { 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}
143hooks/game.tsx 234 lines1import 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
234hooks/tetris.ts 336 lines1export 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
336types/index.d.ts 35 lines1export 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