SLOPSHOPPER

clawd-minecraft

Usage tracker drawn as a block-game HUD: hearts and armor for the 7-day limit, XP bar for the 5-hour limit, hunger for the context window, and a Clawd that…

newbandguard
★ 1v1.0.4MITupdated 2026-10-10amsultan2010/clawd-minecraft
A shopper browsing a rack in a slop shop
README

clawd-minecraft

A Claude Code mod that shows your usage as a Minecraft HUD (with Clawd walking around next to it). It sits right above the prompt box in the Claude desktop app.

The HUD above the prompt box

  • Hearts and armor: how much of your 7-day limit is left
  • XP bar and number: how much of your 5-hour limit is left (the number is the % remaining)
  • Hunger: how much of the context window is left
  • Clawd: paces around, flags you down under flashing "?"s for as long as a question waits on you, and keeps jumping under fireworks once a turn finishes, until you send the next prompt

A bar stays blank until Claude Code has a reading for it (nothing is guessed), and a limit whose reset time has already passed shows as full.

If your system is set to reduce motion, Clawd stands still instead of walking around. And if another mod draws in the same spot above the prompt, it keeps its place right under the HUD.

Install

You need Claude Code 2.1.287 or later (mods are on by default from there, no flag needed). The hearts, armor and XP bar also need a Claude subscription, since they read your plan's limits (without one you still get hunger and Clawd). Run these two in a terminal:

claude plugin marketplace add amsultan2010/clawd-minecraft
claude plugin install clawd-minecraft@clawd-minecraft

Then start a new session in the desktop app's Code tab. The HUD shows up after Claude's first reply.

Or let Claude do it

Paste this into Claude Code (the Code tab works too):

Install the clawd-minecraft mod for me. Run `claude plugin marketplace add amsultan2010/clawd-minecraft` and then `claude plugin install clawd-minecraft@clawd-minecraft`, and tell me when it's done so I can start a new session.

What it touches

It reads your usage numbers (the 5-hour and 7-day limits and the context window) and draws the HUD. That's it. No network calls, no file access, no shell commands.

