SLOPSHOPPER

wasted

When a tool call fails, the spinner dies: the line goes grey and WASTED fades in, in hand-drawn pixel lettering

newspinnerguardtimeraudio
v0.1.0MITupdated 2026-10-02roaringsoul404/cc-mods/wasted
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · wasted
› 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 ✻ Working… ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Spinner
✻ Working…
README

wasted

A Claude Code mod. When a tool call fails, the spinner dies: its word drains to grey and WASTED fades in, in hand-drawn pixel lettering. Then the spinner comes back.

WASTED in the terminal

  • Fires on any tool call that reports an error, including a Bash command with a non-zero exit code.
  • Lasts about 2.5 s: ~0.5 s grey-out, ~1 s zoom-and-fade entrance, then a hold.
  • Silent by default. Add your own sound as sounds/custom.wav (see below).
  • Failures while the effect is playing are ignored, so a burst of errors shows it once.
  • Terminal: a 76 × 10 cell Raster, repainted with $.ui.blit at 20 fps. Needs a terminal at least 76 columns wide.
  • Original lettering, drawn in code. No third-party fonts, logos, sounds or game assets.

Install

/plugin marketplace add roaringsoul404/cc-mods
/plugin install wasted@roaringsoul

To receive new versions on their own: /plugin → Marketplaces → roaringsoul → Enable auto-update. To turn it off, disable it in /plugin.

To try it without installing:

git clone https://github.com/roaringsoul404/cc-mods
claude --plugin-dir ./cc-mods/wasted

Then ask for something that fails:

Run ./HESOYAM with Bash. Full health, full armor, $250k. What could go wrong?

Desktop app: the effect has a desktop branch (the same pixels as an Svg, 10 fps), and its drawing test passes. The Claude Code build bundled with the Desktop app (2.1.284 at the time of writing) loads installed mods only with CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 in its environment, and the effect hasn't been watched there yet.

Check that it loaded

/plugin shows 1 mod active · wasted. Or start with --debug-file /tmp/wasted.log and run:

grep "wasted@inline" /tmp/wasted.log

loaded means it's on. not loaded: ... the rollout switch served off means Claude Code switched mods off remotely for that launch; restart and check again.

Files

| File | What it does | | :- | :- | | hooks/register.js | Hooks: detects failures (tool.call), replaces the spinner (ui.render), runs the animation clock, plays sounds/custom.wav if present | | hooks/draw.js | Pure drawing: the lettering, the timeline and the shared pixel model, plus its terminal and SVG renderings | | sounds/ | Empty: where your custom.wav goes | | scripts/render-frames.mjs | Renders terminal frames to PNG in frames/: node scripts/render-frames.mjs 80 | | tests/wasted.test.ts | claude plugin test: a failed call draws a Raster on terminal and an Svg on desktop | | video/ | Records a real session and renders the 7 s demo video; see video/README.md |

Disclaimer

A fan project inspired by a meme. Not affiliated with, endorsed by or connected to Rockstar Games or Take-Two Interactive. "Grand Theft Auto" and related names are their trademarks. This repository contains no game assets: the lettering is original and no audio is included. Any file you add as sounds/custom.wav is your responsibility.

