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…

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.

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.
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.
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.
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.
Issues and pull requests are welcome. Before you open a PR, make sure these two still pass:
claude plugin validate .
claude plugin test .
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.
hooks/register.tsx 414 lines1import { 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}
414types/index.d.ts 8 lines1export type Mood = 'walk' | 'ask' | 'done'
2
3declare module 'claude-code' {
4 interface PluginState {
5 'clawd-minecraft': { mood: Mood }
6 }
7}
8