SLOPSHOPPER

marvel-8bit

Replaces Claude's working mark with 8-bit Marvel superhero sprites

newspinnerrowscommand
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · marvel-8bit
› fix the failing auth test and add an audit log call ⏺ 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 › /hero ⎿ marvel-8bit: Shuffling through the heroes. Use /hero <name> or /hero shuffle. Heroes: Iron Man, Captain America, Spider-Man ▄▀▀▀▀▀▀▄ Thinking ▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Spinner
▄▀▀▀▀▀▀▄ Thinking ▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀
README

claude-code-marvel-8bit

A Claude Code mod that replaces Claude's working mark with 8-bit superhero sprites.

The eight heroes

Iron Man, Captain America, Spider-Man, Hulk, Thor, Black Panther, Wolverine and Deadpool. Each is a 12×12 sprite drawn in code (no image files), so the whole mod is a few hundred lines of TypeScript.

Install

claude plugin marketplace add arjkul/claude-code-marvel-8bit
claude plugin install marvel-8bit@marvel-8bit

Start a new session and send a prompt. Turn it off with claude plugin disable marvel-8bit@marvel-8bit.

Use

CommandWhat it does
/heroShow the current setting and the roster
/hero thorPin one hero (loose matching: spider, panther, cap)
/hero shuffleGo back to rotating through all eight

Your pin is remembered across sessions.

How it works

It is a Claude Code mod: a plugin of function hooks that hook ui.render for the Spinner and running ToolUse components.

  • Terminal: the sprite is a colored cell grid (half-block characters), with the working word beside it.
  • Desktop app: the sprite is an SVG. In shuffle mode all eight heroes live in one self-animating image (one every 2.5 seconds), because the desktop app drops a custom mark as soon as the tree under it changes.

Limits

  • The hero shows on the working spinner and on running command rows. It does not appear on reply text or in hidden thinking blocks, and the app icon, sidebar logo and welcome logo are not exposed to mods.
  • On the desktop, the step text next to the mark (such as "Running a command") is not shown in shuffle mode, for the reason above.
  • The mod API is early access and may change between Claude Code releases. Built and tested on Claude Code 2.1.288.

Develop

claude plugin validate plugins/marvel-8bit
claude plugin test plugins/marvel-8bit

Disclaimer

Unofficial fan project. The sprites are original pixel-art interpretations of characters owned by Marvel. This project is not affiliated with, endorsed by or sponsored by Marvel, Disney or Anthropic. Marvel character names and likenesses belong to their owners; the MIT license covers the code only.

