A haunted Clawd in the spinner: pumpkin, ghost, zombie, witch and devil, cycling with Halloween effects

A Claude Code mod that haunts the spinner. While Claude works, a small animated Clawd (the size of the welcome screen mascot, 3 rows by 12 columns) takes the spinner's place and cycles through five Halloween costumes, nine seconds each, swapping in a puff of smoke:
| Costume | Effects | Spinner words |
|---|---|---|
| Pumpkin Clawd | carved face lit by a flickering candle, a stem, a flapping bat | Carving, Scooping, Flickering, Grinning |
| Ghost Clawd | bobs up and down, wavy sheet hem, drifting fog, faint sparkles | Haunting, Lurking, Creeping, Possessing, Spooking |
| Zombie Clawd | rotting green with a stitched scar, mismatched eyes, sways as he shambles, reaches out, flies buzz overhead, a tombstone | Shambling, Groaning, Lurching, Rotting, Braaaining |
| Witch Clawd | pointed hat with a gold buckle, glowing green eyes that blink, moon, stars, lightning | Brewing, Conjuring, Hexing, Bewitching, Cackling, Summoning |
| Devil Clawd | horns, glowing yellow eyes, a smirk, a swishing arrow tail, standing in hellfire with rising embers | Scheming, Tempting, Smoldering, Bargaining, Pitchforking |
The word beside him comes from his costume, so it changes with every costume swap, and each costume moves on to its next word every time it comes around. Nothing else on screen changes, and he is gone the moment the turn ends.

He also comes as a bigger macOS screensaver.
/plugin install halloween --marketplace whirlwin/claude-code-halloween
Answer y to add the marketplace, then pick a scope.
/plugin update installs from the local clone of the marketplace and does not fetch it first, so refresh the clone before updating:
claude plugin marketplace update claude-code-halloween
claude plugin update halloween@claude-code-halloween
Then run /reload-plugins in an open session, or restart Claude Code.
| Command | What it does |
|---|---|
/halloween | hide Clawd, or bring him back |
/halloween next | skip straight to the next costume |
The same five costumes also come as a macOS screensaver, big enough to fill the screen. It plays the mod's own frames, baked from hooks/scene.ts at build time. Each quadrant pixel is drawn as a block twice as tall as it is wide, like a terminal cell, so Clawd keeps his shape. Below him are the costume's word and how long the screensaver has been running. He moves to a new spot with every costume swap, behind the puff of smoke, so nothing burns in.

