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

A Claude Code mod that replaces Claude's working mark with 8-bit superhero sprites.
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.
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.
| Command | What it does |
|---|---|
/hero | Show the current setting and the roster |
/hero thor | Pin one hero (loose matching: spider, panther, cap) |
/hero shuffle | Go back to rotating through all eight |
Your pin is remembered across sessions.
It is a Claude Code mod: a plugin of function hooks that hook ui.render for the Spinner and running ToolUse components.
claude plugin validate plugins/marvel-8bit
claude plugin test plugins/marvel-8bit
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.
hooks/register.tsx 136 lines1import { 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}
136hooks/sprites.ts 286 lines1import 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}
286types/index.d.ts 16 lines1export 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