Source 3 files
hooks/register.tsx 136 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { HeroId } from '../types'
5import { HEROES, SIZE, findHero, toCells, toCycleSvg, toSvg } from './sprites'
6
7const turn = atom({ plugin: 'marvel-8bit', key: 'turn' } as const, 0)
8const pinned = atom({ plugin: 'marvel-8bit', key: 'pinned' } as const, null)
9
10const STORE_KEY = 'pinned'
11const ROSTER = HEROES.map(hero => hero.name).join(', ')
12
13export const register: Register = on => {
14  on('session.start', async ($, e, next) => {
15    await $.command.register({
16      name: 'hero',
17      description: 'Pin an 8-bit Marvel hero as the working mark, or shuffle',
18    })
19
20    const stored = await $.store.get(STORE_KEY)
21    const hero = HEROES.find(one => one.id === stored)
22    if (hero) {
23      await update($, pinned, () => hero.id)
24    }
25
26    return next(e)
27  })
28
29  // Each turn opens on the next hero; a pinned hero ignores the count.
30  on('turn.start', async ($, e, next) => {
31    await update($, turn, n => n + 1)
32
33    return next(e)
34  })
35
36  on('command.run', { command: 'hero' }, async ($, e) => {
37    const args = e.args.trim()
38
39    if (args === '') {
40      const id = await read($, pinned)
41      const now = HEROES.find(one => one.id === id)
42
43      return {
44        text: `${now ? `Pinned: ${now.name}.` : 'Shuffling through the heroes.'} Use /hero <name> or /hero shuffle. Heroes: ${ROSTER}.`,
45      }
46    }
47
48    if (args.toLowerCase() === 'shuffle') {
49      await update($, pinned, () => null)
50      await $.store.delete(STORE_KEY)
51
52      return { text: 'Shuffling through the heroes.' }
53    }
54
55    const hero = findHero(args)
56    if (!hero) {
57      return { text: `No hero matches "${args}". Heroes: ${ROSTER}.` }
58    }
59
60    const id: HeroId = hero.id
61    await update($, pinned, () => id)
62    await $.store.set(STORE_KEY, id)
63
64    return { text: `Pinned: ${hero.name}.` }
65  })
66
67  on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
68    const id = await read($, pinned)
69    const first = (await read($, turn)) % HEROES.length
70    const pin = HEROES.find(one => one.id === id)
71
72    if (e.surface === 'terminal') {
73      const { Box, Raster, Text } = $.ui.resolve(e)
74      const bytes = new Uint8Array(toCells(pin ?? HEROES[first]!).buffer)
75      const cells = btoa(String.fromCharCode(...bytes))
76      const label = `${e.props.message ?? e.props.word}${e.props.suffix}`
77
78      return (
79        <Box flexDirection="row" alignItems="flex-end">
80          <Raster key="hero" columns={SIZE} rows={SIZE / 2} cells={cells} />
81          <Text> {label}</Text>
82        </Box>
83      )
84    }
85
86    if (e.surface === 'desktop') {
87      const { Box, Svg } = $.ui.resolve(e)
88
89      // The mark alone, the same tree through the whole turn: the desktop
90      // goes back to its own mark once the row's tree changes under it.
91      return (
92        <Box flexDirection="row" alignItems="center">
93          <Svg
94            source={pin ? toSvg(pin) : toCycleSvg(first)}
95            alt={pin ? pin.name : 'Marvel heroes'}
96            width={24}
97            height={24}
98          />
99        </Box>
100      )
101    }
102
103    return next(e)
104  })
105
106  // On the desktop a running tool call's row stands where the spinner was,
107  // under a mark of its own: the hero takes that too, until the call ends.
108  on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
109    if (e.surface !== 'desktop' || !e.props.isRunning) {
110      return next(e)
111    }
112
113    const id = await read($, pinned)
114    const first = (await read($, turn)) % HEROES.length
115    const pin = HEROES.find(one => one.id === id)
116    const { input, tool } = e.props
117    const said =
118      typeof input === 'object' && input !== null && 'description' in input
119        ? input.description
120        : undefined
121    const { Box, Svg, Text } = $.ui.resolve(e)
122
123    return (
124      <Box flexDirection="row" alignItems="center">
125        <Svg
126          source={pin ? toSvg(pin) : toCycleSvg(first)}
127          alt={pin ? pin.name : 'Marvel heroes'}
128          width={24}
129          height={24}
130        />
131        <Text dimColor> {typeof said === 'string' ? said : tool}</Text>
132      </Box>
133    )
134  })
135}
136
hooks/sprites.ts 286 lines
1import type { HeroId } from '../types'
2
3export const SIZE = 12
4
5export type Hero = {
6  id: HeroId
7  name: string
8  // One letter per pixel, SIZE rows of SIZE; '.' is transparent, 'W' the eyes.
9  rows: readonly string[]
10  palette: Readonly<Record<string, number>>
11}
12
13const WHITE = 0xffffff
14const BLACK = 0x15151c
15const SKIN = 0xf2c193
16
17export const HEROES: readonly Hero[] = [
18  {
19    id: 'iron-man',
20    name: 'Iron Man',
21    palette: { R: 0xb3171e, Y: 0xf2c230, W: 0x9fe8ff, K: BLACK },
22    rows: [
23      '...RRRRRR...',
24      '..RRRRRRRR..',
25      '.RRRYYYYRRR.',
26      '.RRYYYYYYRR.',
27      '.RYYYYYYYYR.',
28      '.RYWWYYWWYR.',
29      '.RYYYYYYYYR.',
30      '.RRYYYYYYRR.',
31      '.RRYYKKYYRR.',
32      '..RYYYYYYR..',
33      '..RRYYYYRR..',
34      '...RRRRRR...',
35    ],
36  },
37  {
38    id: 'captain-america',
39    name: 'Captain America',
40    palette: { B: 0x1f4fa8, A: WHITE, W: WHITE, S: SKIN, K: BLACK },
41    rows: [
42      '...BBBBBB...',
43      '..BBBBBBBB..',
44      '.BBBBAABBBB.',
45      '.BBBABBABBB.',
46      'ABBBAAAABBBA',
47      '.BBBABBABBB.',
48      '.BWWBBBBWWB.',
49      '.BSSSSSSSSB.',
50      '.BSSSSSSSSB.',
51      '..SSSKKSSS..',
52      '..SSSSSSSS..',
53      '...SSSSSS...',
54    ],
55  },
56  {
57    id: 'spider-man',
58    name: 'Spider-Man',
59    palette: { R: 0xd81f26, K: BLACK, W: WHITE },
60    rows: [
61      '...RRRRRR...',
62      '..RRRRRRRR..',
63      '.RRRRRRRRRR.',
64      '.RKKKRRKKKR.',
65      '.RKWWKKWWKR.',
66      '.RRKWKKWKRR.',
67      '.RRRKRRKRRR.',
68      '.RRRRRRRRRR.',
69      '..RRRRRRRR..',
70      '..RRRRRRRR..',
71      '...RRRRRR...',
72      '....RRRR....',
73    ],
74  },
75  {
76    id: 'hulk',
77    name: 'Hulk',
78    palette: { G: 0x5cb83a, D: 0x1f4d1a, W: WHITE, K: BLACK },
79    rows: [
80      '..DDDDDDDD..',
81      '.DDDDDDDDDD.',
82      '.DDGGGGGGDD.',
83      '.DGGGGGGGGD.',
84      '.GDDDGGDDDG.',
85      '.GGWKGGKWGG.',
86      '.GGGGGGGGGG.',
87      '.GGGGDDGGGG.',
88      '.GGKKKKKKGG.',
89      '.GGGGGGGGGG.',
90      '..GGGGGGGG..',
91      '...GGGGGG...',
92    ],
93  },
94  {
95    id: 'thor',
96    name: 'Thor',
97    palette: { H: 0xaab4c4, A: WHITE, Y: 0xf0cf5a, S: SKIN, W: 0x3f8fe0, K: BLACK },
98    rows: [
99      'A..HHHHHH..A',
100      'AA.HHHHHH.AA',
101      '.AHHHHHHHHA.',
102      '..HHHHHHHH..',
103      '.YHSSSSSSHY.',
104      '.YSSSSSSSSY.',
105      '.YSWSSSSWSY.',
106      '.YSSSSSSSSY.',
107      '.YSYYYYYYSY.',
108      '.YYYYKKYYYY.',
109      '..YYYYYYYY..',
110      '...YYYYYY...',
111    ],
112  },
113  {
114    id: 'black-panther',
115    name: 'Black Panther',
116    palette: { P: 0x3a3a52, L: 0xb9c0d4, V: 0x8f5cf0, W: WHITE },
117    rows: [
118      '.PP......PP.',
119      '.PPP....PPP.',
120      '.PPPPPPPPPP.',
121      '.PPPPPPPPPP.',
122      '.PPPPPPPPPP.',
123      '.PWWPPPPWWP.',
124      '.PPWPPPPWPP.',
125      '.PPPPLLPPPP.',
126      '.PLPPPPPPLP.',
127      '..PLLPPLLP..',
128      '..PPVVVVPP..',
129      '...PPPPPP...',
130    ],
131  },
132  {
133    id: 'wolverine',
134    name: 'Wolverine',
135    palette: { Y: 0xf5d21f, K: BLACK, W: WHITE, S: SKIN },
136    rows: [
137      'K..........K',
138      'KK........KK',
139      'KKY......YKK',
140      'KKYYYYYYYYKK',
141      '.KKYYYYYYKK.',
142      '.YKKKYYKKKY.',
143      '.YKWWYYWWKY.',
144      '.YYKKYYKKYY.',
145      '.YYSSSSSSYY.',
146      '..YSSKKSSY..',
147      '..YSSSSSSY..',
148      '...SSSSSS...',
149    ],
150  },
151  {
152    id: 'deadpool',
153    name: 'Deadpool',
154    palette: { R: 0x9c1218, K: BLACK, W: WHITE },
155    rows: [
156      '...RRRRRR...',
157      '..RRRRRRRR..',
158      '.RRRRRRRRRR.',
159      '.RKKRRRRKKR.',
160      '.RKKKRRKKKR.',
161      '.RKWKRRKWKR.',
162      '.RKWKRRKWKR.',
163      '.RKKKRRKKKR.',
164      '.RRKKRRKKRR.',
165      '..RRRRRRRR..',
166      '..RRRRRRRR..',
167      '...RRRRRR...',
168    ],
169  },
170]
171
172const hex = (color: number) => `#${color.toString(16).padStart(6, '0')}`
173
174// One <rect> per horizontal run of a colour; the eyes sit in a group that
175// blinks, so the mark still reads as working.
176export function toSvg(hero: Hero): string {
177  const body: string[] = []
178  const eyes: string[] = []
179
180  hero.rows.forEach((row, y) => {
181    let x = 0
182    while (x < row.length) {
183      const letter = row[x]!
184      let end = x + 1
185      while (end < row.length && row[end] === letter) {
186        end += 1
187      }
188      const color = hero.palette[letter]
189      if (color !== undefined) {
190        const rect = `<rect x="${x}" y="${y}" width="${end - x}" height="1" fill="${hex(color)}"/>`
191        ;(letter === 'W' ? eyes : body).push(rect)
192      }
193      x = end
194    }
195  })
196
197  const blink =
198    '<animate attributeName="opacity" values="1;0;1" keyTimes="0;0.86;0.94" calcMode="discrete" dur="1.6s" repeatCount="indefinite"/>'
199
200  return (
201    `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${SIZE} ${SIZE}" shape-rendering="crispEdges">` +
202    body.join('') +
203    `<g>${blink}${eyes.join('')}</g>` +
204    '</svg>'
205  )
206}
207
208export const CYCLE_SECONDS = 20
209
210// Every hero in one document, each shown for its slice of the cycle from
211// `start` on: the drawing shuffles by itself, so its tree never changes. The
212// first is visible without the animation, where a surface draws it still.
213export function toCycleSvg(start: number): string {
214  const count = HEROES.length
215  const groups = HEROES.map((_, i) => {
216    const hero = HEROES[(start + i) % count]!
217    const svg = toSvg(hero)
218    const rects = svg.slice(svg.indexOf('>') + 1, -'</svg>'.length)
219    const [from, to] = [i / count, (i + 1) / count]
220    const frames =
221      i === 0
222        ? `values="1;0" keyTimes="0;${to}"`
223        : to === 1
224          ? `values="0;1" keyTimes="0;${from}"`
225          : `values="0;1;0" keyTimes="0;${from};${to}"`
226
227    return (
228      `<g opacity="${i === 0 ? 1 : 0}">` +
229      `<animate attributeName="opacity" ${frames} calcMode="discrete" dur="${CYCLE_SECONDS}s" repeatCount="indefinite"/>` +
230      `${rects}</g>`
231    )
232  })
233
234  return (
235    `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${SIZE} ${SIZE}" shape-rendering="crispEdges">` +
236    groups.join('') +
237    '</svg>'
238  )
239}
240
241const DEFAULT = 0x01000000
242const UPPER_HALF = 0x2580
243const LOWER_HALF = 0x2584
244const SPACE = 0x20
245
246// Two pixel rows per terminal row through half blocks: SIZE columns by
247// SIZE / 2 rows of [codePoint, foreground, background], as RasterProps packs.
248export function toCells(hero: Hero): Uint32Array {
249  const words = new Uint32Array(SIZE * (SIZE / 2) * 3)
250  let at = 0
251
252  for (let y = 0; y < SIZE; y += 2) {
253    for (let x = 0; x < SIZE; x += 1) {
254      const top = hero.palette[hero.rows[y]![x]!]
255      const bottom = hero.palette[hero.rows[y + 1]![x]!]
256
257      if (top !== undefined) {
258        words.set([UPPER_HALF, top, bottom ?? DEFAULT], at)
259      } else if (bottom !== undefined) {
260        words.set([LOWER_HALF, bottom, DEFAULT], at)
261      } else {
262        words.set([SPACE, DEFAULT, DEFAULT], at)
263      }
264      at += 3
265    }
266  }
267
268  return words
269}
270
271const fold = (text: string) => text.toLowerCase().replace(/[^a-z]/g, '')
272
273// `thor`, `Iron Man`, `spider`, `cap`: the first hero whose name starts with
274// or holds what was typed.
275export function findHero(query: string): Hero | undefined {
276  const wanted = fold(query)
277  if (wanted === '') {
278    return undefined
279  }
280
281  return (
282    HEROES.find(hero => fold(hero.name).startsWith(wanted)) ??
283    HEROES.find(hero => fold(hero.name).includes(wanted))
284  )
285}
286
types/index.d.ts 16 lines
1export type HeroId =
2  | 'iron-man'
3  | 'captain-america'
4  | 'spider-man'
5  | 'hulk'
6  | 'thor'
7  | 'black-panther'
8  | 'wolverine'
9  | 'deadpool'
10
11declare module 'claude-code' {
12  interface PluginState {
13    'marvel-8bit': { turn: number; pinned: HeroId | null }
14  }
15}
16