SLOPSHOPPER

halloween

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

newspinnercommandtimer
v0.5.0no licenseupdated 2026-10-15whirlwin/claude-code-halloween
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · halloween
› 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 › /halloween ⎿ halloween: Clawd returns to the crypt. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

claude-code-halloween

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:

CostumeEffectsSpinner words
Pumpkin Clawdcarved face lit by a flickering candle, a stem, a flapping batCarving, Scooping, Flickering, Grinning
Ghost Clawdbobs up and down, wavy sheet hem, drifting fog, faint sparklesHaunting, Lurking, Creeping, Possessing, Spooking
Zombie Clawdrotting green with a stitched scar, mismatched eyes, sways as he shambles, reaches out, flies buzz overhead, a tombstoneShambling, Groaning, Lurching, Rotting, Braaaining
Witch Clawdpointed hat with a gold buckle, glowing green eyes that blink, moon, stars, lightningBrewing, Conjuring, Hexing, Bewitching, Cackling, Summoning
Devil Clawdhorns, glowing yellow eyes, a smirk, a swishing arrow tail, standing in hellfire with rising embersScheming, 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.

Frames of the five costumes

He also comes as a bigger macOS screensaver.

Install

/plugin install halloween --marketplace whirlwin/claude-code-halloween

Answer y to add the marketplace, then pick a scope.

Update

/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.

Use

CommandWhat it does
/halloweenhide Clawd, or bring him back
/halloween nextskip straight to the next costume

Screensaver

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.

The screensaver's five costumes and a costume swap

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

How it works

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.

Develop

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-.

Source 3 files
hooks/register.tsx 94 lines
1import { 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}
94
hooks/scene.ts 283 lines
1// 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}
283
types/index.d.ts 8 lines
1export type IsHidden = boolean
2
3declare module 'claude-code' {
4  interface PluginState {
5    'halloween': { isHidden: IsHidden }
6  }
7}
8