SLOPSHOPPER

pixel-dash

A tiny pixel-art platformer in a side pane: three worlds of running, jumping, stomping and coins. /play

newpanecommand
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · pixel-dash
│ ┃ Pixel Dash ✕ › fix the failing auth test and add an audit log call │ ┃ ▣ client module ./game.tsx │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ 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 │ │ › /play │ ⎿ pixel-dash: Pixel Dash: click the game, then ← → to run, ↑ or sp │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Pixel Dash
▣ client module ./game.tsx
README

Pixel Dash

A tiny pixel-art platformer in a Claude Code side pane. Something to play while Claude works.

A Claude Code mod for the desktop Code tab and the terminal.

license: MIT Claude Code mod version

<img src="docs/preview.svg" alt="Pixel Dash: the hero mid-jump under a row of bricks and question blocks, goombas walking towards a green pipe" width="640">

Install

/plugin marketplace add Talap-creator/pixel-dash
/plugin install pixel-dash@pixel-dash
/reload-plugins

Play

  1. Type /play — the game opens in a side pane.
  2. Click the red strip under the picture (in the terminal, click the game) so it gets the keys.
  3. Run to the flag.
key
→ ← / D Arun (hold it)
↑ / space / Wjump
Rstart over
1 2 3on the title screen: start in that world
Escgive the keys back to the prompt

The worlds

Three hand-built worlds, played one after another — or press 1, 2 or 3 on the title screen to start in any of them:

1-1overworldpipes, ? blocks, two pits, a staircase to the flag
1-2undergroundblue stone, a brick roof, pillars to climb, a low tunnel full of coins
1-3treetopsalmost no ground: hop between treetops of different heights over the void
  • coins (+50, 100 of them give a life) and ? blocks you hit from below (+200)
  • goombas — land on top to squash one (+100), touch one from the side and you lose a life; they turn at ledges
  • each flag gives +1000; finishing 1-3 adds 500 per life left
  • three lives and a checkpoint halfway through each world; your best score is kept between sessions

Every world is checked by a test bot that runs it start to flag, with and without goombas, so no gap is too wide and no wall too high.

How it draws

  • Desktop Code tab: every frame is an SVG — square, crisp pixels that scale to the pane's width.
  • Terminal: every character cell is two square pixels stacked (▀, top pixel as its colour, bottom as its background).

Every tile and sprite is a 6×6 piece of pixel art in a NES-style palette. The game runs at 20 frames a second in the pane's own surface module, so it costs no tokens and never touches your session.

Terminals report key presses but not releases, so running rides on your keyboard's key repeat and you coast for a moment after letting go.

Develop

claude plugin validate plugins/pixel-dash
claude plugin test plugins/pixel-dash

Hack on it live with claude --plugin-dir plugins/pixel-dash. The game is in plugins/pixel-dash/hooks/game.tsx: level, physics, sprites and drawing in one file.

License

MIT. Pixel Dash is an homage to classic side-scrollers, with its own sprites; it is not affiliated with any game company.

