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

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.
<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">
/plugin marketplace add Talap-creator/pixel-dash
/plugin install pixel-dash@pixel-dash
/reload-plugins
/play — the game opens in a side pane.| key | |
|---|---|
→ ← / D A | run (hold it) |
↑ / space / W | jump |
R | start over |
1 2 3 | on the title screen: start in that world |
Esc | give the keys back to the prompt |
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-1 | overworld | pipes, ? blocks, two pits, a staircase to the flag |
| 1-2 | underground | blue stone, a brick roof, pillars to climb, a low tunnel full of coins |
| 1-3 | treetops | almost no ground: hop between treetops of different heights over the void |
? blocks you hit from below (+200)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.
▀, 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.
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.
MIT. Pixel Dash is an homage to classic side-scrollers, with its own sprites; it is not affiliated with any game company.
hooks/register.tsx 64 lines1import { 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}
64hooks/game.tsx 804 lines1import 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, '&').replace(/</g, '<').replace(/>/g, '>')
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
804types/index.d.ts 8 lines1export type Frame = string
2
3declare module 'claude-code' {
4 interface PluginState {
5 'pixel-dash': { frame: Frame | null }
6 }
7}
8