2048 in a pane. Slide tiles with w, a, s and d, merge equal numbers, reach 2048. One undo, a best score that survives restarts, and a board that replays from…

2048 in a pane beside the conversation. Slide the tiles with w, a, s and d; two equal tiles that meet merge into one and score; a new tile appears after every move. Make a 2048 tile, then keep going for a higher one. One undo, a best score and highest tile that survive restarts, and a board that replays from its seed.
It's a game you play, not a watcher: it never calls a model, never reads what Claude is doing, and costs no tokens.

Recorded in a terminal. The Desktop app draws the same pane, with each key shown as a badge.
2048 score 1256 best 4512 · highest 512 · move 83
Score 1256 · best 4512
┌─────┐┌─────┐┌─────┐┌─────┐
│ ││ 2 ││ 8 ││ 4 │
└─────┘└─────┘└─────┘└─────┘
┌─────┐┌─────┐┌─────┐┌─────┐
│ 2 ││ 16 ││ 32 ││ 16 │
└─────┘└─────┘└─────┘└─────┘
┌─────┐┌─────┐┌─────┐┌─────┐
│ 8 ││ 64 ││ 128 ││ 32 │
└─────┘└─────┘└─────┘└─────┘
┌─────┐┌─────┐┌─────┐┌─────┐
│ 16 ││ 256 ││ 512 ││ 64 │
└─────┘└─────┘└─────┘└─────┘
w: Up a: Left s: Down d: Right
n: New game u: Undo h: Help Esc closes
The tiles are colored, which the text above can't show. They use the original game's palette, so the board looks the same on any theme: beige empty slots, cream 2s and 4s, orange deepening through 8 to 64, gold from 128 to 2048, and near-black beyond. Each tile is three rows tall with its number centered.
/plugin marketplace add MDmubarak786/claude-mods
/plugin install game-2048@modhub
Try it for one session without installing:
claude --plugin-dir ./mods/game-2048
/2048. The pane opens with two tiles on a 4 by 4 board. A fresh board shows a short how-to until you hide it with h, which also shows it again.The keys work while the pane has the focus. If pressing w types into the prompt instead, click the pane first.
| Command | What it does |
|---|---|
/2048 | Open the pane. Where no pane can be drawn, print the board and the score. |
/2048 new | Start a new game. |
/2048 stats | Games played, best score, highest tile. |
From claude plugin validate ./mods/game-2048:
hooks: session.start, classic.SessionStart{source=clear|resume|fork}, command.run{command="2048"}, ui.render{component=Pane}
calls: $.clock.now (via load, startNew), $.command.register, $.session.surfaces, $.state.get, $.state.set, $.store.get (via load), $.store.set (via save), $.ui.invalidate, $.ui.log, $.ui.open, $.ui.resolve, $.ui.toast
$.store keeps the board, the score, the one undo, and the record, so a game survives a restart. If the store can't be read at start, the mod plays in memory and writes nothing over the saved record.$.clock seeds each game. There is no timer: nothing happens between your moves.claude plugin validate --strict and claude plugin test passing: line sliding and merging, all four directions, seeded spawns, a move that changes nothing, undo, game over on a locked board, the 2048 win that keeps play going, mounted pane tests for the keys, the status lines, the commands, an unreadable store, and a narrow pane.MIT, see the repository root.
hooks/register.ts 226 lines1// 2048: slide tiles, merge equal numbers, reach 2048, in a pane.
2//
3// /2048 open the game
4// /2048 new start a new game
5// /2048 stats games, best score, highest tile
6//
7// In the pane: w, a, s and d slide the tiles up, left, down and right; u takes
8// the last move back, n starts a new game, h shows the how-to. Nothing here
9// calls a model, reads what Claude is doing, or leaves the machine.
10
11import { atom, read, update } from 'claude-code'
12import type { Game, Stats } from '../types'
13import { GOAL, SIZE, at, isGame, move, newGame, undo, type Dir } from './game'
14
15const PANE = '2048'
16const EMPTY_STATS: Stats = { games: 0, best: 0, highest: 0 }
17const EMPTY: Game = { seed: 0, cells: [], score: 0, moves: 0, won: false, over: false, prev: null, stats: EMPTY_STATS }
18const game = atom({ plugin: 'game-2048', key: 'game' } as const, EMPTY)
19
20// The how-to block: shown on a fresh board until h hides it, for the session.
21let help: boolean | null = null
22// False after the store could not be read: then nothing is written over the
23// saved record until a later load reads it.
24let storeOk = true
25
26async function save($, g: Game) {
27 if (!storeOk) return
28 try {
29 await $.store.set('game', g)
30 } catch {
31 // The drawing still updates; the store catches up on the next change.
32 }
33}
34
35async function change($, fn: (cur: Game) => Game): Promise<Game> {
36 let out: Game | null = null
37 await update($, game, (cur) => {
38 out = fn(cur)
39 return out
40 })
41 if (out) await save($, out)
42 return out ?? (await read($, game))
43}
44
45async function load($) {
46 let saved: unknown = undefined
47 let readable = true
48 try {
49 saved = await $.store.get('game')
50 } catch {
51 readable = false
52 }
53 storeOk = readable
54 if (isGame(saved)) {
55 await update($, game, () => saved as Game)
56 return
57 }
58 if (!readable) {
59 const cur = await read($, game)
60 if (!cur.seed) await update($, game, () => newGame(Date.now(), { ...EMPTY_STATS, games: 1 }))
61 $.ui.log('2048 could not read its saved game; playing without saving')
62 return
63 }
64 const fresh = newGame(await $.clock.now(), { ...EMPTY_STATS, games: 1 })
65 await update($, game, () => fresh)
66 await save($, fresh)
67}
68
69async function startNew($) {
70 const now = await $.clock.now()
71 return change($, (cur) => newGame(now, { ...cur.stats, games: cur.stats.games + 1 }))
72}
73
74function summary(g: Game): string {
75 if (!g.seed) return 'No game yet.'
76 const row = (r: number) => Array.from({ length: SIZE }, (_, c) => g.cells[at(r, c)] || '.').join(' ')
77 const state = g.over ? 'No moves left.' : g.won ? 'You reached ' + GOAL + '; keep going.' : 'In play.'
78 return '2048: ' + Array.from({ length: SIZE }, (_, r) => row(r)).join(' / ') + '. Score ' + g.score + ', best ' + g.stats.best + '. ' + state + ' /2048 opens the pane.'
79}
80
81// The original game's tile colors, which read the same on any theme: a board of
82// beige slots, cream for 2 and 4, orange climbing to the gold of 2048, and
83// near-black beyond it. Light text on the dark tiles, dark text on the light.
84const TILES: Record<number, [string, string]> = {
85 2: ['#eee4da', '#776e65'],
86 4: ['#ede0c8', '#776e65'],
87 8: ['#f2b179', '#f9f6f2'],
88 16: ['#f59563', '#f9f6f2'],
89 32: ['#f67c5f', '#f9f6f2'],
90 64: ['#f65e3b', '#f9f6f2'],
91 128: ['#edcf72', '#f9f6f2'],
92 256: ['#edcc61', '#f9f6f2'],
93 512: ['#edc850', '#f9f6f2'],
94 1024: ['#edc53f', '#f9f6f2'],
95 2048: ['#edc22e', '#f9f6f2'],
96}
97const EMPTY_TILE = '#cdc1b4'
98function tileStyle(v: number): [string, string] {
99 if (!v) return [EMPTY_TILE, EMPTY_TILE]
100 return TILES[v] ?? ['#3c3a32', '#f9f6f2']
101}
102
103export function register(on) {
104 on('session.start', async ($, e, next) => {
105 help = null
106 try {
107 await load($)
108 } catch (error) {
109 $.ui.log('2048 could not load its game: ' + error)
110 }
111 try {
112 await $.command.register({ name: '2048', description: 'Play 2048 in a pane', argumentHint: '[new | stats]', immediate: true })
113 } catch (error) {
114 $.ui.log('could not register /2048: ' + error)
115 }
116 return next(e)
117 })
118
119 // /clear and /resume reset $.state; the game lives on in the store.
120 on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
121 await load($)
122 return next(e)
123 }).catch(async ($, e, next) => next(e))
124
125 on('command.run', { command: '2048' }, async ($, e) => {
126 const args = e.args.trim().toLowerCase()
127 if (args === 'new') return { text: 'New game. ' + summary(await startNew($)) }
128 if (args === 'stats') {
129 const s = (await read($, game)).stats
130 return { text: 'Games ' + s.games + ' · best score ' + s.best + ' · highest tile ' + s.highest }
131 }
132 if (args) return { text: 'Usage: /2048, /2048 new, or /2048 stats' }
133 // Under claude -p nothing draws, and ui.open still reports the pane as placed:
134 // the surfaces list is the reliable sign.
135 let surfaces = []
136 try {
137 surfaces = await $.session.surfaces()
138 } catch {
139 surfaces = []
140 }
141 if (!surfaces.length) return { text: summary(await read($, game)) }
142 const placed = await $.ui.open({ id: PANE, title: '2048', focus: true, closeOnEscape: true, rows: 32 })
143 if (!placed.isPlaced) return { text: summary(await read($, game)) }
144 return {}
145 }).catch(async () => ({ text: '2048: the command failed.' }))
146
147 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
148 if (e.requestId !== PANE) return next(e)
149 const g = await read($, game)
150 const { Box, Text, Button } = $.ui.resolve(e)
151 if (!g.seed) return Text({ children: ['No game loaded. /2048 new starts one.'] })
152 const columns = e.props.bodyColumns || 60
153 const wide = columns >= 50
154 const showHelp = help ?? (wide && g.moves === 0)
155
156 const status = g.over ? { text: 'No moves left. Score ' + g.score + '. Press n for a new game.', color: 'error', bold: true }
157 : g.won ? { text: 'You made ' + GOAL + '! Keep going for a higher tile, or n for a new game.', color: 'success', bold: true }
158 : g.moves === 0 ? { text: 'Slide with w, a, s, d. Equal tiles that touch merge.', color: 'suggestion' }
159 : { text: 'Score ' + g.score + (g.score > 0 && g.stats.best <= g.score ? ' · a new best' : ' · best ' + g.stats.best), color: 'suggestion' }
160
161 const header = Box({ flexDirection: 'column', children: [
162 Box({ flexDirection: 'row', columnGap: 2, flexWrap: 'wrap', children: [
163 Text({ bold: true, children: ['2048'] }),
164 Text({ children: ['score ', Text({ bold: true, children: [String(g.score)] })] }),
165 Text({ dimColor: true, children: ['best ' + g.stats.best + ' · highest ' + g.stats.highest + ' · move ' + g.moves] }),
166 ] }),
167 Text({ color: status.color, bold: status.bold, wrap: 'wrap', children: [status.text] }),
168 ] })
169
170 // Every tile is a fixed-size Box, three rows tall and seven wide, with the
171 // number centered, so the board is a grid of near-square tiles on any
172 // surface. An empty slot is a tile of the board's own color.
173 const tile = (r: number, c: number) => {
174 const v = g.cells[at(r, c)]
175 const [backgroundColor, color] = tileStyle(v)
176 return Box({ width: 7, height: 3, justifyContent: 'center', alignItems: 'center', backgroundColor, children: [Text({ bold: true, color, children: [v ? String(v) : ' '] })] })
177 }
178 const grid = Box({ flexDirection: 'column', rowGap: 1, children: Array.from({ length: SIZE }, (_, r) => Box({ flexDirection: 'row', columnGap: 1, children: Array.from({ length: SIZE }, (_, c) => tile(r, c)) })) })
179
180 const slideButton = (key: string, dir: Dir, label: string) =>
181 Button({ key, label, hotkey: key, plain: true, onPress: async () => {
182 let error: string | undefined
183 await change($, (cur) => {
184 const r = move(cur, dir)
185 error = r.error
186 return r.game
187 })
188 if (error) $.ui.toast(error)
189 } })
190 const controls = Box({ flexDirection: 'row', columnGap: 2, flexWrap: 'wrap', children: [
191 slideButton('w', 'up', 'Up'),
192 slideButton('a', 'left', 'Left'),
193 slideButton('s', 'down', 'Down'),
194 slideButton('d', 'right', 'Right'),
195 ] })
196
197 const howTo = Box({ flexDirection: 'column', borderStyle: 'round', borderColor: 'suggestion', paddingX: 1, children: [
198 Text({ bold: true, children: ['How to play'] }),
199 Text({ wrap: 'wrap', children: ['1. Press w, a, s or d: every tile slides that way as far as it can.'] }),
200 Text({ wrap: 'wrap', children: ['2. Two equal tiles that meet merge into one, and its value is added to your score. A new 2 (or 4) appears after each move.'] }),
201 Text({ wrap: 'wrap', children: ['3. Make a 2048 tile. The game ends when no slide changes the board. u takes one move back.'] }),
202 Text({ dimColor: true, wrap: 'wrap', children: ['Keys need the pane focused: click it if nothing happens. h hides this.'] }),
203 ] })
204
205 const footer = Box({ flexDirection: 'row', columnGap: 3, flexWrap: 'wrap', children: [
206 Button({ key: 'new', label: 'New game', hotkey: 'n', plain: true, onPress: () => startNew($) }),
207 Button({ key: 'undo', label: 'Undo', hotkey: 'u', plain: true, onPress: async () => {
208 let error: string | undefined
209 await change($, (cur) => {
210 const r = undo(cur)
211 error = r.error
212 return r.game
213 })
214 if (error) $.ui.toast(error)
215 } }),
216 Button({ key: 'help', label: showHelp ? 'Hide help' : 'Help', hotkey: 'h', plain: true, dimColor: true, onPress: () => {
217 help = !showHelp
218 $.ui.invalidate('ui.render')
219 } }),
220 Text({ dimColor: true, children: ['Esc closes'] }),
221 ] })
222
223 return Box({ flexDirection: 'column', rowGap: 1, children: showHelp ? [header, grid, controls, howTo, footer] : [header, grid, controls, footer] })
224 })
225}
226hooks/game.ts 136 lines1// The rules of 2048, pure: no `$` here, so the tests and the hooks module
2// share one implementation.
3
4import type { Game, Stats } from '../types'
5
6export const SIZE = 4
7export const GOAL = 2048
8export type Dir = 'up' | 'down' | 'left' | 'right'
9export const DIRS: Dir[] = ['up', 'down', 'left', 'right']
10
11function hash(s: string): number {
12 let h = 2166136261
13 for (let i = 0; i < s.length; i++) {
14 h ^= s.charCodeAt(i)
15 h = Math.imul(h, 16777619)
16 }
17 return h >>> 0
18}
19
20function rng(seed: number): () => number {
21 let a = seed
22 return () => {
23 a = (a + 0x6d2b79f5) | 0
24 let t = Math.imul(a ^ (a >>> 15), 1 | a)
25 t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
26 return ((t ^ (t >>> 14)) >>> 0) / 4294967296
27 }
28}
29
30export const at = (row: number, col: number) => row * SIZE + col
31
32// Drop a 2 (or, one time in ten, a 4) on an empty cell. Deterministic from the
33// seed and the spawn number, so a game replays the same way.
34export function spawn(cells: number[], seed: number, n: number): number[] {
35 const empty = cells.map((v, i) => (v ? -1 : i)).filter((i) => i >= 0)
36 if (!empty.length) return cells
37 const r = rng(hash(seed + ':spawn:' + n))
38 const i = empty[Math.floor(r() * empty.length)]
39 const out = cells.slice()
40 out[i] = r() < 0.1 ? 4 : 2
41 return out
42}
43
44// Slide one line toward index 0, merging each pair once; the points gained ride along.
45export function slideLine(line: number[]): { line: number[]; gained: number } {
46 const packed = line.filter(Boolean)
47 const out: number[] = []
48 let gained = 0
49 for (let i = 0; i < packed.length; i++) {
50 if (i + 1 < packed.length && packed[i] === packed[i + 1]) {
51 out.push(packed[i] * 2)
52 gained += packed[i] * 2
53 i++
54 } else out.push(packed[i])
55 }
56 while (out.length < SIZE) out.push(0)
57 return { line: out, gained }
58}
59
60// The cells of each line in the order they slide for `dir`: the leading cell first.
61function lines(dir: Dir): number[][] {
62 const out: number[][] = []
63 for (let k = 0; k < SIZE; k++) {
64 const idx = [0, 1, 2, 3]
65 if (dir === 'left') out.push(idx.map((i) => at(k, i)))
66 if (dir === 'right') out.push(idx.map((i) => at(k, SIZE - 1 - i)))
67 if (dir === 'up') out.push(idx.map((i) => at(i, k)))
68 if (dir === 'down') out.push(idx.map((i) => at(SIZE - 1 - i, k)))
69 }
70 return out
71}
72
73export function slide(cells: number[], dir: Dir): { cells: number[]; gained: number; moved: boolean } {
74 const out = cells.slice()
75 let gained = 0
76 let moved = false
77 for (const line of lines(dir)) {
78 const before = line.map((i) => cells[i])
79 const r = slideLine(before)
80 gained += r.gained
81 line.forEach((i, k) => {
82 if (out[i] !== r.line[k]) moved = true
83 out[i] = r.line[k]
84 })
85 }
86 return { cells: out, gained, moved }
87}
88
89export function canMove(cells: number[]): boolean {
90 return DIRS.some((d) => slide(cells, d).moved)
91}
92
93export function highestTile(cells: number[]): number {
94 return Math.max(0, ...cells)
95}
96
97export function newGame(seed: number, stats: Stats): Game {
98 let cells = new Array(SIZE * SIZE).fill(0)
99 cells = spawn(cells, seed, 0)
100 cells = spawn(cells, seed, 1)
101 return { seed, cells, score: 0, moves: 0, won: false, over: false, prev: null, stats }
102}
103
104export type MoveResult = { game: Game; error?: string }
105
106export function move(g: Game, dir: Dir): MoveResult {
107 if (!g.cells.length) return { game: g, error: 'No game yet.' }
108 if (g.over) return { game: g, error: 'No moves left. Press n for a new game.' }
109 const r = slide(g.cells, dir)
110 if (!r.moved) return { game: g, error: 'Nothing slides that way.' }
111 const moves = g.moves + 1
112 const cells = spawn(r.cells, g.seed, moves + 1)
113 const score = g.score + r.gained
114 const highest = highestTile(cells)
115 const over = !canMove(cells)
116 const stats: Stats = { ...g.stats, best: Math.max(g.stats.best, score), highest: Math.max(g.stats.highest, highest) }
117 return { game: { ...g, cells, score, moves, won: g.won || highest >= GOAL, over, prev: { cells: g.cells, score: g.score, moves: g.moves, won: g.won }, stats } }
118}
119
120export function undo(g: Game): MoveResult {
121 if (!g.prev) return { game: g, error: 'Nothing to take back.' }
122 return { game: { ...g, cells: g.prev.cells, score: g.prev.score, moves: g.prev.moves, won: g.prev.won, over: false, prev: null } }
123}
124
125export function isGame(x: unknown): x is Game {
126 if (!x || typeof x !== 'object') return false
127 const g = x as Record<string, unknown>
128 const board = (c: unknown) => Array.isArray(c) && c.length === SIZE * SIZE && c.every((v) => Number.isInteger(v) && v >= 0)
129 const stats = g.stats as Record<string, unknown> | undefined
130 const prev = g.prev as Record<string, unknown> | null | undefined
131 return typeof g.seed === 'number' && board(g.cells) && Number.isInteger(g.score) && Number.isInteger(g.moves) &&
132 typeof g.won === 'boolean' && typeof g.over === 'boolean' &&
133 (prev === null || (!!prev && typeof prev === 'object' && board(prev.cells) && Number.isInteger(prev.score) && Number.isInteger(prev.moves) && typeof prev.won === 'boolean')) &&
134 !!stats && typeof stats === 'object' && ['games', 'best', 'highest'].every((k) => Number.isInteger(stats[k]))
135}
136types/index.d.ts 24 lines1export type Stats = { games: number; best: number; highest: number }
2export type Game = {
3 /** Seed of this game's tile spawns; 0 until a game exists. */
4 seed: number
5 /** 16 tiles in reading order; 0 is empty. */
6 cells: number[]
7 score: number
8 /** Moves that changed the board; also the spawn counter. */
9 moves: number
10 /** A 2048 tile has appeared; play goes on. */
11 won: boolean
12 /** No move changes the board. */
13 over: boolean
14 /** The game before the last move, for one undo. */
15 prev: { cells: number[]; score: number; moves: number; won: boolean } | null
16 stats: Stats
17}
18
19declare module 'claude-code' {
20 interface PluginState {
21 'game-2048': { game: Game }
22 }
23}
24