Source 3 files
hooks/register.tsx 64 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4const PANE = 'pixel-dash'
5
6// The desktop pane draws the game as SVG: the game module posts each frame here.
7const frame = atom({ plugin: 'pixel-dash', key: 'frame' } as const, null)
8
9const LOADING =
10  '<svg xmlns="http://www.w3.org/2000/svg" width="768" height="552" viewBox="0 0 96 69"><rect width="96" height="69" fill="#6b8cff"/>' +
11  '<text x="48" y="37" fill="#fcfcfc" font-size="6" font-family="monospace" text-anchor="middle">loading…</text></svg>'
12
13export const register: Register = on => {
14  on('session.start', async ($, e, next) => {
15    await $.command.register({ name: 'play', description: 'Open Pixel Dash, a tiny platformer, in a side pane' })
16    return next(e)
17  })
18
19  on('command.run', { command: 'play' }, async $ => {
20    const opened = await $.ui.open({ id: PANE, title: 'Pixel Dash' })
21    return { text: opened.isPlaced ? 'Pixel Dash: click the game, then ← → to run, ↑ or space to jump.' : `Pixel Dash could not open: ${opened.reason}` }
22  })
23
24  on('ui.message', { component: 'Pane', requestId: PANE }, async ($, e) => {
25    const data = e.data as { score?: unknown; svg?: unknown }
26    if (typeof data.svg === 'string') await update($, frame, () => data.svg as string)
27    // the best score is kept across sessions
28    if (typeof data.score === 'number') {
29      const best = Number((await $.store.get('best')) ?? 0)
30      if (data.score > best) {
31        await $.store.set('best', data.score)
32        return { props: { best: data.score, mode: e.surface === 'terminal' ? 'text' : 'svg' } }
33      }
34    }
35    return {}
36  })
37
38  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
39    const els = $.ui.resolve(e)
40    const { Box } = els
41    const best = Number((await $.store.get('best')) ?? 0)
42    if (!('Client' in els)) return <Box />
43    const { Client } = els
44
45    if (e.surface === 'terminal' || !('Svg' in els)) {
46      return (
47        <Box flexDirection="column" flexGrow={1}>
48          <Client key="game" module="./game.tsx" props={{ best, mode: 'text' }} width="100%" flexGrow={1} />
49        </Box>
50      )
51    }
52
53    // desktop: the picture, and under it the game's own strip, which takes the click and the keys
54    const { Svg } = els
55    const svg = (await read($, frame)) ?? LOADING
56    return (
57      <Box flexDirection="column">
58        <Svg key="picture" source={svg} alt="Pixel Dash" />
59        <Client key="game" module="./game.tsx" props={{ best, mode: 'svg' }} width="100%" />
60      </Box>
61    )
62  })
63}
64
hooks/game.tsx 804 lines
1import type { ClientModule, ClientSurface, JsonValue } from 'claude-code'
2
3// Pixel Dash draws real pixel art in a text grid: every cell is two square
4// pixels stacked ("▀", the top pixel as its colour, the bottom as its
5// background), and every tile is a 6×6 sprite, so a tile is 6 cells by 3.
6
7// ---- tuning (tiles and 50 ms ticks) ----
8
9const TICK_MS = 50
10const TILE = 6
11const GRAVITY = 0.11
12const JUMP = -1.05
13const STOMP_BOUNCE = -0.62
14const MAX_FALL = 0.7
15const RUN = 0.26
16const RUN_TICKS = 6 // keys have no key-up: a press keeps you running this long
17const ENEMY_SPEED = 0.05
18const LIVES = 3
19const H = 10
20
21// ---- palette and sprites ----
22
23const PAL: Record<string, string> = {
24  R: '#d82800', // cap, shirt
25  r: '#a01800',
26  S: '#fcb898', // skin
27  k: '#1a1a1a',
28  B: '#2038ec', // overalls
29  b: '#1028a0',
30  N: '#7c3c00', // shoes, hair
31  Y: '#fca044', // gold
32  y: '#f8d878',
33  O: '#c84c0c', // brick
34  o: '#7c2800',
35  D: '#a85020', // dirt
36  d: '#6c2c08',
37  G: '#00a800', // grass, pipes
38  g: '#007000',
39  L: '#80d010',
40  W: '#fcfcfc',
41  w: '#c8d4ff',
42  T: '#f0c890', // goomba feet
43  M: '#c06020', // goomba
44  m: '#783000',
45  P: '#9c9c9c', // pole
46  F: '#30c030', // flag
47  H: '#58b830', // hills
48  h: '#3c8c20',
49  U: '#5c7cd8', // cave stone and bricks
50  u: '#1c2c6c',
51  V: '#8ca8f0',
52  E: '#7c5020', // tree trunk
53  e: '#4c2c08',
54}
55
56const sprite = (...rows: string[]) => rows
57const flip = (s: string[]) => s.map(r => [...r].reverse().join(''))
58
59const HERO = {
60  stand: sprite('..RRR.', '.RRRRR', '.NSkS.', 'RRBBRR', '.BBBB.', '.NN.NN'),
61  walk: sprite('..RRR.', '.RRRRR', '.NSkS.', 'RRBBRS', 'SBBBB.', 'NN..N.'),
62  jump: sprite('S.RRR.', '.RRRRR', '.NSkSS', 'RRBBR.', '.BBBB.', 'NN..NN'),
63  dead: sprite('.RRRR.', 'SNSSNS', '.SkkS.', 'RRBBRR', '.BBBB.', 'N....N'),
64}
65const GOOMBA = {
66  a: sprite('..MM..', '.MMMM.', 'MWkkWM', 'MMMMMM', '.TTTT.', 'mm..mm'),
67  b: sprite('..MM..', '.MMMM.', 'MkWWkM', 'MMMMMM', '.TTTT.', '.mmmm.'),
68  flat: sprite('......', '......', '......', '......', 'MWkkWM', 'mmmmmm'),
69}
70const COIN = [
71  sprite('..YY..', '.YyyY.', '.YyYY.', '.YyYY.', '.YyyY.', '..YY..'),
72  sprite('..YY..', '..yY..', '..yY..', '..yY..', '..yY..', '..YY..'),
73  sprite('...Y..', '...Y..', '...Y..', '...Y..', '...Y..', '...Y..'),
74  sprite('..YY..', '..Yy..', '..Yy..', '..Yy..', '..Yy..', '..YY..'),
75]
76const DIGITS: Record<string, string[]> = {
77  '0': sprite('WWW', 'W.W', 'W.W', 'W.W', 'WWW'),
78  '1': sprite('.W.', 'WW.', '.W.', '.W.', 'WWW'),
79  '2': sprite('WWW', '..W', 'WWW', 'W..', 'WWW'),
80  '3': sprite('WWW', '..W', '.WW', '..W', 'WWW'),
81  '4': sprite('W.W', 'W.W', 'WWW', '..W', '..W'),
82  '5': sprite('WWW', 'W..', 'WWW', '..W', 'WWW'),
83  '6': sprite('WWW', 'W..', 'WWW', 'W.W', 'WWW'),
84  '7': sprite('WWW', '..W', '.W.', '.W.', '.W.'),
85  '8': sprite('WWW', 'W.W', 'WWW', 'W.W', 'WWW'),
86  '9': sprite('WWW', 'W.W', 'WWW', '..W', 'WWW'),
87}
88const TILES: Record<string, string[]> = {
89  grass: sprite('LLGLLG', 'GGGGGG', 'gDgDDg', 'DDDdDD', 'DdDDDD', 'DDDDdD'),
90  dirt: sprite('DDdDDD', 'DDDDdD', 'dDDDDD', 'DDDdDD', 'DdDDDD', 'DDDDDd'),
91  brick: sprite('oooooo', 'OOOoOO', 'OOOoOO', 'oooooo', 'OoOOOO', 'OoOOOO'),
92  q1: sprite('oYYYYo', 'YYkkYY', 'YYYYkY', 'YYYkYY', 'YYYYYY', 'oYYkYo'),
93  q2: sprite('oyyyyo', 'yykkyy', 'yyyyky', 'yyykyy', 'yyyyyy', 'oyykyo'),
94  spent: sprite('oooooo', 'oNNNNo', 'oNNNNo', 'oNNNNo', 'oNNNNo', 'oooooo'),
95  pipeTopL: sprite('gggggg', 'gLLGGG', 'gLLGGG', 'gggggg', '.gLGGG', '.gLGGG'),
96  pipeTopR: sprite('gggggg', 'GGGGGg', 'GGGGgg', 'gggggg', 'GGGGg.', 'GGGgg.'),
97  pipeL: sprite('.gLGGG', '.gLGGG', '.gLGGG', '.gLGGG', '.gLGGG', '.gLGGG'),
98  pipeR: sprite('GGGGg.', 'GGGgg.', 'GGGGg.', 'GGGgg.', 'GGGGg.', 'GGGgg.'),
99  pole: sprite('..PP..', '..PP..', '..PP..', '..PP..', '..PP..', '..PP..'),
100  flag: sprite('..PP..', 'FFPP..', 'FFFP..', '.FFP..', '..PP..', '..PP..'),
101  ball: sprite('......', '..GG..', '.GLGG.', '.GGGG.', '..GG..', '..PP..'),
102  cloudL: sprite('......', '...WWW', '.WWWWW', 'WWWWWW', 'wWWWWW', '.wwwww'),
103  cloudR: sprite('......', 'W.....', 'WWWW..', 'WWWWW.', 'WWWWWw', 'wwwww.'),
104  hillL: sprite('......', '......', '....HH', '..HHHH', '.HHhHH', 'HHHHHH'),
105  hillM: sprite('.HHHH.', 'HHHHHH', 'HHhHHH', 'HHHHHh', 'HHHHHH', 'HHHHHH'),
106  stone: sprite('UUUUUu', 'UVUUUu', 'UUUUUu', 'UUUUUu', 'uuuuuu', 'UUuUUU'),
107  blueBrick: sprite('uuuuuu', 'UUUuUU', 'UVUuUU', 'uuuuuu', 'UuUUUU', 'UuUVUU'),
108  treeL: sprite('.GGGGG', 'GLGGLG', 'GGGGGG', 'gGGGGG', '.ggggg', '......'),
109  treeM: sprite('GGGGGG', 'GLGGGL', 'GGGGGG', 'GGGGGG', 'gggggg', '......'),
110  treeR: sprite('GGGGG.', 'GLGGGG', 'GGGGGG', 'GGGGGg', 'ggggg.', '......'),
111  trunk: sprite('.EEEe.', '.EEee.', '.EEEe.', '.EeEe.', '.EEEe.', '.EEee.'),
112  hillR: sprite('......', '......', 'HH....', 'HHHH..', 'HHhHH.', 'HHHHHH'),
113}
114
115// ---- levels ----
116// tiles: # ground  = brick  ? coin block  B spent block  [ ] pipe  o coin
117//        { - } treetop (solid)
118// decoration: ( ) cloud   < ^ > hill   | pole   f flag   * pole top   ! trunk
119
120const SOLID = new Set(['#', '=', '?', 'B', '[', ']', '{', '-', '}'])
121
122type Theme = 'overworld' | 'underground' | 'sky'
123type Level = { tiles: string[][]; spawns: { x: number; y: number }[]; flagX: number; checkpointX: number; theme: Theme; name: string }
124
125const THEMES: Record<Theme, { sky: string }> = {
126  overworld: { sky: '#6b8cff' },
127  underground: { sky: '#08081a' },
128  sky: { sky: '#9cbcfc' },
129}
130
131function grid(w: number) {
132  const g: string[][] = Array.from({ length: H }, () => Array.from({ length: w }, () => ' '))
133  const put = (x: number, y: number, t: string) => {
134    if (y >= 0 && y < H && x >= 0 && x < w) g[y]![x] = t
135  }
136  const row = (x0: number, x1: number, y: number, t: string) => {
137    for (let x = x0; x <= x1; x++) put(x, y, t)
138  }
139  const ground = (x0: number, x1: number) => {
140    row(x0, x1, H - 2, '#')
141    row(x0, x1, H - 1, '#')
142  }
143  const pipe = (x: number, h: number) => {
144    for (let y = H - 2 - h; y < H - 2; y++) {
145      put(x, y, '[')
146      put(x + 1, y, ']')
147    }
148  }
149  const flag = (x: number) => {
150    put(x, 0, '*')
151    put(x, 1, 'f')
152    for (let y = 2; y < H - 2; y++) put(x, y, '|')
153  }
154  return { g, put, row, ground, pipe, flag }
155}
156
157// Every gap is at most 3 tiles, every step up at most 3, and no ceiling hangs
158// over a gap: a jump rises about 4.5 tiles and carries about 4.5 across.
159function level1(): Level {
160  const W = 112
161  const { g, put, row, ground, pipe, flag } = grid(W)
162  ground(0, 29)
163  ground(33, 65)
164  ground(69, W - 1)
165  for (const [x, y] of [[4, 1], [17, 2], [29, 1], [41, 2], [56, 1], [70, 2], [85, 1], [99, 2]] as const) {
166    put(x, y, '(')
167    put(x + 1, y, ')')
168  }
169  for (const x of [1, 38, 74]) {
170    put(x, H - 3, '<')
171    put(x + 1, H - 3, '^')
172    put(x + 2, H - 3, '>')
173  }
174  pipe(20, 2)
175  pipe(25, 3)
176  pipe(47, 3)
177  pipe(84, 2)
178
179  put(10, 4, '?')
180  row(13, 17, 4, '=')
181  put(14, 4, '?')
182  put(16, 4, '?')
183  put(15, 0, '?')
184  row(30, 32, 4, 'o') // an arc of coins over the first pit, nothing to bump into
185  row(36, 40, 4, '=')
186  put(38, 4, '?')
187  row(41, 44, 1, '=')
188  row(41, 44, 0, 'o')
189  row(53, 57, 4, '=')
190  put(55, 4, '?')
191  row(59, 63, 4, '=')
192  row(60, 62, 3, 'o')
193  row(66, 68, 4, 'o') // the second pit: open sky above it too
194  for (let i = 0; i < 7; i++) for (let y = H - 3 - i; y < H - 2; y++) put(92 + i, y, '=')
195  flag(102)
196  for (const x of [6, 7, 8]) put(x, H - 3, 'o')
197  for (const x of [22, 23, 51, 52, 77, 78, 79]) put(x, H - 4, 'o')
198
199  const spawns = [18, 29, 37, 50, 53, 61, 76, 80, 88].map(x => ({ x, y: H - 3 }))
200  spawns.push({ x: 39, y: 3 })
201  return { tiles: g, spawns, flagX: 102, checkpointX: 50, theme: 'overworld', name: '1-1' }
202}
203
204function level2(): Level {
205  const W = 104
206  const { g, put, row, ground, pipe, flag } = grid(W)
207  ground(0, 33)
208  ground(36, 69)
209  ground(73, W - 1)
210  row(4, 96, 0, '=') // the cave roof
211  row(14, 20, 4, '=')
212  put(17, 4, '?')
213  row(15, 19, 3, 'o')
214  for (let y = 5; y < H - 2; y++) put(26, y, '=')
215  for (let y = 4; y < H - 2; y++) put(29, y, '=')
216  row(27, 28, 2, 'o')
217  row(34, 35, 5, 'o')
218  pipe(40, 2)
219  pipe(46, 3)
220  row(52, 60, 5, '=') // a low tunnel: walk under it or run along its top
221  row(53, 59, H - 3, 'o')
222  row(53, 59, 4, 'o')
223  put(64, 4, '?')
224  put(66, 4, '?')
225  row(70, 72, 5, 'o')
226  row(78, 82, 4, '=')
227  put(80, 4, '?')
228  for (let i = 0; i < 4; i++) for (let y = H - 3 - i; y < H - 2; y++) put(88 + i, y, '=')
229  flag(98)
230
231  const spawns = [22, 38, 44, 50, 63, 77, 84].map(x => ({ x, y: H - 3 }))
232  spawns.push({ x: 17, y: 3 })
233  return { tiles: g, spawns, flagX: 98, checkpointX: 50, theme: 'underground', name: '1-2' }
234}
235
236function level3(): Level {
237  const W = 104
238  const { g, put, row, ground, flag } = grid(W)
239  ground(0, 8)
240  ground(96, W - 1)
241  const tree = (x0: number, x1: number, y: number) => {
242    put(x0, y, '{')
243    row(x0 + 1, x1 - 1, y, '-')
244    put(x1, y, '}')
245    const mid = Math.floor((x0 + x1) / 2)
246    for (let t = y + 1; t < H; t++) put(mid, t, '!')
247  }
248  const tops: [number, number, number][] = [
249    [11, 15, 6], [18, 21, 4], [24, 29, 6], [32, 34, 3], [37, 42, 5], [45, 47, 3], [50, 56, 6],
250    [59, 61, 4], [64, 70, 5], [73, 75, 3], [78, 83, 6], [86, 89, 4], [92, 94, 6],
251  ]
252  for (const [a, b, y] of tops) {
253    tree(a, b, y)
254    row(a + 1, b - 1, y - 1, 'o')
255  }
256  for (const [x, y] of [[3, 1], [27, 1], [52, 2], [80, 1]] as const) {
257    put(x, y, '(')
258    put(x + 1, y, ')')
259  }
260  put(40, 1, '?')
261  put(67, 2, '?')
262  flag(100)
263
264  const spawns = [[26, 5], [40, 4], [53, 5], [67, 4], [81, 5]].map(([x, y]) => ({ x: x!, y: y! }))
265  return { tiles: g, spawns, flagX: 100, checkpointX: 50, theme: 'sky', name: '1-3' }
266}
267
268const LEVELS = [level1, level2, level3]
269
270// ---- state ----
271
272type Enemy = { x: number; y: number; vx: number; vy: number; isDead: boolean; squashed: number }
273
274type Game = {
275  level: number
276  theme: Theme
277  name: string
278  tiles: string[][]
279  flagX: number
280  checkpointX: number
281  x: number
282  y: number
283  vx: number
284  vy: number
285  facing: 1 | -1
286  runTicks: number
287  isOnGround: boolean
288  enemies: Enemy[]
289  score: number
290  coins: number
291  lives: number
292  checkpoint: number
293  phase: 'ready' | 'play' | 'dying' | 'clear' | 'won' | 'over'
294  phaseTicks: number
295  ticks: number
296  bumps: { x: number; y: number; ttl: number }[]
297  popups: { x: number; y: number; text: string; ttl: number }[]
298  isReported?: boolean
299}
300
301function newGame(level = 0, lives = LIVES, score = 0, coins = 0, checkpoint = 2): Game {
302  const lv = LEVELS[level]!()
303  return {
304    level,
305    theme: lv.theme,
306    name: lv.name,
307    tiles: lv.tiles,
308    flagX: lv.flagX,
309    checkpointX: lv.checkpointX,
310    x: checkpoint,
311    y: H - 3,
312    vx: 0,
313    vy: 0,
314    facing: 1,
315    runTicks: 0,
316    isOnGround: true,
317    enemies: lv.spawns.map(s => ({ ...s, vx: -ENEMY_SPEED, vy: 0, isDead: false, squashed: 0 })),
318    score,
319    coins,
320    lives,
321    checkpoint,
322    phase: 'ready',
323    phaseTicks: 0,
324    ticks: 0,
325    bumps: [],
326    popups: [],
327    isReported: false,
328  }
329}
330
331// ---- physics ----
332
333const levelWidth = (g: Game) => g.tiles[0]?.length ?? 0
334
335const tileAt = (g: Game, x: number, y: number) => {
336  const cx = Math.floor(x)
337  const cy = Math.floor(y)
338  if (cx < 0) return '#'
339  if (cy < 0 || cy >= H || cx >= levelWidth(g)) return ' '
340  return g.tiles[cy]![cx]!
341}
342const isSolid = (g: Game, x: number, y: number) => SOLID.has(tileAt(g, x, y))
343
344const E = 0.001
345
346/** Moves a 1×1 box along x then y against the tiles; reports what it hit. */
347function move(g: Game, b: { x: number; y: number; vx: number; vy: number }) {
348  let hitWall = false
349  let hitHead: { x: number; y: number } | null = null
350  let isOnGround = false
351
352  b.x += b.vx
353  if (b.vx > 0 && (isSolid(g, b.x + 1 - E, b.y + E) || isSolid(g, b.x + 1 - E, b.y + 1 - E))) {
354    b.x = Math.floor(b.x + 1 - E) - 1
355    hitWall = true
356  } else if (b.vx < 0 && (isSolid(g, b.x, b.y + E) || isSolid(g, b.x, b.y + 1 - E))) {
357    b.x = Math.floor(b.x) + 1
358    hitWall = true
359  }
360
361  b.vy = Math.min(MAX_FALL, b.vy + GRAVITY)
362  b.y += b.vy
363  if (b.vy > 0 && (isSolid(g, b.x + E, b.y + 1) || isSolid(g, b.x + 1 - E, b.y + 1))) {
364    b.y = Math.floor(b.y + 1) - 1
365    b.vy = 0
366    isOnGround = true
367  } else if (b.vy < 0 && (isSolid(g, b.x + E, b.y) || isSolid(g, b.x + 1 - E, b.y))) {
368    const cy = Math.floor(b.y)
369    const cx = isSolid(g, b.x + 0.5, b.y) ? Math.floor(b.x + 0.5) : isSolid(g, b.x + E, b.y) ? Math.floor(b.x + E) : Math.floor(b.x + 1 - E)
370    hitHead = { x: cx, y: cy }
371    b.y = cy + 1
372    b.vy = 0
373  }
374  return { hitWall, hitHead, isOnGround }
375}
376
377function popup(g: Game, x: number, y: number, text: string) {
378  g.popups.push({ x, y, text, ttl: 16 })
379}
380
381function die(g: Game) {
382  g.phase = 'dying'
383  g.phaseTicks = 28
384  g.vy = JUMP * 0.9
385}
386
387function step(g: Game) {
388  g.ticks += 1
389  g.popups = g.popups.filter(p => ((p.y -= 0.06), --p.ttl > 0))
390  g.bumps = g.bumps.filter(b => --b.ttl > 0)
391
392  if (g.phase === 'dying') {
393    g.vy = Math.min(MAX_FALL, g.vy + GRAVITY * 0.7)
394    g.y += g.vy
395    if (--g.phaseTicks <= 0) {
396      if (g.lives <= 1) {
397        g.lives = 0
398        g.phase = 'over'
399      } else {
400        Object.assign(g, newGame(g.level, g.lives - 1, g.score, g.coins, g.checkpoint), { phase: 'play' })
401      }
402    }
403    return
404  }
405  if (g.phase === 'clear') {
406    if (--g.phaseTicks <= 0) Object.assign(g, newGame(g.level + 1, g.lives, g.score, g.coins), { phase: 'play' })
407    return
408  }
409  if (g.phase !== 'play') return
410
411  if (g.runTicks > 0) g.runTicks -= 1
412  else g.vx *= 0.55
413  if (Math.abs(g.vx) < 0.01) g.vx = 0
414
415  const hit = move(g, g)
416  g.isOnGround = hit.isOnGround
417  if (hit.hitHead) {
418    const { x, y } = hit.hitHead
419    const t = g.tiles[y]?.[x]
420    if (t === '?') {
421      g.tiles[y]![x] = 'B'
422      g.coins += 1
423      g.score += 200
424      popup(g, x, y - 1, '200')
425    }
426    if (t === '?' || t === '=') g.bumps.push({ x, y, ttl: 5 })
427  }
428
429  for (const [cx, cy] of [
430    [g.x + 0.5, g.y + 0.5],
431    [g.x + 0.5, g.y + 0.15],
432    [g.x + 0.5, g.y + 0.85],
433  ] as const) {
434    if (tileAt(g, cx, cy) === 'o') {
435      g.tiles[Math.floor(cy)]![Math.floor(cx)] = ' '
436      g.coins += 1
437      g.score += 50
438    }
439  }
440  if (g.coins >= 100) {
441    g.coins -= 100
442    g.lives += 1
443  }
444
445  if (g.x > g.checkpointX && g.checkpoint < g.checkpointX) g.checkpoint = g.checkpointX
446  if (g.y > H + 1) return die(g)
447  if (g.x + 0.6 >= g.flagX) {
448    g.score += 1000
449    if (g.level + 1 < LEVELS.length) {
450      g.phase = 'clear'
451      g.phaseTicks = 40
452    } else {
453      g.phase = 'won'
454      g.score += g.lives * 500
455    }
456    return
457  }
458
459  for (const en of g.enemies) {
460    if (en.isDead) {
461      if (en.squashed > 0) en.squashed -= 1
462      continue
463    }
464    if (Math.abs(en.x - g.x) > 18) continue // asleep off screen
465    const r = move(g, en)
466    // turn at walls, and at ledges so they keep to their platform
467    const ahead = en.vx > 0 ? en.x + 1 + E : en.x - E
468    if (r.hitWall || (r.isOnGround && !isSolid(g, ahead, en.y + 1.5))) en.vx = -en.vx
469    if (en.y > H + 1) en.isDead = true
470
471    const dx = Math.abs(en.x - g.x)
472    const dy = en.y - g.y
473    if (dx < 0.8 && Math.abs(dy) < 0.9) {
474      if (g.vy > 0 && dy > 0.3) {
475        en.isDead = true
476        en.squashed = 14
477        g.vy = STOMP_BOUNCE
478        g.score += 100
479        popup(g, en.x, en.y - 1, '100')
480      } else {
481        return die(g)
482      }
483    }
484  }
485}
486
487function onKey(g: Game, key: string) {
488  const k = key.toLowerCase()
489  const isTitle = g.phase === 'ready' || g.phase === 'over' || g.phase === 'won'
490  if (isTitle && /^[1-9]$/.test(k) && Number(k) <= LEVELS.length) {
491    Object.assign(g, newGame(Number(k) - 1), { phase: 'play' })
492    return
493  }
494  if (k === 'r' || ((g.phase === 'over' || g.phase === 'won') && (k === 'return' || k === ' ' || k === 'space'))) {
495    Object.assign(g, newGame(), { phase: 'play' })
496    return
497  }
498  if (g.phase === 'ready') g.phase = 'play'
499  if (g.phase !== 'play') return
500  if (k === 'left' || k === 'a') {
501    g.vx = -RUN
502    g.facing = -1
503    g.runTicks = RUN_TICKS
504  } else if (k === 'right' || k === 'd') {
505    g.vx = RUN
506    g.facing = 1
507    g.runTicks = RUN_TICKS
508  } else if ((k === 'up' || k === 'w' || k === ' ' || k === 'space' || k === 'k') && g.isOnGround) {
509    g.vy = JUMP
510    g.isOnGround = false
511  }
512}
513
514// for the tests: the simulation without the drawing
515export { LEVELS, newGame, onKey, step, isSolid }
516export type { Game }
517
518// ---- drawing ----
519
520function tileSprite(g: Game, t: string, x: number, y: number): string[] | null {
521  const cave = g.theme === 'underground'
522  switch (t) {
523    case '#':
524      return cave ? TILES.stone! : tileAt(g, x, y - 1) === '#' ? TILES.dirt! : TILES.grass!
525    case '=':
526      return cave ? TILES.blueBrick! : TILES.brick!
527    case '?':
528      return g.ticks % 20 < 14 ? TILES.q1! : TILES.q2!
529    case 'B':
530      return TILES.spent!
531    case '[':
532      return tileAt(g, x, y - 1) === '[' ? TILES.pipeL! : TILES.pipeTopL!
533    case ']':
534      return tileAt(g, x, y - 1) === ']' ? TILES.pipeR! : TILES.pipeTopR!
535    case 'o':
536      return COIN[Math.floor(g.ticks / 3) % COIN.length]!
537    case '|':
538      return TILES.pole!
539    case 'f':
540      return TILES.flag!
541    case '*':
542      return TILES.ball!
543    case '(':
544      return TILES.cloudL!
545    case ')':
546      return TILES.cloudR!
547    case '<':
548      return TILES.hillL!
549    case '^':
550      return TILES.hillM!
551    case '>':
552      return TILES.hillR!
553    case '{':
554      return TILES.treeL!
555    case '-':
556      return TILES.treeM!
557    case '}':
558      return TILES.treeR!
559    case '!':
560      return TILES.trunk!
561    default:
562      return null
563  }
564}
565
566const HUD_PX = 9
567
568function esc(text: string) {
569  return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
570}
571
572/** The frame as SVG: one rect per run of a colour in a pixel row, the HUD and banners as text. */
573function toSvg(buf: string[], pxW: number, pxH: number, g: Game, best: number): string {
574  const pad = (n: number, w: number) => String(n).padStart(w, '0')
575  const sky = THEMES[g.theme].sky
576  const parts: string[] = []
577  for (let y = 0; y < pxH; y++) {
578    let x = 0
579    while (x < pxW) {
580      const c = buf[y * pxW + x]!
581      let end = x + 1
582      while (end < pxW && buf[y * pxW + end] === c) end++
583      if (c !== sky) parts.push(`<rect x="${x}" y="${y + HUD_PX}" width="${end - x}" height="1" fill="${c}"/>`)
584      x = end
585    }
586  }
587  const t = (x: number, y: number, text: string, fill: string, anchor = 'start', size = 5) =>
588    `<text x="${x}" y="${y}" fill="${fill}" font-size="${size}" font-weight="700" text-anchor="${anchor}">${esc(text)}</text>`
589  const hud = [
590    t(2, 6.5, `DASH ${pad(g.score, 6)}`, '#fcfcfc'),
591    t(pxW * 0.36, 6.5, `● ×${pad(g.coins, 2)}`, '#fca044'),
592    t(pxW * 0.52, 6.5, '♥'.repeat(Math.max(0, g.lives)), '#d82800'),
593    t(pxW * 0.66, 6.5, g.name, '#fcfcfc'),
594    t(pxW - 2, 6.5, `BEST ${pad(best, 6)}`, '#9a9a9a', 'end'),
595  ].join('')
596  const lines: [string, string][] =
597    g.phase === 'ready'
598      ? [['PIXEL DASH', '#fca044'], [`world ${g.name} · → to start · 1 2 3 pick a world`, '#fcfcfc']]
599      : g.phase === 'clear'
600        ? [[`WORLD ${g.name} CLEAR!`, '#fca044'], [`score ${g.score}`, '#fcfcfc']]
601      : g.phase === 'over'
602        ? [['GAME OVER', '#d82800'], [`score ${g.score} · r to retry`, '#fcfcfc']]
603        : g.phase === 'won'
604          ? [['ALL WORLDS CLEAR!', '#fca044'], [`score ${g.score} · r to play again`, '#fcfcfc']]
605          : []
606  const banner = lines.length
607    ? `<rect x="${pxW * 0.12}" y="${HUD_PX + 14}" width="${pxW * 0.76}" height="19" rx="2" fill="#101020" fill-opacity="0.88"/>` +
608      t(pxW / 2, HUD_PX + 23, lines[0]![0], lines[0]![1], 'middle', 7) +
609      t(pxW / 2, HUD_PX + 30, lines[1]![0], lines[1]![1], 'middle', 4.5)
610    : ''
611  const h = pxH + HUD_PX
612  return (
613    `<svg xmlns="http://www.w3.org/2000/svg" width="${pxW * 8}" height="${h * 8}" viewBox="0 0 ${pxW} ${h}" shape-rendering="crispEdges" font-family="ui-monospace, Consolas, monospace">` +
614    `<rect width="${pxW}" height="${HUD_PX}" fill="#0c0c0c"/><rect y="${HUD_PX}" width="${pxW}" height="${pxH}" fill="${sky}"/>` +
615    parts.join('') +
616    hud +
617    banner +
618    `</svg>`
619  )
620}
621
622type Cell = { ch: string; fg: string; bg: string; bold?: boolean }
623type Props = { best?: number; mode?: 'text' | 'svg' }
624
625// the desktop pane shows this many tiles across when it draws SVG
626const SVG_TILES_ACROSS = 16
627type State = { game: Game; frame: number }
628
629const PixelDash: ClientModule<JsonValue, State> = (props, surface: ClientSurface<State>) => {
630  const { Box, Text } = surface.elements
631  const best = Math.max(Number((props as Props | null)?.best ?? 0), 0)
632  const isSvg = (props as Props | null)?.mode === 'svg'
633
634  if (surface.state === undefined) {
635    const game = newGame()
636    surface.every(TICK_MS, () => {
637      step(game)
638      surface.setState({ game, frame: game.ticks })
639    })
640    surface.onKey(ev => {
641      onKey(game, ev.key)
642      surface.setState({ game, frame: game.ticks })
643    })
644    surface.onPointer(() => undefined)
645    surface.setState({ game, frame: 0 })
646    return <Text dimColor>loading…</Text>
647  }
648
649  const g = surface.state.game
650  const pxW = isSvg ? SVG_TILES_ACROSS * TILE : Math.max(36, Math.min(surface.columns || 72, 160))
651  const pxH = H * TILE
652  const sky = THEMES[g.theme].sky
653  const levelPx = levelWidth(g) * TILE
654  const camPx = Math.max(0, Math.min(levelPx - pxW, Math.round(g.x * TILE - pxW / 3)))
655
656  // the pixel buffer
657  const buf: string[] = new Array(pxW * pxH).fill(sky)
658  const blit = (spr: string[], px: number, py: number) => {
659    for (let sy = 0; sy < spr.length; sy++) {
660      const line = spr[sy]!
661      const y = py + sy
662      if (y < 0 || y >= pxH) continue
663      for (let sx = 0; sx < line.length; sx++) {
664        const c = line[sx]!
665        if (c === '.') continue
666        const x = px + sx
667        if (x < 0 || x >= pxW) continue
668        buf[y * pxW + x] = PAL[c] ?? sky
669      }
670    }
671  }
672
673  const firstTile = Math.floor(camPx / TILE)
674  const lastTile = Math.ceil((camPx + pxW) / TILE)
675  for (let ty = 0; ty < H; ty++) {
676    for (let tx = firstTile; tx <= lastTile; tx++) {
677      const t = tileAt(g, tx, ty)
678      if (t === ' ') continue
679      const spr = tileSprite(g, t, tx, ty)
680      if (!spr) continue
681      const bump = g.bumps.find(b => b.x === tx && b.y === ty)
682      blit(spr, tx * TILE - camPx, ty * TILE - (bump ? Math.min(bump.ttl, 6 - bump.ttl, 2) : 0))
683    }
684  }
685  for (const en of g.enemies) {
686    if (en.isDead && en.squashed <= 0) continue
687    const spr = en.isDead ? GOOMBA.flat : g.ticks % 10 < 5 ? GOOMBA.a : GOOMBA.b
688    blit(spr, Math.round(en.x * TILE) - camPx, Math.round(en.y * TILE))
689  }
690  if (g.phase !== 'over') {
691    const moving = g.vx !== 0 && g.isOnGround
692    const base =
693      g.phase === 'dying' ? HERO.dead : !g.isOnGround ? HERO.jump : moving && g.ticks % 6 < 3 ? HERO.walk : HERO.stand
694    blit(g.facing < 0 ? flip(base) : base, Math.round(g.x * TILE) - camPx, Math.round(g.y * TILE))
695  }
696
697  // score pop-ups as 3×5 pixel digits, so the picture stays all blocks
698  for (const p of g.popups) {
699    let px = Math.round(p.x * TILE) - camPx
700    for (const d of p.text) {
701      blit(DIGITS[d] ?? [], px, Math.round(p.y * TILE))
702      px += 4
703    }
704  }
705
706  // A run ended: hand the hooks module the score once.
707  const isEnded = g.phase === 'over' || g.phase === 'won'
708  const score = isEnded && !g.isReported ? g.score : undefined
709  if (isEnded) g.isReported = true
710  const pad = (n: number, w: number) => String(n).padStart(w, '0')
711  const shownBest = Math.max(best, isEnded ? g.score : 0)
712
713  if (isSvg) {
714    surface.post(score === undefined ? { svg: toSvg(buf, pxW, pxH, g, shownBest) } : { svg: toSvg(buf, pxW, pxH, g, shownBest), score })
715    // the strip under the picture: a click here gives the game the keys
716    return (
717      <Box key="focus" backgroundColor="#d82800" paddingX={1} justifyContent="center">
718        <Text bold color="#fcfcfc">
719          ▶ CLICK HERE TO PLAY · ← → run · ↑ / space jump · r restart · Esc stop
720        </Text>
721      </Box>
722    )
723  }
724  if (score !== undefined) surface.post({ score })
725
726  // two pixels per cell, every cell a block glyph: the desktop font is not
727  // monospace, and a space there is narrower than a block
728  const rowsCount = pxH / 2
729  const rows = []
730  for (let r = 0; r < rowsCount; r++) {
731    const runs: Cell[] = []
732    for (let x = 0; x < pxW; x++) {
733      const top = buf[2 * r * pxW + x]!
734      const bottom = buf[(2 * r + 1) * pxW + x]!
735      const c: Cell = top === bottom ? { ch: '█', fg: top, bg: top } : { ch: '▀', fg: top, bg: bottom }
736      const last = runs[runs.length - 1]
737      if (last && last.ch[0] === c.ch && last.fg === c.fg && last.bg === c.bg) last.ch += c.ch
738      else runs.push(c)
739    }
740    rows.push(
741      <Box key={`r${r}`}>
742        {runs.map(run => (
743          <Text color={run.fg} backgroundColor={run.bg}>
744            {run.ch}
745          </Text>
746        ))}
747      </Box>,
748    )
749  }
750
751  // banners float above the picture instead of replacing its cells
752  const banners: [string, string][] =
753    g.phase === 'ready'
754      ? [['P I X E L   D A S H', '#fca044'], [`world ${g.name} · → to start · 1 2 3 pick a world`, '#fcfcfc']]
755      : g.phase === 'clear'
756        ? [[`W O R L D   ${g.name}   C L E A R !`, '#fca044'], [`score ${g.score}`, '#fcfcfc']]
757        : g.phase === 'over'
758          ? [['G A M E   O V E R', '#d82800'], [`score ${g.score}  ·  r to retry`, '#fcfcfc']]
759          : g.phase === 'won'
760            ? [['A L L   W O R L D S   C L E A R !', '#fca044'], [`score ${g.score}  ·  r to play again`, '#fcfcfc']]
761            : []
762  const overlay =
763    banners.length > 0 ? (
764      <Box key="overlay" position="absolute" top={7} left={0} width={pxW} flexDirection="column" alignItems="center" gap={1}>
765        {banners.map(([text, color]) => (
766          <Box key={text} backgroundColor="#101020" paddingX={2}>
767            <Text bold color={color}>
768              {text}
769            </Text>
770          </Box>
771        ))}
772      </Box>
773    ) : null
774
775  return (
776    <Box flexDirection="column">
777      <Box key="hud" justifyContent="space-between" width={pxW}>
778        <Text bold color="#fcfcfc">
779          DASH {pad(g.score, 6)}
780        </Text>
781        <Text bold color="#fca044">
782          ● ×{pad(g.coins, 2)}
783        </Text>
784        <Text bold color="#d82800">
785          {'♥'.repeat(Math.max(0, g.lives))}
786        </Text>
787        <Text bold color="#fcfcfc">
788          WORLD {g.name}
789        </Text>
790        <Text dimColor>BEST {pad(shownBest, 6)}</Text>
791      </Box>
792      <Box key="picture" position="relative" flexDirection="column" width={pxW}>
793        {rows}
794        {overlay}
795      </Box>
796      <Text key="help" dimColor>
797        ← → run · ↑ / space jump · r restart · Esc gives the keys back
798      </Text>
799    </Box>
800  )
801}
802
803export default PixelDash
804
types/index.d.ts 8 lines
1export type Frame = string
2
3declare module 'claude-code' {
4  interface PluginState {
5    'pixel-dash': { frame: Frame | null }
6  }
7}
8