Building needs macOS 13 or later, the Xcode command line tools and Node 22.18 or later (to run scene.ts without a compile step):
screensaver/build.sh --install
This builds screensaver/build/Halloween Clawd.saver and copies it into ~/Library/Screen Savers. Then pick Halloween Clawd in System Settings, Screen Saver. Leave out --install to only build it.
screensaver/render.swift loads a built saver the way macOS does and renders frames offscreen into one PNG. The build uses it for the thumbnails System Settings shows, and it drew the picture above:
swift screensaver/render.swift "screensaver/build/Halloween Clawd.saver" docs/screensaver.png 756 491 20 85 140 200 260 1500
Mods can only draw where Claude Code exposes a render site, and the welcome screen logo is not one of them (as of Claude Code 2.1.296), so Clawd moves into the spinner instead. He is a Raster of quadrant block glyphs traced from the welcome screen, repainted with $.ui.blit on a 150 ms clock rather than redrawing the whole tree.
Drawing a tree in the spinner's place drops what the engine draws after the word (elapsed time, tokens, effort), so the mod shows its own elapsed time. The token count is not shown. In the desktop app only the word changes.
claude --plugin-dir .
claude plugin validate .
claude plugin test .
The plugin is named halloween rather than after the repository because Claude Code reserves plugin names starting with claude-.
hooks/register.tsx 94 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import { FRAMES_PER_VARIANT, SCENE_COLUMNS, SCENE_ROWS, TICK_MS, frame, variantAt, wordAt } from './scene'
5
6const isHidden = atom({ plugin: 'halloween', key: 'isHidden' } as const, false)
7
8const RASTER = 'clawd'
9const TICKS_PER_SECOND = Math.round(1000 / TICK_MS)
10
11function elapsed(ms: number): string {
12 const s = Math.floor(ms / 1000)
13 return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${s % 60}s`
14}
15
16export const register: Register = on => {
17 let tick = 0
18 // The spinner as last drawn, which the timer repaints between draws.
19 let spinner: string | null = null
20 // When each spinner first drew: drawing a tree replaces the engine's own
21 // elapsed time, so the mod keeps its own.
22 const startedAt = new Map<string, number>()
23
24 on('session.start', async ($, e, next) => {
25 await $.command.register({
26 name: 'halloween',
27 description: 'Show or hide the haunted Clawd in the spinner; "next" summons the next one',
28 argumentHint: '[next]',
29 })
30
31 $.clock.every(TICK_MS, () => {
32 tick += 1
33 if (spinner === null) return
34 // The name and timer are text, so a new costume or second is a redraw.
35 if (tick % FRAMES_PER_VARIANT === 0 || tick % TICKS_PER_SECOND === 0) {
36 $.ui.invalidate('ui.render')
37 return
38 }
39 const requestId = spinner
40 void $.ui.blit({ requestId, key: RASTER, cells: frame(tick) }).then(result => {
41 if (result.deny !== undefined && spinner === requestId) {
42 spinner = null
43 startedAt.delete(requestId)
44 }
45 })
46 })
47
48 return next(e)
49 })
50
51 on('command.run', { command: 'halloween' }, async ($, e) => {
52 if (e.args.trim() === 'next') {
53 tick = (Math.floor(tick / FRAMES_PER_VARIANT) + 1) * FRAMES_PER_VARIANT
54 await update($, isHidden, () => false)
55 $.ui.invalidate('ui.render')
56 return { text: `${variantAt(tick).name} rises.` }
57 }
58 const hidden = await update($, isHidden, was => !was)
59 return { text: hidden ? 'Clawd returns to the crypt.' : 'Clawd rises again.' }
60 })
61
62 on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
63 if (await read($, isHidden)) {
64 spinner = null
65 return next(e)
66 }
67 // The costume's own word, not one derived from the engine's: that word
68 // can stay the same turn after turn, which pinned the spinner to one.
69 const word = wordAt(tick)
70 if (e.surface !== 'terminal') {
71 return next({ ...e, props: { ...e.props, word } })
72 }
73
74 const now = await $.clock.now()
75 const start = startedAt.get(e.requestId) ?? now
76 startedAt.set(e.requestId, start)
77 spinner = e.requestId
78 const variant = variantAt(tick)
79 const { Box, Raster, Text } = $.ui.resolve(e)
80
81 return (
82 <Box flexDirection="row">
83 <Raster key={RASTER} columns={SCENE_COLUMNS} rows={SCENE_ROWS} cells={frame(tick)} />
84 <Box flexDirection="column" justifyContent="center" marginLeft={1}>
85 <Text>
86 <Text color={variant.color}>{e.props.message ?? word}{e.props.suffix}</Text>
87 <Text dimColor> ({elapsed(now - start)})</Text>
88 </Text>
89 </Box>
90 </Box>
91 )
92 })
93}
94hooks/scene.ts 283 lines1// The spinner's picture: a canvas of quadrant pixels, four to a terminal cell
2// (the glyphs the welcome screen draws Clawd with), packed into Raster cells.
3
4export const DEFAULT = 0x01000000
5export const SCENE_COLUMNS = 12
6export const SCENE_ROWS = 3
7export const TICK_MS = 150
8export const FRAMES_PER_VARIANT = 60
9
10const WIDTH = SCENE_COLUMNS * 2
11const HEIGHT = SCENE_ROWS * 2
12const POOF_FRAMES = 3
13
14// Clawd as the welcome screen draws him, one character per quadrant pixel.
15const CLAWD = [
16 '...############...',
17 '...##.######.##...',
18 '.################.',
19 '...############...',
20 '....#.#....#.#....',
21]
22const CLAWD_WIDTH = 18
23const EYES = [5, 12]
24const TOP = 1
25// The columns right of Clawd where each costume's effect plays.
26const ZONE = CLAWD_WIDTH
27
28type Canvas = Uint32Array
29
30export type Variant = {
31 name: string
32 color: string
33 // What the spinner says while this costume is on.
34 words: readonly string[]
35 draw: (c: Canvas, t: number) => void
36}
37
38function rnd(a: number, b: number, c = 0): number {
39 let h = Math.imul(a, 374761393) ^ Math.imul(b, 668265263) ^ Math.imul(c, 2147483647)
40 h = Math.imul(h ^ (h >>> 13), 1274126177)
41 return ((h ^ (h >>> 16)) >>> 0) / 4294967296
42}
43
44function set(c: Canvas, x: number, y: number, color: number): void {
45 if (x >= 0 && x < WIDTH && y >= 0 && y < HEIGHT) c[y * WIDTH + x] = color
46}
47
48function get(c: Canvas, x: number, y: number): number {
49 return c[y * WIDTH + x] ?? DEFAULT
50}
51
52function body(c: Canvas, top: number, color: (x: number, y: number) => number, rows = CLAWD.length, left = 0): void {
53 for (let y = 0; y < rows; y++) {
54 const line = CLAWD[y] ?? ''
55 for (let x = 0; x < CLAWD_WIDTH; x++) {
56 if (line[x] === '#') set(c, left + x, top + y, color(x, y))
57 }
58 }
59}
60
61const FLAME = [0xffd23f, 0xffb000, 0xfff1a8, 0xffc83d]
62
63const pumpkin: Variant = {
64 name: 'Pumpkin Clawd',
65 color: '#FF7518',
66 words: ['Carving', 'Scooping', 'Flickering', 'Grinning'],
67 draw(c, t) {
68 body(c, TOP, x => (x === 6 || x === 11 ? 0xd9590f : 0xff7518))
69 const glow = FLAME[Math.floor(rnd(t, 7) * FLAME.length)] ?? 0xffd23f
70 for (const eye of EYES) set(c, eye, TOP + 1, glow)
71 for (const x of [6, 8, 10, 12]) set(c, x, TOP + 3, glow)
72 set(c, 8, 0, 0x4e8a2e)
73 set(c, 9, 0, 0x4e8a2e)
74 set(c, 10, 0, 0x6fbf3a)
75 const bx = ZONE + 1 + (Math.floor(t / 3) % 3)
76 const by = Math.floor(t / 2) % 4 < 2 ? 1 : 2
77 const isUp = t % 4 < 2
78 set(c, bx, by + (isUp ? 0 : 1), 0x9b6bd6)
79 set(c, bx + 2, by + (isUp ? 0 : 1), 0x9b6bd6)
80 set(c, bx + 1, by + (isUp ? 1 : 0), 0x9b6bd6)
81 },
82}
83
84const ghost: Variant = {
85 name: 'Ghost Clawd',
86 color: '#E8ECF4',
87 words: ['Haunting', 'Lurking', 'Creeping', 'Possessing', 'Spooking'],
88 draw(c, t) {
89 const top = Math.floor(t / 4) % 2
90 body(c, top, () => 0xe8ecf4, 4)
91 const hem = Math.floor(t / 2) % 3
92 for (let x = 3; x <= 14; x++) {
93 if ((x + hem) % 3 !== 0) set(c, x, top + 4, 0xb8c4d6)
94 }
95 for (const eye of EYES) set(c, eye, top + 1, 0x2e3440)
96 set(c, 8, top + 2, 0x2e3440)
97 set(c, 9, top + 2, 0x2e3440)
98 for (let x = ZONE; x < WIDTH; x++) {
99 for (let y = 0; y < HEIGHT - 1; y++) {
100 if (rnd(x, y, Math.floor(t / 3)) < 0.06) set(c, x, y, 0x88c0d0)
101 }
102 }
103 for (let x = 0; x < WIDTH; x++) {
104 if (get(c, x, HEIGHT - 1) === DEFAULT && rnd(Math.floor((x + t) / 2), 9) < 0.5) {
105 set(c, x, HEIGHT - 1, 0x6c7a89)
106 }
107 }
108 },
109}
110
111const witch: Variant = {
112 name: 'Witch Clawd',
113 color: '#B38BE6',
114 words: ['Brewing', 'Conjuring', 'Hexing', 'Bewitching', 'Cackling', 'Summoning'],
115 draw(c, t) {
116 body(c, TOP, () => 0xd77757)
117 for (let x = 6; x <= 11; x++) set(c, x, 0, 0x6a2c9e)
118 for (let x = 1; x <= 16; x++) set(c, x, TOP, 0x3d1a5b)
119 set(c, 8, TOP, 0xffd700)
120 set(c, 9, TOP, 0xffd700)
121 const eyes = t % 25 === 0 ? 0xd77757 : 0x7cfc00
122 for (const eye of EYES) set(c, eye, TOP + 1, eyes)
123 for (const [x, y] of [[21, 0], [22, 0], [20, 1], [21, 1], [22, 1], [23, 1], [21, 2], [22, 2]] as const) {
124 set(c, x, y, 0xf5e6a8)
125 }
126 for (let i = 0; i < 4; i++) {
127 const x = ZONE + Math.floor(rnd(i, 1) * 3)
128 const y = Math.floor(rnd(i, 2) * HEIGHT)
129 if (rnd(i, Math.floor(t / 2)) > 0.4) set(c, x, y, 0xffffff)
130 }
131 if (t % 37 < 2) {
132 for (const [x, y] of [[20, 1], [19, 2], [20, 3], [21, 3], [20, 4], [19, 5]] as const) {
133 set(c, x, y, 0xfff7ae)
134 }
135 }
136 },
137}
138
139const ZOMBIE = 0x8db360
140const ROT = 0x5e7f3c
141
142const zombie: Variant = {
143 name: 'Zombie Clawd',
144 color: '#8DB360',
145 words: ['Shambling', 'Groaning', 'Lurching', 'Rotting', 'Braaaining'],
146 draw(c, t) {
147 const sway = Math.floor(t / 4) % 2
148 // Rot keyed to the pixel, not the frame, so it stays put as he shambles.
149 body(c, TOP, (x, y) => (rnd(x, y, 99) < 0.18 ? ROT : ZOMBIE), CLAWD.length, sway)
150 for (const x of [5, 6, 7, 8]) set(c, x + sway, TOP, x % 2 === 0 ? 0x4a2c2a : 0x8b1a1a)
151 set(c, (EYES[0] ?? 5) + sway, TOP + 1, 0xf0eac8)
152 set(c, (EYES[1] ?? 12) + sway, TOP + 1, 0xd64541)
153 for (const x of [7, 9, 11]) set(c, x + sway, TOP + 3, 0x3b2f2f)
154 const reach = t % 6 < 3 ? 1 : 2
155 for (let i = 1; i <= reach; i++) set(c, 16 + sway + i, TOP + 2, ZOMBIE)
156 for (let i = 0; i < 2; i++) set(c, 3 + ((t + 7 * i) % 12), 0, 0xc8d96f)
157 set(c, 22, 2, 0x8a8f98)
158 for (let y = 3; y < HEIGHT; y++) {
159 for (const x of [21, 22, 23]) set(c, x, y, x === 22 && y === 3 ? 0x5c616b : 0x8a8f98)
160 }
161 },
162}
163
164const DEVIL = 0xe74c3c
165const EMBER = [0xff4500, 0xff8c00, 0xffd23f]
166const TAIL = [
167 [[15, 4], [16, 4], [17, 4], [18, 3], [19, 2], [18, 1], [19, 1], [20, 1], [19, 0]],
168 [[15, 4], [16, 4], [17, 4], [18, 4], [19, 3], [20, 3], [20, 2], [21, 2], [22, 2], [21, 1]],
169] as const
170
171const devil: Variant = {
172 name: 'Devil Clawd',
173 color: '#E74C3C',
174 words: ['Scheming', 'Tempting', 'Smoldering', 'Bargaining', 'Pitchforking'],
175 draw(c, t) {
176 // Hellfire first, so Clawd stands in it rather than behind it. One color
177 // per cell: a cell shows two, and a third would turn to noise.
178 for (let x = 0; x < WIDTH; x++) {
179 const cell = Math.floor(x / 2)
180 if (rnd(cell, t, 5) < 0.6) set(c, x, HEIGHT - 1, EMBER[Math.floor(rnd(t, cell, 6) * EMBER.length)] ?? 0xff4500)
181 }
182 for (let i = 0; i < 2; i++) set(c, ZONE + 1 + i * 3, HEIGHT - 1 - ((t + 3 * i) % HEIGHT), 0xffd23f)
183 body(c, TOP, (_, y) => (y >= 3 ? 0xa93226 : DEVIL))
184 // Horns in his own red: a third color in the head's top cells would be dropped.
185 set(c, 4, 0, DEVIL)
186 set(c, 13, 0, DEVIL)
187 for (const eye of EYES) set(c, eye, TOP + 1, 0xffe347)
188 for (const x of [7, 8, 9, 10]) set(c, x, TOP + 3, 0x4a0e0e)
189 set(c, 11, TOP + 2, 0x4a0e0e)
190 for (const [x, y] of TAIL[Math.floor(t / 3) % TAIL.length] ?? []) set(c, x, y, DEVIL)
191 },
192}
193
194export const VARIANTS: readonly Variant[] = [pumpkin, ghost, zombie, witch, devil]
195
196export function variantAt(t: number): Variant {
197 return VARIANTS[Math.floor(t / FRAMES_PER_VARIANT) % VARIANTS.length] ?? pumpkin
198}
199
200// Each time a costume comes around again it says its next word.
201export function wordAt(t: number): string {
202 const v = variantAt(t)
203 const round = Math.floor(t / (FRAMES_PER_VARIANT * VARIANTS.length))
204 return v.words[round % v.words.length] ?? v.name
205}
206
207const SMOKE = [0x8b5fbf, 0x6fbf3a, 0xb0b0b0]
208
209// A puff of smoke over the first frames of each costume hides the swap.
210function poof(c: Canvas, t: number): void {
211 const f = t % FRAMES_PER_VARIANT
212 if (f >= POOF_FRAMES) return
213 const density = (POOF_FRAMES - f) / (POOF_FRAMES + 1)
214 for (let y = 0; y < HEIGHT; y++) {
215 for (let x = 0; x < CLAWD_WIDTH; x++) {
216 if (rnd(x, y, t) < density) set(c, x, y, SMOKE[Math.floor(rnd(y, x, t) * SMOKE.length)] ?? 0xb0b0b0)
217 }
218 }
219}
220
221// Indexed by which quadrants hold the foreground: 1 upper left, 2 upper
222// right, 4 lower left, 8 lower right.
223const QUADRANTS = ' ▘▝▀▖▌▞▛▗▚▐▜▄▙▟█'
224
225// A cell holds two colors, so a busier block keeps its two commonest and
226// paints the rest in the second.
227function cell(pixels: number[]): [number, number, number] {
228 const counts = new Map<number, number>()
229 for (const p of pixels) counts.set(p, (counts.get(p) ?? 0) + 1)
230 const ranked = [...counts.entries()].sort((a, b) => b[1] - a[1] || (a[0] === DEFAULT ? 1 : -1))
231 const fg = ranked.find(([color]) => color !== DEFAULT)?.[0]
232 if (fg === undefined) return [0x20, DEFAULT, DEFAULT]
233 const bg = counts.has(DEFAULT) ? DEFAULT : (ranked.find(([color]) => color !== fg)?.[0] ?? DEFAULT)
234 const mask = pixels.reduce((m, p, i) => (p === fg ? m | (1 << i) : m), 0)
235 return [QUADRANTS.charCodeAt(mask), fg, bg]
236}
237
238// The picture before it is packed into cells, one color per quadrant pixel.
239// The screensaver bakes these, since it has no two-colors-per-cell limit.
240export function pixels(t: number): Uint32Array {
241 const c: Canvas = new Uint32Array(WIDTH * HEIGHT).fill(DEFAULT)
242 variantAt(t).draw(c, t)
243 poof(c, t)
244 return c
245}
246
247export function paint(t: number): Uint32Array {
248 const c = pixels(t)
249 const cells = new Uint32Array(SCENE_COLUMNS * SCENE_ROWS * 3)
250 for (let row = 0; row < SCENE_ROWS; row++) {
251 for (let col = 0; col < SCENE_COLUMNS; col++) {
252 const x = col * 2
253 const y = row * 2
254 cells.set(cell([get(c, x, y), get(c, x + 1, y), get(c, x, y + 1), get(c, x + 1, y + 1)]), (row * SCENE_COLUMNS + col) * 3)
255 }
256 }
257 return cells
258}
259
260const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
261
262// Raster cells are little-endian u32s; typed arrays use the host's order,
263// which is little-endian on every platform Claude Code ships for.
264export function encode(words: Uint32Array): string {
265 const bytes = new Uint8Array(words.buffer, words.byteOffset, words.byteLength)
266 let out = ''
267 for (let i = 0; i < bytes.length; i += 3) {
268 const a = bytes[i] ?? 0
269 const b = bytes[i + 1] ?? 0
270 const d = bytes[i + 2] ?? 0
271 const n = (a << 16) | (b << 8) | d
272 out += ALPHABET[(n >> 18) & 63]
273 out += ALPHABET[(n >> 12) & 63]
274 out += i + 1 < bytes.length ? ALPHABET[(n >> 6) & 63] : '='
275 out += i + 2 < bytes.length ? ALPHABET[n & 63] : '='
276 }
277 return out
278}
279
280export function frame(t: number): string {
281 return encode(paint(t))
282}
283types/index.d.ts 8 lines1export type IsHidden = boolean
2
3declare module 'claude-code' {
4 interface PluginState {
5 'halloween': { isHidden: IsHidden }
6 }
7}
8