It also notices when Claude asks you a question and when a turn starts or finishes (that's how Clawd knows when to react), but it never changes or blocks anything. Run claude plugin validate . in this repo if you want to see every hook and call for yourself.

Contributing

Issues and pull requests are welcome. Before you open a PR, make sure these two still pass:

claude plugin validate .
claude plugin test .

License

MIT. That covers the code and the pixel art in this repo (all redrawn from scratch).

This is a fan project and isn't affiliated with Mojang, Microsoft or Anthropic. Minecraft and Clawd belong to their owners, and the license doesn't give you any rights to those names or characters.

Source 2 files
hooks/register.tsx 414 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Mood } from '../types'
5
6const mood = atom({ plugin: 'clawd-minecraft', key: 'mood' } as const, 'walk')
7
8// 9x9 sprites: `.` clear, `K` outline, any other letter a color of the icon's palette.
9const ARMOR = [
10  'KKK...KKK',
11  'KLLK.KLLK',
12  'KLLLKLLMK',
13  'KLLLLLMMK',
14  '.KLLLLMK.',
15  '.KLLLLMK.',
16  '.KLLLLMK.',
17  '.KLLMMMK.',
18  '..KKKKK..',
19]
20const HEART = [
21  '..KK.KK..',
22  '.KRRKRRK.',
23  'KRWRRRRRK',
24  'KRRRRRRDK',
25  'KRRRRRRDK',
26  '.KRRRRDK.',
27  '..KRRDK..',
28  '...KDK...',
29  '....K....',
30]
31const FOOD = [
32  '..KKKK...',
33  '.KBBRRK..',
34  'KBBBRRRK.',
35  'KBBBBRRK.',
36  'KTBBBBBK.',
37  'KTTBBBK..',
38  '.KKTTKWK.',
39  '...KKKWWK',
40  '......KKK',
41]
42const ARMOR_COLORS = { L: '#f4f4f4', M: '#b9b9b9' }
43const HEART_COLORS = { R: '#ff1313', W: '#ffc8c8', D: '#bb1313' }
44const FOOD_COLORS = { B: '#b97a45', R: '#d83a2b', T: '#8a5630', W: '#f2f2f2' }
45const EMPTY = '#4b4b4b'
46const XP = ['#b9f55f', '#80e01f', '#58a514']
47
48// 5x7 glyphs in the game's font.
49const FONT: Record<string, string[]> = Object.fromEntries(
50  Object.entries({
51    0: '.###. #...# #..## #.#.# ##..# #...# .###.',
52    1: '..#.. .##.. ..#.. ..#.. ..#.. ..#.. #####',
53    2: '.###. #...# ....# ..##. .#... #...# #####',
54    3: '.###. #...# ....# ..##. ....# #...# .###.',
55    4: '...## ..#.# .#..# #...# ##### ....# ....#',
56    5: '##### #.... ####. ....# ....# #...# .###.',
57    6: '..##. .#... #.... ####. #...# #...# .###.',
58    7: '##### #...# ....# ...#. ..#.. ..#.. ..#..',
59    8: '.###. #...# #...# .###. #...# #...# .###.',
60    9: '.###. #...# #...# .#### ....# ...#. .##..',
61    '%': '#...# #..#. ...#. ..#.. .#... .#..# #...#',
62    '?': '.###. #...# ....# ...#. ..#.. ..... ..#..',
63    '✓': '..... ..... ....# ...#. #.#.. .#... .....',
64  }).map(([glyph, rows]) => [glyph, rows.split(' ')]),
65)
66
67// One path per color; a later color paints over an earlier one.
68type Paint = Map<string, string>
69
70const px = (paint: Paint, fill: string, x: number, y: number, w = 1, h = 1) =>
71  paint.set(fill, `${paint.get(fill) ?? ''}M${x} ${y}h${w}v${h}h${-w}z`)
72
73const paths = (paint: Paint) =>
74  [...paint].map(([fill, d]) => `<path fill="${fill}" d="${d}"/>`).join('')
75
76// Text in the game's font, black-edged like the level number.
77const write = (paint: Paint, text: string, x0: number, y0: number, fill: string) => {
78  for (const [i, glyph] of [...text].entries()) {
79    FONT[glyph]?.forEach((row, dy) =>
80      [...row].forEach((c, dx) => {
81        if (c !== '#') return
82        const x = x0 + i * 6 + dx
83        const y = y0 + dy
84        px(paint, '#000', x - 1, y, 3, 1)
85        px(paint, '#000', x, y - 1, 1, 3)
86        px(paint, fill, x, y)
87      }),
88    )
89  }
90}
91
92// Ten icons for `left` percent in half-icon steps, draining from the last
93// drawn; `half` is the columns a half icon keeps lit.
94const icons = (
95  paint: Paint,
96  rows: string[],
97  colors: Record<string, string>,
98  left: number,
99  x0: number,
100  step: number,
101  y: number,
102  half: [number, number],
103) => {
104  const halves = Math.round(left / 5)
105
106  for (let i = 0; i < 10; i++) {
107    const lit: [number, number] =
108      halves >= 2 * i + 2 ? [0, 9] : halves === 2 * i + 1 ? half : [0, 0]
109
110    rows.forEach((row, dy) =>
111      [...row].forEach((c, dx) => {
112        if (c === '.') return
113        const isLit = dx >= lit[0] && dx < lit[1]
114        const fill = c === 'K' ? '#000' : isLit ? (colors[c] ?? EMPTY) : EMPTY
115        px(paint, fill, x0 + i * step + dx, y + dy)
116      }),
117    )
118  }
119}
120
121// The HUD at the game's own 182x25 pixel grid; each argument is a percent
122// left, or undefined for a figure with no reading, whose bar is left out.
123const hud = (week?: number, session?: number, context?: number) => {
124  const base: Paint = new Map()
125  const top: Paint = new Map()
126
127  if (week !== undefined) {
128    icons(base, ARMOR, ARMOR_COLORS, week, 0, 8, 0, [0, 5])
129    icons(base, HEART, HEART_COLORS, week, 0, 8, 10, [0, 5])
130  }
131
132  if (context !== undefined) {
133    icons(base, FOOD, FOOD_COLORS, context, 173, -8, 10, [4, 9])
134  }
135
136  if (session !== undefined) {
137    px(base, '#000', 0, 20, 182, 5)
138    px(base, '#333', 1, 21, 180, 3)
139    const filled = Math.round(session * 1.8)
140    if (filled > 0) XP.forEach((fill, i) => px(base, fill, 1, 21 + i, filled))
141    for (let x = 10; x < 180; x += 10) px(base, 'rgba(0,0,0,.5)', x, 21, 1, 3)
142
143    const text = `${session}%`
144    write(top, text, Math.round((182 - (text.length * 6 - 1)) / 2), 14, '#80ff20')
145  }
146
147  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 182 25" width="364" height="50" shape-rendering="crispEdges">${paths(base)}${paths(top)}</svg>`
148}
149
150const LOOP = 'repeatCount="indefinite"'
151const STEPPED = 'calcMode="discrete"'
152const JUMP = 'calcMode="spline" keyTimes="0;.5;1" keySplines="0 0 .4 1;.6 0 1 1"'
153
154const animate = (attribute: string, values: string, dur: string, pace = STEPPED) =>
155  `<animate attributeName="${attribute}" values="${values}" dur="${dur}" ${pace} ${LOOP}/>`
156
157const move = (values: string, dur: string, pace = STEPPED) =>
158  `<animateTransform attributeName="transform" type="translate" values="${values}" dur="${dur}" ${pace} ${LOOP}/>`
159
160const BODY = '#d77757'
161const EYE = '#141414'
162
163const EDGE = '#5b2c1e'
164
165// A block of Clawd: his orange, lit along the top and left, shaded along the
166// bottom and right.
167const block = (x: number, y: number, w: number, h: number) => {
168  const paint: Paint = new Map()
169  px(paint, BODY, x, y, w, h)
170  px(paint, '#eb9d7e', x, y, w, 1)
171  px(paint, '#eb9d7e', x, y, 1, h)
172  px(paint, '#b65f43', x + w - 1, y, 1, h)
173  px(paint, '#b65f43', x, y + h - 1, w, 1)
174
175  return paths(paint)
176}
177
178// The grain of his hide, as the game's blocks have one.
179const GRAIN = `<path fill="#e2896a" d="M12 2h1v1h-1zM19 10h1v1h-1zM26 12h1v1h-1zM7 11h1v1h-1z"/><path fill="#c8684b" d="M15 6h1v1h-1zM21 3h1v1h-1zM10 13h1v1h-1zM27 8h1v1h-1zM17 12h1v1h-1z"/>`
180
181const marks = (text: string, fill: string, x: number) => {
182  const paint: Paint = new Map()
183  write(paint, text, x, -4, fill)
184
185  return paths(paint)
186}
187
188const POP = 'calcMode="discrete" keyTimes="0;.12;.24;.36;.48"'
189const SPARKS: [number, number][] = [
190  [1.5, 0], [1, 1], [0, 1.5], [-1, 1], [-1.5, 0], [-1, -1], [0, -1.5], [1, -1],
191]
192
193// A firework: two rings of sparks thrown out from (x, y), fading as they go,
194// then a pause; `begin` staggers one burst against the next.
195const burst = (x: number, y: number, fill: string, begin: string) => {
196  const timing = `${POP} begin="${begin}"`
197  const sparks = [1, 2].flatMap(ring =>
198    SPARKS.map(([dx, dy]) => {
199      const flight = [0, 1, 2, 3, 3].map(far => `${dx * ring * far} ${dy * ring * far}`).join(';')
200
201      return `<rect x="${x}" y="${y}" width="1" height="1">${move(flight, '1.4s', timing)}</rect>`
202    }),
203  )
204
205  return `<g fill="${fill}" opacity="0">${animate('opacity', '1;1;1;.5;0', '1.4s', timing)}${sparks.join('')}</g>`
206}
207
208// Clawd at twice the grid Claude Code draws him on (13x8 body, 2x2 arms,
209// four legs), in the HUD's own 2px pixels, as `now` has him; `isStill` leaves
210// out everything that moves.
211const figure = (now: Mood, isStill: boolean) => {
212  const isWalking = now === 'walk'
213  const anim = isStill ? () => '' : animate
214  const shift = isStill ? () => '' : move
215
216  // Each part carries its dark edge on the sides the body does not cover.
217  const leg = (x: number, fill: string, values: string) => {
218    const edge = values.split(';').map(tall => Number(tall) + 1).join(';')
219
220    return `<rect x="${x - 1}" y="15" width="4" height="6" fill="${EDGE}">${isWalking ? anim('height', edge, '.44s') : ''}</rect><rect x="${x}" y="15" width="2" height="5" fill="${fill}">${isWalking ? anim('height', values, '.44s') : ''}</rect>`
221  }
222  const arm = (x: number, edge: number, motion: string) =>
223    `<g${now === 'done' ? ' transform="translate(0 -5)"' : ''}>${motion}<rect x="${edge}" y="7" width="5" height="6" fill="${EDGE}"/>${block(x, 8, 4, 4)}</g>`
224  const swing: Record<Mood, [string, string]> = {
225    walk: ['0 0;0 1', '0 1;0 0'],
226    ask: ['0 -5;0 0', '0 0;0 -5'],
227    done: ['0 0', '0 0'],
228  }
229  const [left, right] = swing[now]
230  const beat = now === 'ask' ? '.36s' : '.44s'
231  // What the whole of him does: sway to flag the person down, or jump for joy.
232  const bounce = {
233    walk: '',
234    ask: shift('-1 0;1 0', '.36s'),
235    done: shift('0 0;0 -4;0 0', '.56s', JUMP),
236  }[now]
237  // A sign in the game's font that flashes white.
238  const sign = (text: string, fill: string, x: number) =>
239    `${marks(text, fill, x)}<g opacity="0">${anim('opacity', '0;1', '.5s')}${marks(text, '#fff', x)}</g>`
240  const eyes =
241    now === 'done'
242      ? `<path fill="${EYE}" d="M8 5h2v1h-2zM7 6h1v1h-1zM10 6h1v1h-1zM24 5h2v1h-2zM23 6h1v1h-1zM26 6h1v1h-1z"/>`
243      : `<g${isWalking && !isStill ? ' class="g"' : ''}><path fill="${EYE}" d="M8 4h2v4h-2zM24 4h2v4h-2z"/><path fill="#fff" d="M8 4h1v1h-1zM24 4h1v1h-1z"/><path fill="${BODY}" opacity="0" d="M8 4h2v3h-2zM24 4h2v3h-2z">${anim('opacity', '0;1', '4s', `keyTimes="0;.97" ${STEPPED}`)}</path></g>`
244  const shadow = `<rect x="5" y="20" width="24" height="1" opacity=".3">${now === 'done' ? anim('x', '5;9;5', '.56s', JUMP) + anim('width', '24;16;24', '.56s', JUMP) : ''}</rect>`
245  const beside = {
246    walk: '',
247    ask: `<g>${shift('0 0;0 2', '.5s')}${sign('?', '#ffff55', 36)}</g><g>${shift('0 2;0 0', '.5s')}${sign('?', '#ffff55', -7)}</g>`,
248    done: `${sign('✓', '#55ff55', 36)}${isStill ? '' : burst(-9, 6, '#ffff55', '0s') + burst(45, 12, '#ff5555', '-.5s') + burst(-26, 11, '#55ffff', '-.9s') + burst(62, 6, '#ff55ff', '-.2s')}`,
249  }[now]
250
251  return `<g transform="scale(2)"><g transform="translate(0 4)">${shadow}<g>${bounce}${leg(8, '#9c4f37', '3;5')}${leg(24, '#9c4f37', '5;3')}${leg(4, BODY, '5;3')}${leg(28, BODY, '3;5')}<g>${isWalking ? shift('0 0;0 -1', '.22s') : ''}<rect x="3" y="-1" width="28" height="18" fill="${EDGE}"/>${block(4, 0, 26, 16)}${GRAIN}${arm(0, -1, shift(left, beat))}${arm(30, 30, shift(right, beat))}${eyes}</g></g>${beside}</g></g>`
252}
253
254// Clawd's strip. He is drawn in a frame as wide as the band has left, so CSS
255// places him by the frame's width (`vw`) and SMIL moves his parts: nothing
256// redraws while he walks. `width` is that frame's cap; the frame's page is
257// painted the band's dark so it shows no white. Two figures are in it, the
258// moving one (`m`) and a still one (`s`) the system's reduced-motion setting
259// shows in its place. The walk starts a quarter of the way into its lap: mid
260// strip heading right, which is where the other poses stand, so going back to
261// walking after a question or a cheer does not jump him to the left edge.
262const clawd = (now: Mood, width: number) => {
263  const place = now === 'walk' ? 'w' : 'c'
264  const lap = Math.max(8, Math.round((width - 80) / 14))
265  const css = `:root{color-scheme:light dark;overflow:hidden}body{margin:0;overflow:hidden}svg{width:100vw}@media(prefers-color-scheme:dark){:root,body{background:#212121}}.w{animation:w ${lap}s linear ${-lap / 4}s infinite}.g{animation:g ${lap}s step-end ${-lap / 4}s infinite}.c{transform:translateX(calc(50vw - 34px))}.s{display:none}@media(prefers-reduced-motion:no-preference){.e{animation:e .6s steps(6) infinite}}@media(prefers-reduced-motion:reduce){.m{display:none}.s{display:inline}}@keyframes w{0%,100%{transform:translateX(6px)}50%{transform:translateX(calc(100vw - 74px))}}@keyframes g{0%{transform:translateX(2px)}50%{transform:translateX(-2px)}}@keyframes e{to{stroke-dashoffset:-12}}`
266  // A pose that waits on the person shows across the whole band, not only
267  // where he stands: dashes of its color march along the strip's edges.
268  const edges =
269    now === 'walk'
270      ? ''
271      : `<path class="e" stroke="${now === 'ask' ? '#ffff55' : '#55ff55'}" stroke-width="2" stroke-dasharray="6" d="M0 1h9999M0 49h9999"/>`
272
273  return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="50" shape-rendering="crispEdges"><style>${css}</style>${edges}<g class="m ${place}">${figure(now, false)}</g><g class="s c">${figure(now, true)}</g></svg>`
274}
275
276// The width Clawd's strip was last drawn for. The desktop keeps his frame only
277// while his Svg source stays byte-identical (a changed one reloads it, which
278// blinks and restarts his walk), and the band's width wobbles by a cell or two
279// while Claude works, so only a real resize gets a new strip.
280let strip = 0
281
282// Clawd's mood is read off two facts, so events that overlap cannot leave it
283// wrong: how many questions are waiting, and whether the last turn ended in an
284// answer that no new turn has followed.
285let asking = 0
286let isAnswered = false
287
288const settle = ($: EngineInterface) =>
289  update($, mood, () => (asking > 0 ? 'ask' : isAnswered ? 'done' : 'walk'))
290
291export const register: Register = on => {
292  // A reload drops both facts, so start over from them.
293  on('session.start', async ($, e, next) => {
294    await settle($)
295
296    return next(e)
297  })
298
299  // The count is balanced whatever fails, so a mood that could not be saved
300  // never leaves him asking for good, and the question itself goes on as asked.
301  on('tool.call', { tool: 'AskUserQuestion' }, async ($, e, next) => {
302    asking += 1
303
304    try {
305      await settle($)
306
307      return await next(e)
308    } finally {
309      asking -= 1
310      await settle($)
311    }
312  })
313
314  // The cheer lasts until Claude is at work again: the next turn, whether a
315  // prompt or a continuation began it. A subagent's run raises no turn.start.
316  on('turn.start', async ($, e, next) => {
317    isAnswered = false
318    await settle($)
319
320    return next(e)
321  })
322
323  // Only the main thread's own answered turn is cheered: not an error, a
324  // refusal, an interrupt or a subagent's turn.
325  on('turn.complete', async ($, e, next) => {
326    if (e.agentId === undefined) {
327      isAnswered = e.reason === 'answer'
328      await settle($)
329    }
330
331    return next(e)
332  })
333
334  // The HUD shows the limits and the context, not the cost, which grows with
335  // every reply: a measurement of that alone is no reason to draw again.
336  on('session.measure', ($, e, next) => {
337    if (e.changed.some(unit => unit !== 'cost')) {
338      $.ui.invalidate('ui.render')
339    }
340
341    return next(e)
342  })
343
344  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
345    if (e.surface !== 'desktop' || e.props.hasSurvey) {
346      return next(e)
347    }
348
349    const { context, rateLimits } = await $.session.usage()
350    const time = await $.clock.now()
351    // What a window has left: nothing to say without a reading, all of it once
352    // its reset time has passed, never below zero.
353    const left = (kind: string) => {
354      const one = rateLimits.find(limit => limit.kind === kind)
355
356      if (one === undefined) {
357        return undefined
358      }
359
360      if (one.resetsAt !== undefined && Date.parse(one.resetsAt) <= time) {
361        return 100
362      }
363
364      return Math.min(100, Math.max(0, Math.round(100 - one.percentUsed)))
365    }
366    const week = left('seven_day')
367    const session = left('five_hour')
368    const room = context.percent === undefined ? undefined : Math.max(0, 100 - context.percent)
369
370    // Nothing has a reading yet (a fresh session before its first reply).
371    if (week === undefined && session === undefined && room === undefined) {
372      return next(e)
373    }
374
375    const figures = [
376      week !== undefined && `7d ${week}% left`,
377      session !== undefined && `5h ${session}% left`,
378      room !== undefined && `context ${room}% left`,
379    ]
380    const now = await read($, mood)
381    // What the band has left beside the HUD, guessed from its cells: only the
382    // cap on Clawd's frame, which the surface fits to the room it really has.
383    const spare = Math.max(120, Math.round(e.props.bodyColumns * 8) - 364)
384
385    if (Math.abs(spare - strip) >= 32) {
386      strip = spare
387    }
388
389    const { Box, Svg } = $.ui.resolve(e)
390    // The band is shared: whatever another mod drew there (a confirmation with
391    // buttons, say) keeps its place under the HUD.
392    const theirs = await next(e)
393
394    return (
395      <Box flexDirection="column">
396        <Box>
397          <Box flexShrink={0}>
398            <Svg
399              source={hud(week, session, room)}
400              alt={figures.filter(Boolean).join(', ')}
401              width={364}
402              height={50}
403            />
404          </Box>
405          <Box flexGrow={1} minWidth={0} overflow="hidden">
406            <Svg source={clawd(now, strip)} alt={`Clawd: ${now}`} height={50} isInteractive />
407          </Box>
408        </Box>
409        {theirs}
410      </Box>
411    )
412  })
413}
414
types/index.d.ts 8 lines
1export type Mood = 'walk' | 'ask' | 'done'
2
3declare module 'claude-code' {
4  interface PluginState {
5    'clawd-minecraft': { mood: Mood }
6  }
7}
8