SLOPSHOPPER

game-2048

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…

newpanecommandtoast
v0.1.1MITupdated 2026-10-13MDmubarak786/claude-mods/mods/game-2048
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · game-2048
│ ┃ 2048 ✕ › fix the failing auth test and add an audit log call │ ┃ 2048 score 0 best 0 · highest 0 · move 0 │ ┃ Slide with w, a, s, d. Equal tiles that ⏺ Read(src/auth.ts) │ ┃ touch merge. ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ 2 ⎿ 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 │ ┃ │ ┃ 2 › /2048 │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ w: Up a: Left s: Down d: Right │ ┃ │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ How to play │ │ ┃ │ 1. Press w, a, s or d: every tile slides │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · 2048
2048 score 0 best 0 · highest 0 · move 0 Slide with w, a, s, d. Equal tiles that touch merge. 2 2 w: Up a: Left s: Down d: Right ╭──────────────────────────────────────────────────────────╮ │ How to play │ │ 1. Press w, a, s or d: every tile slides that way as far │ │ as it can. │ │ 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. │ │ 3. Make a 2048 tile. The game ends when no slide changes │ │ the board. u takes one move back. │ │ Keys need the pane focused: click it if nothing happens. │ │ h hides this. │ ╰──────────────────────────────────────────────────────────╯ n: New game u: Undo h: Hide help Esc closes
README

game-2048

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.

2048 in a pane: two 128s merge, then two 256s make 512.

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.

Install

/plugin marketplace add MDmubarak786/claude-mods
/plugin install game-2048@modhub

Try it for one session without installing:

claude --plugin-dir ./mods/game-2048

How to play

  1. Run /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.
  2. Press w, a, s or d, or click the arrow buttons. Every tile slides that way as far as it can. Two equal tiles that meet merge, once per move, and the merged value is added to your score.
  3. A 2, or one time in ten a 4, appears on an empty cell after each move that changed the board. A slide that changes nothing is refused with a toast and spawns nothing.
  4. Make a 2048 tile and the status line says so; play goes on. The game ends when no slide changes the board.
  5. u takes back the last move, once, including a 2048 it made. n starts a new game; the best score and highest tile are kept.
  6. Esc closes the pane. The board is saved on every move.

The keys work while the pane has the focus. If pressing w types into the prompt instead, click the pane first.

CommandWhat it does
/2048Open the pane. Where no pane can be drawn, print the board and the score.
/2048 newStart a new game.
/2048 statsGames played, best score, highest tile.

What it touches

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.
  • No tool calls are observed, no files, processes, model calls, or network.

Tested with

  • Claude Code 2.1.295, 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.
  • Played in the Desktop app, 2.1.293, through hot reloading.

Limitations

  • One game at a time per machine. Two sessions playing at once overwrite each other; the last write wins.
  • One level of undo, and none after a new game.
  • The arrow keys are not hotkeys a mod can take; the letters w, a, s, d are.

License

MIT, see the repository root.

Source 3 files
hooks/register.ts 226 lines
1// 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}
226
hooks/game.ts 136 lines
1// 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}
136
types/index.d.ts 24 lines
1export 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