Tetris above the Claude Code prompt: fit the falling pieces and clear lines. Plays while Claude works, pauses when the turn ends, keeps your best score; zero…

Tetris above the Claude Code prompt, for the minutes Claude spends working: fit the falling pieces and clear lines. The game pauses and its status line says so when Claude finishes the turn, so you never miss a reply. Playing costs no tokens: the mod answers every key and click itself.
A single game split out of sezaakgun/cc-arcade (MIT, by Seza Akgün). Want all nine games, a picker and the pet in one plugin? Install
games/cc-arcadeinstead.
/tetris opens the board above the prompt (/tetris stop or the close button closes it).x rotates, ↓ drops one row, Space drops to the bottom, p pauses, r restarts. The pieces fall faster every ten lines.Your best score is kept in the plugin's store across sessions.
npx claude-code-templates@latest --mod games/tetris
claude
It is written to .claude/skills/tetris/, which Claude Code auto-loads as tetris@skills-dir. For one session with hot reload: claude --plugin-dir .claude/skills/tetris.
claude -p, the desktop app or mobile.Clones of the genre, not affiliated with or endorsed by the original publishers.
hooks/register.tsx 95 lines1/* @jsx h */
2import type { Register } from 'claude-code'
3import { isBetter } from './games/best.ts'
4
5// Tetris above the Claude Code prompt, split out of cc-arcade (https://github.com/sezaakgun/cc-arcade,
6// MIT, by Seza Akgün) as a single-game mod. `/tetris` opens it, `/tetris stop` closes it. The board in
7// ./boards is a surface module that runs on the drawing thread; this module mounts it, keeps the best
8// score in $.store and pauses the game when Claude finishes a turn. Playing costs no tokens.
9
10const GAME = 'tetris' // what the board posts its score under
11const TITLE = 'Tetris'
12const LOWER_IS_BETTER = false
13const UNIT = ''
14
15let open = false
16// bumped at each finished turn; the open game pauses when it sees a new value
17let turnsDone = 0
18let best = 0
19
20export const register: Register = on => {
21 on('session.start', async ($, e, next) => {
22 const r = await next(e)
23 // a store that cannot be read costs the best score, never the game
24 const score = Number(await $.store.get('best').catch(() => undefined))
25 if (score) best = score
26 await $.command.register({
27 name: 'tetris',
28 description: `$Tetris above the prompt, while Claude works (stop closes)`,
29 argumentHint: '[stop]',
30 immediate: true,
31 }).catch(err => $.ui.log(`tetris: /tetris not registered: ${err}`))
32 return r
33 })
34
35 on('command.run', { command: 'tetris' }, async ($, e) => {
36 const arg = e.args.trim().toLowerCase()
37 if (arg === 'stop' || arg === 'close') {
38 open = false
39 $.ui.invalidate('ui.render')
40 return { text: `$Tetris closed` }
41 }
42 open = true
43 $.ui.invalidate('ui.render')
44 const score = best ? ` · best ${best}$` : ''
45 return { text: `$Tetris · click the board to play · Esc returns to the prompt · /tetris stop closes${score}` }
46 })
47
48 on('turn.complete', async ($, e, next) => {
49 const r = await next(e)
50 // a subagent's turn is not the one the person is waiting for
51 if (e.agentId) return r
52 if (open) {
53 turnsDone++
54 $.ui.invalidate('ui.render')
55 }
56 return r
57 })
58
59 // a finished round, posted by the board: keep the best score and hand it back with the next props
60 on('ui.message', async ($, e, next) => {
61 const data = e.data as { game?: unknown; score?: unknown } | null
62 if (data?.game !== GAME || typeof data.score !== 'number') return next(e)
63 if (isBetter(data.score, best || undefined, LOWER_IS_BETTER)) {
64 best = data.score
65 await $.store.set('best', best).catch(err => $.ui.log(`tetris: store write failed: ${err}`))
66 $.ui.toast(`$Tetris: new best ${best}$`)
67 }
68 return { props: { best, done: turnsDone, safe: false } }
69 })
70
71 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
72 // the board needs a terminal's keys and mouse; the desktop and mobile surfaces draw their own band
73 if (!open || e.props.hasSurvey || e.surface !== 'terminal') return next(e)
74 const { Box, Button, Client } = $.ui.resolve(e)
75 // the band's own width: the transcript column's while a Pane is docked beside it
76 const cols = e.props.bodyColumns || (e.viewport?.columns ?? 80)
77 // one row for the close button, one for whatever else draws in the band
78 const rows = Math.max(8, e.props.maxRows - 2)
79 const tall = (max: number) => Math.min(rows, max)
80 const close = () => {
81 open = false
82 $.ui.invalidate('ui.render')
83 }
84 return (
85 <Box flexDirection="column">
86 <Box flexDirection="row" columnGap={1}>
87 <Button key="tetris:close" label="close" onPress={close} />
88 </Box>
89 <Client key="board:tetris" module="./boards/tetris.tsx" width={cols} height={tall(23)} props={{ best, done: turnsDone, safe: false }} />
90 {await next(e)}
91 </Box>
92 )
93 })
94}
95hooks/games/best.ts 10 lines1// Which score counts as a new best. Minesweeper's best is its fastest clear, so lower wins there;
2// everywhere else higher wins. A score of zero or less never counts: a Minesweeper board whose
3// first click clears it takes 0 s and would otherwise lock the best forever, and a Pong match you
4// lost posts a negative margin.
5export function isBetter(score: number, prev: number | undefined, lowerIsBetter = false): boolean {
6 if (!Number.isFinite(score) || score <= 0) return false
7 if (prev === undefined || prev <= 0) return true
8 return lowerIsBetter ? score < prev : score > prev
9}
10hooks/boards/tetris.tsx 75 lines1/* @jsx h */
2import type { ClientSurface } from 'claude-code'
3import { base, claudeDone, grid, statusLine, type Base, type Props } from './common.tsx'
4import { cells, drop, dropEvery, fall, NAMES, newTetris, rotate, shift, type TetrisGame } from '../games/tetris.ts'
5
6const COLORS = ['cyan', 'yellow', 'magenta', 'green', 'red', 'blue', '#ff8800']
7type State = Base & { game?: TetrisGame }
8
9export default function Tetris(props: Props, surface: ClientSurface<State>) {
10 const { Box, Text } = surface.elements
11 const boardRows = () => Math.min(20, surface.rows - 3)
12 // ten columns wide, less on a terminal too narrow for the board
13 const boardCols = () => Math.max(6, Math.min(10, Math.floor((surface.columns - 2) / 2)))
14
15 if (surface.state === undefined) {
16 surface.setState(base(props))
17 let ticks = 0
18 surface.every(100, () => {
19 const s = surface.state
20 if (!s?.game || !s.playing || s.game.over) return
21 if (++ticks < dropEvery(s.game.lines)) return
22 ticks = 0
23 const game = fall(s.game)
24 if (game.over) surface.post({ game: 'tetris', score: game.score })
25 surface.setState({ ...s, game })
26 })
27 surface.onKey(({ key }) => {
28 const s = surface.state
29 if (!s) return
30 // the space bar may arrive as the character or by name
31 const k = key === 'space' ? ' ' : key.toLowerCase()
32 if (!s.game || s.game.over || k === 'r') {
33 if (boardRows() < 6) return
34 surface.setState({ ...s, banner: false, playing: true, game: newTetris(boardCols(), boardRows()) })
35 return
36 }
37 if (k === 'p') {
38 surface.setState({ ...s, banner: false, playing: !s.playing })
39 return
40 }
41 const g = s.game
42 const game = k === 'left' || k === 'a' ? shift(g, -1)
43 : k === 'right' || k === 'd' ? shift(g, 1)
44 : k === 'up' || k === 'w' || k === 'x' ? rotate(g)
45 : k === 'down' || k === 's' ? fall(g)
46 : k === ' ' ? drop(g)
47 : undefined
48 if (!game) return
49 if (game.over) surface.post({ game: 'tetris', score: game.score })
50 surface.setState({ ...s, banner: false, playing: true, game })
51 })
52 }
53 claudeDone(props, surface)
54
55 const s = surface.state
56 const g = s?.game
57 const bw = g?.w ?? boardCols()
58 const bh = g?.h ?? boardRows()
59 const falling = new Map((g && !g.over ? cells(g.piece) : []).map(([x, y]) => [`${x},${y}`, g!.piece.kind]))
60 const rows = grid(Text, bw, bh, (x, y) => {
61 const kind = falling.get(`${x},${y}`) ?? (g?.board[y]?.[x] ?? 0) - 1
62 return kind >= 0 ? ['██', COLORS[kind]] : [' ·', 'gray']
63 })
64 const text = !g ? 'tetris · click here, then any key · ←→ move · ↑ rotate · ↓ soft drop · space drop · p pause'
65 : g.over ? `tetris · game over · score ${g.score} · lines ${g.lines} · r plays again`
66 : !s?.playing ? `tetris · paused · score ${g.score} · p resumes`
67 : `tetris · score ${g.score} · lines ${g.lines} · next ${NAMES[g.next]} · p pause`
68 return (
69 <Box flexDirection="column">
70 <Box flexDirection="column" borderStyle="round" borderColor={g?.over ? 'red' : 'cyan'} width={bw * 2 + 2}>{rows}</Box>
71 {statusLine(Text, !!s?.banner, `${text} · best ${props?.best ?? 0}`)}
72 </Box>
73 )
74}
75hooks/boards/common.tsx 61 lines1/* @jsx h */
2import type { ClientElements, ClientSurface } from 'claude-code'
3
4// Shared by every board. A board is a surface module: it runs on the drawing thread with its own
5// frame clock, keys (after a click gives it focus; Esc gives the prompt back) and mouse.
6//
7// Never name a local `h` in a board file: every JSX tag compiles to a call of `h`.
8
9export type Props = { best?: number; done?: number; safe?: boolean } | undefined
10export type Base = { playing: boolean; banner: boolean; seenDone: number }
11export type Paint = readonly [glyph: string, color?: string, bg?: string]
12type TextTag = ClientElements['Text']
13
14export const base = (props: Props): Base => ({ playing: false, banner: false, seenDone: props?.done ?? 0 })
15
16// the hooks module bumps props.done when Claude finishes a turn: pause and say so, once per turn
17export function claudeDone<S extends Base>(props: Props, surface: ClientSurface<S>) {
18 const s = surface.state
19 const done = props?.done ?? 0
20 if (s && done !== s.seenDone) surface.setState({ ...s, seenDone: done, banner: true, playing: false })
21}
22
23export function statusLine(Text: TextTag, banner: boolean, text: string) {
24 return banner
25 ? <Text color="yellow" bold wrap="truncate-end">{`● Claude is done · ${text}`}</Text>
26 : <Text dimColor wrap="truncate-end">{text}</Text>
27}
28
29export type Run = [text: string, color?: string, bg?: string]
30
31// one run per stretch of cells sharing both colors. A drawing the engine cannot fit in its node
32// budget is worse than a coarser one, so maxRuns folds neighbours together, pair by pair, until
33// the row fits: a merged pair keeps the left one's colors and every glyph.
34export function runs(cols: number, y: number, at: (x: number, y: number) => Paint, maxRuns = Infinity): Run[] {
35 let out: Run[] = []
36 for (let x = 0; x < cols; x++) {
37 const [glyph, color, bg] = at(x, y)
38 const last = out[out.length - 1]
39 if (last && last[1] === color && last[2] === bg) last[0] += glyph
40 else out.push([glyph, color, bg])
41 }
42 while (out.length > Math.max(1, maxRuns)) {
43 const folded: Run[] = []
44 for (let i = 0; i < out.length; i += 2) {
45 const [t, c, b] = out[i]
46 const next = out[i + 1]
47 folded.push(next ? [t + next[0], c, b] : [t, c, b])
48 }
49 out = folded
50 }
51 return out
52}
53
54// a board as rows of two-character cells (a terminal cell is about twice as tall as wide, so two
55// make a square), one styled span per run of the same colors
56export function grid(Text: TextTag, cols: number, rowCount: number, at: (x: number, y: number) => Paint, maxRuns = Infinity) {
57 return Array.from({ length: Math.max(0, rowCount) }, (_, y) => (
58 <Text>{runs(cols, y, at, maxRuns).map(([t, c, b]) => (b ? <Text color={c} backgroundColor={b}>{t}</Text> : <Text color={c}>{t}</Text>))}</Text>
59 ))
60}
61hooks/games/tetris.ts 98 lines1// Tetris: pure game logic, drawn by the Tetris module in ../surface.tsx.
2
3export type Cell = readonly [number, number]
4export type Piece = { kind: number; rot: number; x: number; y: number }
5export type TetrisGame = { w: number; h: number; board: number[][]; piece: Piece; next: number; score: number; lines: number; over: boolean }
6
7// the seven pieces in spawn rotation, each inside an n×n box it rotates within
8const SHAPES: { n: number; cells: Cell[] }[] = [
9 { n: 4, cells: [[0, 1], [1, 1], [2, 1], [3, 1]] }, // I
10 { n: 2, cells: [[0, 0], [1, 0], [0, 1], [1, 1]] }, // O
11 { n: 3, cells: [[1, 0], [0, 1], [1, 1], [2, 1]] }, // T
12 { n: 3, cells: [[1, 0], [2, 0], [0, 1], [1, 1]] }, // S
13 { n: 3, cells: [[0, 0], [1, 0], [1, 1], [2, 1]] }, // Z
14 { n: 3, cells: [[0, 0], [0, 1], [1, 1], [2, 1]] }, // J
15 { n: 3, cells: [[2, 0], [0, 1], [1, 1], [2, 1]] }, // L
16]
17export const NAMES = 'IOTSZJL'
18const LINE_SCORES = [0, 100, 300, 500, 800]
19
20// the board cells a piece covers: its shape turned clockwise `rot` times inside its box
21export function cells(p: Piece): Cell[] {
22 const { n, cells: shape } = SHAPES[p.kind]
23 const turns = ((p.rot % 4) + 4) % 4
24 return shape.map(([x, y]) => {
25 let cx = x
26 let cy = y
27 for (let i = 0; i < turns; i++) [cx, cy] = [n - 1 - cy, cx]
28 return [p.x + cx, p.y + cy] as const
29 })
30}
31
32export function collides(g: Pick<TetrisGame, 'w' | 'h' | 'board'>, p: Piece): boolean {
33 return cells(p).some(([x, y]) => x < 0 || x >= g.w || y >= g.h || (y >= 0 && g.board[y][x] !== 0))
34}
35
36const spawn = (w: number, kind: number): Piece => ({ kind, rot: 0, x: Math.floor((w - SHAPES[kind].n) / 2), y: 0 })
37const randomKind = (rand: () => number) => Math.floor(rand() * SHAPES.length)
38
39export function newTetris(w: number, h: number, rand = Math.random): TetrisGame {
40 const board = Array.from({ length: h }, () => Array<number>(w).fill(0))
41 return { w, h, board, piece: spawn(w, randomKind(rand)), next: randomKind(rand), score: 0, lines: 0, over: false }
42}
43
44// fix the piece into the board, clear full rows, score them and bring in the next piece;
45// the round ends when that piece has no room
46function lock(g: TetrisGame, rand: () => number, bonus = 0): TetrisGame {
47 const board = g.board.map(row => [...row])
48 for (const [x, y] of cells(g.piece)) if (y >= 0) board[y][x] = g.piece.kind + 1
49 const kept = board.filter(row => row.some(c => c === 0))
50 const cleared = g.h - kept.length
51 const next: TetrisGame = {
52 ...g,
53 board: [...Array.from({ length: cleared }, () => Array<number>(g.w).fill(0)), ...kept],
54 piece: spawn(g.w, g.next),
55 next: randomKind(rand),
56 lines: g.lines + cleared,
57 score: g.score + bonus + LINE_SCORES[cleared],
58 }
59 return collides(next, next.piece) ? { ...next, over: true } : next
60}
61
62export function shift(g: TetrisGame, dx: number): TetrisGame {
63 const piece = { ...g.piece, x: g.piece.x + dx }
64 return g.over || collides(g, piece) ? g : { ...g, piece }
65}
66
67// clockwise, nudged sideways off a wall or a block when it would not fit in place
68export function rotate(g: TetrisGame): TetrisGame {
69 if (g.over) return g
70 for (const dx of [0, -1, 1, -2, 2]) {
71 const piece = { ...g.piece, rot: (g.piece.rot + 1) % 4, x: g.piece.x + dx }
72 if (!collides(g, piece)) return { ...g, piece }
73 }
74 return g
75}
76
77// gravity and soft drop: one row down, or lock when it cannot fall
78export function fall(g: TetrisGame, rand = Math.random): TetrisGame {
79 if (g.over) return g
80 const piece = { ...g.piece, y: g.piece.y + 1 }
81 return collides(g, piece) ? lock(g, rand) : { ...g, piece }
82}
83
84// hard drop: straight to the bottom, two points per row dropped
85export function drop(g: TetrisGame, rand = Math.random): TetrisGame {
86 if (g.over) return g
87 let piece = g.piece
88 let rows = 0
89 while (!collides(g, { ...piece, y: piece.y + 1 })) {
90 piece = { ...piece, y: piece.y + 1 }
91 rows++
92 }
93 return lock({ ...g, piece }, rand, rows * 2)
94}
95
96// frame ticks (100 ms each) between gravity steps: 600 ms, one tick faster every 10 lines
97export const dropEvery = (lines: number) => Math.max(1, 6 - Math.floor(lines / 10))
98