Source 2 files
hooks/register.js 93 lines
1import { frame, frameModel, modelSvg, hex, DURATION_MS } from './draw.js'
2
3const KEY = 'wasted'
4const FRAME_MS = 50 // terminal: blit at 20 fps
5const DESKTOP_EVERY = 2 // desktop: redraw every 2nd tick, 10 fps
6
7// When the current death started, or null when nothing is playing
8let diedAt = null
9// The word the spinner showed when the tool failed
10let deathWord = 'Working'
11// The terminal spinner (agent id) whose Raster is on screen, or null
12let mounted = null
13// Whether a desktop spinner is drawing the effect
14let onDesktop = false
15let tick = 0
16// Whether this death's sound has started
17let played = false
18
19export function register(on) {
20  on('session.start', async ($, e, next) => {
21    $.clock.every(FRAME_MS, async () => {
22      if (diedAt === null) return
23      const t = Date.now() - diedAt
24      tick += 1
25      if (t >= DURATION_MS) {
26        // Effect over: give the spinner back
27        diedAt = null
28        mounted = null
29        onDesktop = false
30        $.ui.invalidate('ui.render')
31        return
32      }
33      // The mod ships no sound: it plays sounds/custom.wav if the user added one,
34      // and only once the effect is on screen (no sound from `claude -p`,
35      // background subagents, or an Esc that took the spinner away)
36      if (!played && (mounted !== null || onDesktop)) {
37        played = true
38        // Not awaited: play resolves only when the clip ends. No file, or no
39        // player on this machine: silence.
40        $.audio.play({ asset: 'sounds/custom.wav' }).catch(() => {})
41      }
42      // Desktop has no blit: redraw the site, which runs the render hook again
43      if (onDesktop && tick % DESKTOP_EVERY === 0) $.ui.invalidate('ui.render')
44      if (mounted === null) return
45      const r = await $.ui.blit({ requestId: mounted, key: KEY, ...frame(t, deathWord) })
46      if (r && r.deny) {
47        // Raster no longer mounted: ask for a redraw, which draws the current
48        // frame if the spinner is still on screen (also covers a site that
49        // refuses blits, at the slower invalidate rate)
50        mounted = null
51        $.ui.invalidate('ui.render')
52      }
53    })
54    return next(e)
55  })
56
57  on('tool.call', async ($, e, next) => {
58    const r = await next(e)
59    // Ignore failures while a death is already playing
60    if (r && r.isError && diedAt === null) {
61      diedAt = Date.now()
62      tick = 0
63      played = false
64      $.ui.invalidate('ui.render')
65    }
66    return r
67  })
68
69  on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
70    if (diedAt === null || (e.surface !== 'terminal' && e.surface !== 'desktop')) {
71      if (e.props && e.props.word) deathWord = e.props.word
72      return next(e)
73    }
74    const t = Date.now() - diedAt
75    if (e.surface === 'terminal') {
76      const { Raster } = $.ui.resolve(e)
77      mounted = e.requestId
78      return Raster({ key: KEY, ...frame(t, deathWord) })
79    }
80    // Desktop: the same pixel model, drawn as SVG
81    const { Box, Text, Svg } = $.ui.resolve(e)
82    const model = frameModel(t, deathWord)
83    onDesktop = true
84    return Box({
85      flexDirection: 'column',
86      children: [
87        Text({ color: hex(model.wordColor), children: [model.label] }),
88        Svg({ source: modelSvg(model), alt: 'WASTED' }),
89      ],
90    })
91  })
92}
93
hooks/draw.js 154 lines
1// Pure drawing for the WASTED effect. No mods API here, so a plain node
2// script can render the same frames to PNG (scripts/render-frames.mjs).
3
4export const DURATION_MS = 2500
5export const COLUMNS = 76
6export const ROWS = 10 // 1 word row + 9 rows of half-block pixels
7
8const DEFAULT = 0x01000000 // terminal default color
9const PX_W = COLUMNS
10const PX_H = (ROWS - 1) * 2 // 18 pixels tall, two per cell with ▀/▄
11
12const CLAUDE_ORANGE = 0xd77757
13const DEAD_GREY = 0x6e6e6e
14const RED = 0xd3302f
15const SHADOW = 0x1a0606
16
17// Timeline
18const GREY_END = 500 // word drains to grey
19const IN_START = 450 // lettering starts to appear
20const IN_END = 1400 // lettering settled, then holds until DURATION_MS
21const SCALE_FROM = 2.15
22const SCALE_TO = 2
23const SHADOW_OFF = 1 // shadow offset in output pixels, right and down
24
25// Hand-drawn 5x7 lettering. Original glyphs, not any existing typeface.
26const GLYPHS = {
27  W: ['#...#', '#...#', '#...#', '#.#.#', '#.#.#', '#.#.#', '.#.#.'],
28  A: ['.###.', '#...#', '#...#', '#####', '#...#', '#...#', '#...#'],
29  S: ['.####', '#....', '#....', '.###.', '....#', '....#', '####.'],
30  T: ['#####', '..#..', '..#..', '..#..', '..#..', '..#..', '..#..'],
31  E: ['#####', '#....', '#....', '####.', '#....', '#....', '#####'],
32  D: ['####.', '#...#', '#...#', '#...#', '#...#', '#...#', '####.'],
33}
34
35// The word as one bitmap: 6 glyphs, 1 pixel gap -> 35 x 7
36const BITMAP = (() => {
37  const rows = []
38  for (let y = 0; y < 7; y++) {
39    rows.push([...'WASTED'].map((ch) => GLYPHS[ch][y]).join('.'))
40  }
41  return rows
42})()
43const BM_W = BITMAP[0].length
44const BM_H = BITMAP.length
45
46const clamp01 = (x) => Math.max(0, Math.min(1, x))
47const easeOut = (x) => 1 - Math.pow(1 - x, 3)
48
49function lerpColor(a, b, t) {
50  const ch = (shift) => Math.round(((a >> shift) & 255) + (((b >> shift) & 255) - ((a >> shift) & 255)) * t)
51  return (ch(16) << 16) | (ch(8) << 8) | ch(0)
52}
53
54// Is output pixel (px, py) inside the lettering at this scale, centered?
55function inLetters(px, py, scale) {
56  const bx = Math.floor((px + 0.5 - PX_W / 2) / scale + BM_W / 2)
57  const by = Math.floor((py + 0.5 - PX_H / 2) / scale + BM_H / 2)
58  if (bx < 0 || by < 0 || bx >= BM_W || by >= BM_H) return false
59  return BITMAP[by][bx] === '#'
60}
61
62// The shared pixel model: one frame of the effect, independent of surface.
63// `label` and `wordColor` are the dead spinner line; `pixels` is a
64// PX_W x PX_H grid, row-major, each a 0xRRGGBB color or null (transparent).
65export function frameModel(tMs, word) {
66  const wordColor = lerpColor(CLAUDE_ORANGE, DEAD_GREY, easeOut(clamp01(tMs / GREY_END)))
67  // A Raster refuses the whole tree over one cell that isn't a width-1
68  // character, so anything but printable Latin becomes '?'
69  const printable = (cp) => (cp >= 0x20 && cp <= 0x7e) || (cp >= 0xa0 && cp <= 0x24f)
70  const safe = [...(word || 'Working')].map((ch) => (printable(ch.codePointAt(0)) ? ch : '?')).join('')
71  const label = '✻ ' + safe + '…'
72
73  // WASTED, zooming in from slightly bigger and fading up from black
74  const p = clamp01((tMs - IN_START) / (IN_END - IN_START))
75  const alpha = easeOut(p)
76  const scale = SCALE_FROM + (SCALE_TO - SCALE_FROM) * easeOut(p)
77  const red = lerpColor(0x000000, RED, alpha)
78  const shadow = lerpColor(0x000000, SHADOW, alpha)
79
80  const pixels = new Array(PX_W * PX_H).fill(null)
81  if (p > 0) {
82    for (let py = 0; py < PX_H; py++) {
83      for (let px = 0; px < PX_W; px++) {
84        if (inLetters(px, py, scale)) pixels[py * PX_W + px] = red
85        else if (inLetters(px - SHADOW_OFF, py - SHADOW_OFF, scale)) pixels[py * PX_W + px] = shadow
86      }
87    }
88  }
89  return { label, wordColor, width: PX_W, height: PX_H, pixels }
90}
91
92// Terminal: the model as a flat array of [codePoint, fg, bg] per cell,
93// row-major, two pixels per cell with ▀/▄.
94export function modelCells(model) {
95  const cells = new Array(COLUMNS * ROWS * 3)
96  const put = (col, row, ch, fg, bg) => {
97    const i = (row * COLUMNS + col) * 3
98    cells[i] = ch.codePointAt(0)
99    cells[i + 1] = fg
100    cells[i + 2] = bg
101  }
102
103  const label = [...model.label].slice(0, COLUMNS)
104  for (let c = 0; c < COLUMNS; c++) put(c, 0, label[c] ?? ' ', model.wordColor, DEFAULT)
105
106  const pixel = (x, y) => model.pixels[y * PX_W + x]
107  for (let row = 1; row < ROWS; row++) {
108    const top = (row - 1) * 2
109    for (let c = 0; c < COLUMNS; c++) {
110      const a = pixel(c, top)
111      const b = pixel(c, top + 1)
112      if (a === null && b === null) put(c, row, ' ', DEFAULT, DEFAULT)
113      else if (b === null) put(c, row, '▀', a, DEFAULT)
114      else if (a === null) put(c, row, '▄', b, DEFAULT)
115      else put(c, row, '▀', a, b)
116    }
117  }
118  return cells
119}
120
121// Pack cells into the base64 string a Raster takes: u32 little-endian triples.
122export function packCells(cells) {
123  return new Uint8Array(Uint32Array.from(cells).buffer).toBase64()
124}
125
126export const hex = (color) => '#' + color.toString(16).padStart(6, '0')
127
128// Desktop: the model's pixels as an SVG document, one <rect> per pixel,
129// grouped by color. Same grid as the terminal, square pixels.
130export const SVG_PX = 6 // CSS pixels per model pixel
131export const SVG_MAX = 131072
132
133export function modelSvg(model) {
134  const byColor = new Map()
135  model.pixels.forEach((color, i) => {
136    if (color === null) return
137    const rect = `<rect x="${i % model.width}" y="${Math.floor(i / model.width)}" width="1" height="1"/>`
138    byColor.set(color, (byColor.get(color) ?? '') + rect)
139  })
140  let groups = ''
141  for (const [color, rects] of byColor) groups += `<g fill="${hex(color)}">${rects}</g>`
142  const w = model.width * SVG_PX
143  const h = model.height * SVG_PX
144  return (
145    `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" ` +
146    `viewBox="0 0 ${model.width} ${model.height}" shape-rendering="crispEdges">${groups}</svg>`
147  )
148}
149
150// Terminal Raster props for one frame
151export function frame(tMs, word) {
152  return { columns: COLUMNS, rows: ROWS, cells: packCells(modelCells(frameModel(tMs, word))) }
153}
154