2048 above the Claude Code prompt: slide and merge tiles up to 2048. Plays while Claude works, pauses when the turn ends, keeps your best score; zero tokens. A…

2048 above the Claude Code prompt, for the minutes Claude spends working: slide and merge tiles up to 2048. Its status line tells you when Claude finishes the turn (2048 has no clock, so there is nothing to pause), 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.
/2048 opens the board above the prompt (/2048 stop or the close button closes it).r restarts. Equal tiles merge; the game ends when no slide changes anything.Your best score is kept in the plugin's store across sessions.
npx claude-code-templates@latest --mod games/2048
claude
It is written to .claude/skills/2048/, which Claude Code auto-loads as 2048@skills-dir. For one session with hot reload: claude --plugin-dir .claude/skills/2048.
claude -p, the desktop app or mobile.Clones of the genre, not affiliated with or endorsed by the original publishers.
hooks/register.tsx 92 lines1/* @jsx h */
2import type { Register } from 'claude-code'
3import { isBetter } from './games/best.ts'
4
5// 2048 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. `/2048` opens it, `/2048 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 = '2048' // what the board posts its score under
11const TITLE = '2048'
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: '2048',
28 description: `$2048 above the prompt, while Claude works (stop closes)`,
29 argumentHint: '[stop]',
30 immediate: true,
31 }).catch(err => $.ui.log(`2048: /2048 not registered: ${err}`))
32 return r
33 })
34
35 on('command.run', { command: '2048' }, 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: `$2048 closed` }
41 }
42 open = true
43 $.ui.invalidate('ui.render')
44 const score = best ? ` · best ${best}$` : ''
45 return { text: `$2048 · click the board to play · Esc returns to the prompt · /2048 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(`2048: store write failed: ${err}`))
66 $.ui.toast(`$2048: 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 const close = () => {
78 open = false
79 $.ui.invalidate('ui.render')
80 }
81 return (
82 <Box flexDirection="column">
83 <Box flexDirection="row" columnGap={1}>
84 <Button key="2048:close" label="close" onPress={close} />
85 </Box>
86 <Client key="board:2048" module="./boards/twenty48.tsx" width={cols} props={{ best, done: turnsDone, safe: false }} />
87 {await next(e)}
88 </Box>
89 )
90 })
91}
92hooks/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/twenty48.tsx 57 lines1/* @jsx h */
2import type { ClientSurface } from 'claude-code'
3import { base, claudeDone, statusLine, type Base, type Props } from './common.tsx'
4import { move, new2048, SIZE, type G2048 } from '../games/twenty48.ts'
5
6const TILE_COLORS: Record<number, string> = { 2: 'white', 4: 'whiteBright', 8: 'yellow', 16: 'yellowBright', 32: '#ff8800', 64: 'red', 128: 'magenta', 256: 'magentaBright', 512: 'blue', 1024: 'cyan', 2048: 'green' }
7const SLIDE_KEYS: Record<string, string> = { left: 'left', right: 'right', up: 'up', down: 'down', a: 'left', d: 'right', w: 'up', s: 'down' }
8type State = Base & { game: G2048 }
9
10export default function Twenty48(props: Props, surface: ClientSurface<State>) {
11 const { Box, Text } = surface.elements
12
13 if (surface.state === undefined) {
14 surface.setState({ ...base(props), game: new2048() })
15 surface.onKey(({ key }) => {
16 const s = surface.state
17 if (!s) return
18 const k = key.toLowerCase()
19 if (k === 'r') {
20 surface.setState({ ...s, banner: false, game: new2048() })
21 return
22 }
23 if (!Object.hasOwn(SLIDE_KEYS, k)) {
24 if (s.banner) surface.setState({ ...s, banner: false })
25 return
26 }
27 const game = move(s.game, SLIDE_KEYS[k])
28 if (game.over && !s.game.over) surface.post({ game: '2048', score: game.score })
29 surface.setState({ ...s, banner: false, game })
30 })
31 }
32 claudeDone(props, surface)
33
34 const s = surface.state
35 const g = s?.game
36 const rows = Array.from({ length: SIZE }, (_, y) => (
37 <Text>
38 {Array.from({ length: SIZE }, (_, x) => {
39 const v = g?.cells[y * SIZE + x] ?? 0
40 const label = `${v ? String(v) : '·'}`.padStart(5) + ' '
41 return v >= 128
42 ? <Text color={TILE_COLORS[v] ?? 'greenBright'} bold>{label}</Text>
43 : <Text color={v ? TILE_COLORS[v] : 'gray'}>{label}</Text>
44 })}
45 </Text>
46 ))
47 const text = !g ? '2048'
48 : g.over ? `2048 · no moves left · score ${g.score} · r plays again`
49 : `2048 · score ${g.score}${g.won ? ' · 2048 reached, keep going' : ''} · click here, then arrows or wasd · r restarts`
50 return (
51 <Box flexDirection="column">
52 <Box flexDirection="column" borderStyle="round" borderColor={g?.over ? 'red' : 'yellow'} width={SIZE * 6 + 3}>{rows}</Box>
53 {statusLine(Text, !!s?.banner, `${text} · best ${props?.best ?? 0}`)}
54 </Box>
55 )
56}
57hooks/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/twenty48.ts 67 lines1// 2048: pure game logic, drawn by the Twenty48 module in ../surface.tsx.
2
3export const SIZE = 4
4export type G2048 = { cells: number[]; score: number; over: boolean; won: boolean }
5
6// slide one line toward its start: equal neighbours merge once each, first pair first
7export function slide(line: number[]): { line: number[]; gained: number } {
8 const tiles = line.filter(v => v !== 0)
9 const out: number[] = []
10 let gained = 0
11 for (let i = 0; i < tiles.length; i++) {
12 if (tiles[i] === tiles[i + 1]) {
13 out.push(tiles[i] * 2)
14 gained += tiles[i] * 2
15 i++
16 } else {
17 out.push(tiles[i])
18 }
19 }
20 while (out.length < line.length) out.push(0)
21 return { line: out, gained }
22}
23
24// the cell indices of every line, each listed from the edge the tiles slide toward
25function linesFor(dir: string): number[][] {
26 const r = [...Array(SIZE).keys()]
27 switch (dir) {
28 case 'left': return r.map(y => r.map(x => y * SIZE + x))
29 case 'right': return r.map(y => r.map(x => y * SIZE + (SIZE - 1 - x)))
30 case 'up': return r.map(x => r.map(y => y * SIZE + x))
31 case 'down': return r.map(x => r.map(y => (SIZE - 1 - y) * SIZE + x))
32 default: return []
33 }
34}
35
36// a 2 (nine times in ten) or a 4 on a random empty cell
37function addTile(cells: number[], rand: () => number): number[] {
38 const empty = cells.flatMap((v, i) => (v ? [] : [i]))
39 if (!empty.length) return cells
40 const next = [...cells]
41 next[empty[Math.floor(rand() * empty.length)]] = rand() < 0.9 ? 2 : 4
42 return next
43}
44
45const canMove = (cells: number[]) =>
46 cells.some((v, i) => v === 0 || (i % SIZE < SIZE - 1 && v === cells[i + 1]) || (i + SIZE < cells.length && v === cells[i + SIZE]))
47
48export function new2048(rand = Math.random): G2048 {
49 return { cells: addTile(addTile(Array<number>(SIZE * SIZE).fill(0), rand), rand), score: 0, over: false, won: false }
50}
51
52// a move that changes nothing is not a move: no new tile, same game back
53export function move(g: G2048, dir: string, rand = Math.random): G2048 {
54 const lines = linesFor(dir)
55 if (g.over || !lines.length) return g
56 const cells = [...g.cells]
57 let gained = 0
58 for (const idx of lines) {
59 const slid = slide(idx.map(i => g.cells[i]))
60 idx.forEach((i, k) => { cells[i] = slid.line[k] })
61 gained += slid.gained
62 }
63 if (cells.every((v, i) => v === g.cells[i])) return g
64 const next = addTile(cells, rand)
65 return { cells: next, score: g.score + gained, won: g.won || next.includes(2048), over: !canMove(next) }
66}
67