Your Claude session and weekly usage limits as an animated villager health card, with rank badges and a death screen at 0 HP

Your Claude usage limits as a game health card, drawn above the prompt in Claude Code.

It takes about a minute. You need Claude Code on your computer first.
claude and press Enter. /plugin install villager-hp --marketplace Ashley-Pettit/villager-hp
y and press Enter. For the second, just press Enter./hp 40 and press Enter to see it at 40 HP.Good to know:
Two bars, SESSION (your 5-hour limit) and WEEKLY (your weekly limit across all models), show the hit points you have left. HP is 100 minus the percent used, with the time each one resets. An Age of Empires–style villager watches over them, and his mood follows your lowest bar.


| Command | What it does |
|---|---|
/hp | Show both bars as text |
/hp 40 | Preview the card at any HP from 0 to 100 |
/hp off | Back to your real limits |
/die | Watch Claude die (same as /hp 0) |
/revive | Bring Claude back to your real limits |
/rank top | Change your badge: scout, contributor, innovator, top or god |
It reads your session's usage figures and keeps your chosen rank in its own small store. It makes no network calls, starts no processes, and reads or writes no files of yours.
Readings appear on Claude subscriptions, which report rate-limit windows; on API keys the bars show — until a reading arrives.
MIT
hooks/register.tsx 158 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Limit } from '../types'
5import { RANKS, backIn, bars, hudSvg, killer, mood } from './hud'
6import type { Rank } from './hud'
7
8const limitsAtom = atom({ plugin: 'villager-hp', key: 'limits' } as const, [] as Limit[])
9const demoAtom = atom({ plugin: 'villager-hp', key: 'demo' } as const, null as number | null)
10const nowAtom = atom({ plugin: 'villager-hp', key: 'now' } as const, 0)
11const rankAtom = atom({ plugin: 'villager-hp', key: 'rank' } as const, 'god' as Rank)
12
13const FACES = { healthy: '(◕‿◕)', hurt: '(•_•)', critical: '(╥﹏╥)', dead: '(x_x)', unknown: '(•‿•)' }
14const SEGMENTS = 20
15// Named by /hp, to tell at a glance which version is loaded.
16const VERSION = '2.5: animated villager; card and HP commands only'
17// AoE-style: green for what's left, red for what's gone.
18const split = (hp: number) => {
19 const full = Math.round((hp / 100) * SEGMENTS)
20 return { green: '█'.repeat(full), red: '█'.repeat(SEGMENTS - full) }
21}
22
23export const register: Register = on => {
24 on('session.start', async ($, e, next) => {
25 const started = await next(e)
26 await $.command.register({
27 name: 'hp',
28 description: "Show Claude's HP, or preview a level: /hp 10, /hp 0 to die, /hp off for real readings",
29 argumentHint: '[0-100 | off]',
30 })
31 await $.command.register({ name: 'die', description: 'Watch Claude die on the health card (same as /hp 0); /revive to undo' })
32 await $.command.register({ name: 'revive', description: 'Bring Claude back to your real health (same as /hp off)' })
33 await $.command.register({
34 name: 'rank',
35 description: 'Change the rank badge on your health card: /rank scout, contributor, innovator, top, god',
36 argumentHint: '[scout | contributor | innovator | top | god]',
37 })
38 // The rank you picked, kept across sessions.
39 const saved = await $.store.get('rank')
40 if (typeof saved === 'string' && saved in RANKS) await update($, rankAtom, () => saved as Rank)
41 const usage = await $.session.usage()
42 await update($, limitsAtom, () => usage.rateLimits)
43 await update($, nowAtom, () => Date.now())
44 // Tick each minute so the reset countdowns move.
45 $.clock.every(60_000, () => {
46 void update($, nowAtom, () => Date.now())
47 })
48 return started
49 })
50
51 on('command.run', { command: 'die' }, async $ => {
52 await update($, demoAtom, () => 0)
53 return { text: 'Claude has been defeated. /revive to bring it back.' }
54 })
55
56 on('command.run', { command: 'revive' }, async $ => {
57 await update($, demoAtom, () => null)
58 return { text: 'Claude is back, at your real health.' }
59 })
60
61 on('command.run', { command: 'rank' }, async ($, e) => {
62 const arg = e.args.trim().toLowerCase().replace(/\s+/g, ' ')
63 const aliases: Record<string, Rank> = { 'top contributor': 'top', 'god mode': 'god', godmode: 'god' }
64 const rank = (arg in RANKS ? arg : aliases[arg]) as Rank | undefined
65 if (!rank) {
66 const current = await read($, rankAtom)
67 const list = (Object.keys(RANKS) as Rank[]).map(r => `${r}${r === current ? ' (yours)' : ''}`).join(', ')
68 return { text: arg ? `No rank called "${arg}". Ranks: ${list}.` : `Your rank: ${RANKS[current].name}. Change it with /rank and one of: ${list}.` }
69 }
70 await $.store.set('rank', rank)
71 await update($, rankAtom, () => rank)
72 return { text: `Your badge is now ${RANKS[rank].name}.` }
73 })
74
75 // Fresh readings arrive after each turn and whenever a window moves a point.
76 on('session.measure', async ($, e, next) => {
77 await update($, limitsAtom, () => e.rateLimits)
78 await update($, nowAtom, () => Date.now())
79 return next(e)
80 })
81
82 on('command.run', { command: 'hp' }, async ($, e) => {
83 const arg = e.args.trim().toLowerCase()
84 if (arg === 'off' || arg === 'real') {
85 await update($, demoAtom, () => null)
86 return { text: 'HP bars show your real limits again.' }
87 }
88 if (arg !== '') {
89 const n = Number(arg)
90 if (!Number.isFinite(n) || n < 0 || n > 100) return { text: 'Give an HP from 0 to 100, or "off".' }
91 await update($, demoAtom, () => Math.round(n))
92 return { text: n <= 0 ? 'Preview: Claude is dead. /hp off to revive.' : `Preview: both bars at ${Math.round(n)} HP. /hp off for real readings.` }
93 }
94 const all = bars(await read($, limitsAtom), await read($, demoAtom), Date.now())
95 return {
96 text: [
97 ...all.map(b => `${b.label}: ${b.hp === null ? 'no reading yet' : `${b.hp}/100 HP${b.respawn ? `, resets in ${b.respawn}` : ''}`}`),
98 `(villager-hp ${VERSION})`,
99 ].join('\n'),
100 }
101 })
102
103 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
104 const below = await next(e)
105 if (e.props.hasSurvey) return below
106
107 const all = bars(await read($, limitsAtom), await read($, demoAtom), await read($, nowAtom) || Date.now())
108 const m = mood(all)
109 const elements = $.ui.resolve(e)
110
111 // The animated card everywhere but the terminal, which gets the text bars.
112 // Chosen by the surface, not by which elements the table lists: a table can
113 // carry an element its surface draws as nothing.
114 if (e.surface !== 'terminal' && 'Svg' in elements) {
115 const { Box, Svg } = elements
116 const alt = all.map(b => `${b.label} ${b.hp === null ? 'unknown' : `${b.hp} of 100`} HP`).join(', ')
117 return (
118 <Box flexDirection="column">
119 {below}
120 <Svg key={`hp-${m}`} source={hudSvg(all, await read($, rankAtom))} alt={`Claude's health: ${alt}`} />
121 </Box>
122 )
123 }
124
125 const { Box, Text } = elements
126 const dead = killer(all)
127 return (
128 <Box flexDirection="column">
129 {below}
130 {dead !== undefined ? (
131 <Text color="#c9a14a" bold>
132 {`(x_x) Claude has been defeated. Out of ${dead.label === 'SESSION' ? 'session' : 'weekly'} hit points · ${backIn(dead.respawn)}`}
133 </Text>
134 ) : (
135 <>
136 {all.map((b, i) => (
137 <Box key={b.label} flexDirection="row">
138 <Text color="#d97757">{i === 0 ? `${FACES[m]} ` : ' '}</Text>
139 <Text bold color="#d4ab52">{b.label.padEnd(8)}</Text>
140 {b.hp === null ? (
141 <Text dimColor>{'░'.repeat(SEGMENTS)} ??/100</Text>
142 ) : (
143 <>
144 <Text color="#3fd23f">{split(b.hp).green}</Text>
145 <Text color="#a01d14">{split(b.hp).red}</Text>
146 <Text color="#f1e3c0" bold>{` ${b.hp}/100`}</Text>
147 <Text color="#cdbb94">{b.respawn ? ` ${b.respawn}` : ''}</Text>
148 </>
149 )}
150 </Box>
151 ))}
152 </>
153 )}
154 </Box>
155 )
156 })
157}
158hooks/hud.ts 503 lines1import type { Limit } from '../types'
2
3// Turns the account's rate-limit windows into two game health bars, and
4// draws them as an SVG HUD for surfaces that draw Svg.
5
6export type Bar = {
7 label: 'SESSION' | 'WEEKLY'
8 hp: number | null // 0 to 100; null before the first reading
9 respawn: string // when the window resets: "2h 50m", "38m" or "Sun 10am"
10}
11
12export type Mood = 'healthy' | 'hurt' | 'critical' | 'dead' | 'unknown'
13
14const KINDS: Record<Bar['label'], string> = { SESSION: 'five_hour', WEEKLY: 'seven_day' }
15const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
16
17const respawnIn = (resetsAt: string | undefined, now: number) => {
18 if (resetsAt === undefined) return ''
19 const at = Date.parse(resetsAt)
20 if (Number.isNaN(at)) return ''
21 const mins = Math.max(0, Math.round((at - now) / 60000))
22 if (mins < 60) return `${mins}m`
23 if (mins < 24 * 60) return `${Math.floor(mins / 60)}h ${mins % 60}m`
24 const d = new Date(at)
25 const h = d.getHours()
26 const time = `${h % 12 === 0 ? 12 : h % 12}${d.getMinutes() ? `:${String(d.getMinutes()).padStart(2, '0')}` : ''}${h < 12 ? 'am' : 'pm'}`
27 return `${DAYS[d.getDay()]} ${time}`
28}
29
30export const bars = (limits: Limit[], demo: number | null, now: number): Bar[] =>
31 (['SESSION', 'WEEKLY'] as const).map(label => {
32 const limit = limits.find(l => l.kind === KINDS[label])
33 if (demo !== null) return { label, hp: demo, respawn: respawnIn(limit?.resetsAt, now) || '4h 20m' }
34 if (limit === undefined) return { label, hp: null, respawn: '' }
35 return { label, hp: Math.max(0, Math.round(100 - limit.percentUsed)), respawn: respawnIn(limit.resetsAt, now) }
36 })
37
38const lowest = (all: Bar[]) => {
39 const known = all.flatMap(b => (b.hp === null ? [] : [b.hp]))
40 return known.length === 0 ? null : Math.min(...known)
41}
42
43export const mood = (all: Bar[]): Mood => {
44 const low = lowest(all)
45 if (low === null) return 'unknown'
46 return low <= 0 ? 'dead' : low < 25 ? 'critical' : low < 50 ? 'hurt' : 'healthy'
47}
48
49// "back in 2h 50m", "back Sun 10am"
50export const backIn = (respawn: string) => (respawn ? (/^\d/.test(respawn) ? `back in ${respawn}` : `back ${respawn}`) : 'back soon')
51
52export const killer = (all: Bar[]) => all.find(b => b.hp !== null && b.hp <= 0)
53
54// ---- the SVG HUD: a modern status card with the villager, 960 x 64 ----
55
56export const HUD_W = 960
57export const HUD_H = 64
58const AV = { cx: 34, cy: 32, r: 25 } // the avatar circle
59const ICON_X = 74
60const LABEL_X = 92
61const BAR_X = 172
62// The right of the card is kept for the rank badge: a zone as wide as the
63// widest badge, so every rank sits the same distance from the reset times.
64const BADGE_ZONE = 156
65const BADGE_GAP = 24 // between the reset times and the badge zone
66// The bars fill what the badge leaves; hudSvg fits them to the badge it draws.
67let BAR_W = 960 - 16 - BADGE_ZONE - BADGE_GAP - 78 - 66 - 16 - 172
68const BAR_H = 12
69let VALUE_X = BAR_X + BAR_W + 16
70const ROW_CY = [20, 44] // the two rows' centre lines
71const UI = `font-family="'Segoe UI Variable Display', 'Segoe UI', system-ui, -apple-system, sans-serif"`
72
73const T = {
74 text: '#f1f4f8',
75 muted: '#8f9aa7',
76 faint: '#5d6875',
77 track: '#1b2129',
78 trackEdge: '#2b333d',
79 lost: '#3a1c20', // lost HP, a faint red under the track
80 chip: '#1e252e',
81 green: ['#22c55e', '#86efac'],
82 amber: ['#f59e0b', '#fcd34d'],
83 red: ['#ef4444', '#fca5a5'],
84}
85
86const tone = (hp: number) => (hp > 50 ? 'green' : hp > 25 ? 'amber' : 'red') as 'green' | 'amber' | 'red'
87
88// ---- the villager: a vector bust in a brown cap and a blue tunic ----
89
90const SKIN = { base: '#eab98e', shade: '#d39a6c', ear: '#dca57a' }
91const DEAD = { base: '#9a958e', shade: '#7f7a73', ear: '#8c8780' }
92const INK = '#2b1a12'
93
94// A tear that runs from under an eye down the cheek, over and over.
95const tear = (x: number, delay: number) => `
96 <path d="M ${x},35 q 1.3,2.2 0,3 q -1.3,-0.8 0,-3 z" fill="#5ec4ff" opacity="0">
97 <animateTransform attributeName="transform" type="translate" values="0 0; 0 9" dur="1.1s" begin="${delay}s" repeatCount="indefinite"/>
98 <animate attributeName="opacity" values="0.95;0.95;0" keyTimes="0;0.7;1" dur="1.1s" begin="${delay}s" repeatCount="indefinite"/>
99 </path>`
100
101// His face by how much health is left: grinning, flat, frowning, crying, sobbing.
102const face = (hp: number) => {
103 const ry = hp < 25 ? 1.5 : 2
104 const blink = `keyTimes="0;0.9;0.94;0.98;1" dur="4.2s" repeatCount="indefinite"`
105 const eye = (x: number) =>
106 `<ellipse cx="${x}" cy="32.2" rx="1.7" ry="${ry}" fill="${INK}"><animate attributeName="ry" values="${ry};${ry};0.2;${ry};${ry}" ${blink}/></ellipse>` +
107 `<circle cx="${x + 0.6}" cy="31.4" r="0.6" fill="#fff"><animate attributeName="opacity" values="1;1;0;1;1" ${blink}/></circle>`
108 const eyes = eye(29.8) + eye(38.2)
109 const nose = `<path d="M 34,33.5 q 1.2,1.6 0,2.3" fill="none" stroke="${SKIN.shade}" stroke-width="1.1" stroke-linecap="round"/>`
110 const stroke = (d: string) => `<path d="${d}" fill="none" stroke="${INK}" stroke-width="1.4" stroke-linecap="round"/>`
111 const brows = (sad: boolean) =>
112 sad
113 ? stroke('M 27.4,28.6 L 31.4,27.2') + stroke('M 36.6,27.2 L 40.6,28.6')
114 : stroke('M 27.6,27.6 Q 29.8,26.6 32,27.4') + stroke('M 36,27.4 Q 38.2,26.6 40.4,27.6')
115 const blush = `<circle cx="27.2" cy="36" r="2" fill="#f28b82" opacity="0.35"/><circle cx="40.8" cy="36" r="2" fill="#f28b82" opacity="0.35"/>`
116 if (hp >= 75) return brows(false) + eyes + nose + blush + stroke('M 30.4,38.2 Q 34,41.4 37.6,38.2')
117 if (hp >= 50) return brows(false) + eyes + nose + stroke('M 31,39.2 L 37,39.2')
118 if (hp >= 25) return brows(true) + eyes + nose + stroke('M 30.6,40.4 Q 34,37.8 37.4,40.4')
119 if (hp >= 10) return brows(true) + eyes + nose + stroke('M 30.4,40.6 Q 34,37.4 37.6,40.6') + tear(29.4, 0)
120 return (
121 brows(true) +
122 eyes +
123 nose +
124 `<ellipse cx="34" cy="39.8" rx="3" ry="2" fill="#5a1d14"/>` +
125 tear(29.4, 0) +
126 tear(38.6, 0.55) +
127 tear(29.4, 0.55)
128 )
129}
130
131// How the villager moves in each state: his pickaxe, his head, his breathing.
132type Motion = { pick: string; head: string; breathe: number; tremble?: string }
133const motion = (m: Mood, hp: number): Motion => {
134 if (m === 'dead') return { pick: '', head: '', breathe: 0 }
135 // Happy: hard at work, the pickaxe chopping, head bobbing along.
136 if (hp >= 75)
137 return {
138 pick: `values="0 44 62;-24 44 62;6 44 62;0 44 62" keyTimes="0;0.45;0.6;1" dur="1.6s"`,
139 head: `values="0 0;0 -0.9;0 0" dur="0.8s"`,
140 breathe: 2.4,
141 }
142 // Content: a slower, steadier swing.
143 if (hp >= 50)
144 return { pick: `values="0 44 62;-14 44 62;0 44 62" dur="2.6s"`, head: `values="0 0;0 -0.5;0 0" dur="2.6s"`, breathe: 2.8 }
145 // Hurt: the pickaxe barely lifts, his head drooping.
146 if (hp >= 25)
147 return { pick: `values="0 44 62;-5 44 62;0 44 62" dur="3.6s"`, head: `values="0 0;0 0.8;0 0" dur="3.6s"`, breathe: 3.6 }
148 // Crying: the dropped pickaxe drags a little; he sniffles.
149 if (hp >= 10)
150 return { pick: `values="0 46 62;3 46 62;0 46 62" dur="2.2s"`, head: `values="0 0;0 0.7;0 0;0 0.5;0 0" dur="1.1s"`, breathe: 1.4 }
151 // Sobbing: shoulders heaving, the whole villager trembling.
152 return {
153 pick: `values="0 46 62;4 46 62;0 46 62" dur="1.4s"`,
154 head: `values="0 0;0 1;0 0;0 1;0 0" dur="0.7s"`,
155 breathe: 0.7,
156 tremble: `values="0 0;-0.4 0;0.4 0;0 0" dur="0.18s"`,
157 }
158}
159
160const villager = (m: Mood, hp: number) => {
161 const dead = m === 'dead'
162 const skin = dead ? DEAD : SKIN
163 const tunic = dead ? '#5f6470' : '#3b6fe0'
164 const tunicDark = dead ? '#4c505a' : '#2c56b8'
165 const cap = dead ? '#5a554f' : '#7a4a24'
166 const capDark = dead ? '#4a4540' : '#5e3818'
167 const coping = hp >= 25 || dead
168 const mv = motion(m, hp)
169 const spin = (v: string) => (v ? `<animateTransform attributeName="transform" type="rotate" ${v} repeatCount="indefinite"/>` : '')
170 const move = (v: string) => (v ? `<animateTransform attributeName="transform" type="translate" ${v} repeatCount="indefinite"/>` : '')
171
172 // The pickaxe, behind him: on his shoulder while he's coping, dropped once he isn't.
173 const pick = coping
174 ? `<g>${spin(mv.pick)}
175 <path d="M 44,62 L 53,24" stroke="${dead ? '#6a655e' : '#8b5a2b'}" stroke-width="2.6" stroke-linecap="round"/>
176 <path d="M 45.5,24.5 Q 53,19 60,26" fill="none" stroke="${dead ? '#77726b' : '#c7cfd8'}" stroke-width="3" stroke-linecap="round"/></g>`
177 : `<g>${spin(mv.pick)}
178 <path d="M 46,62 L 60,48" stroke="#8b5a2b" stroke-width="2.6" stroke-linecap="round"/>
179 <path d="M 56,44 Q 62,47 61,54" fill="none" stroke="#c7cfd8" stroke-width="3" stroke-linecap="round"/></g>`
180
181 // His body breathes: the tunic rises and settles.
182 const body = `<g>${mv.breathe ? move(`values="0 0;0 -0.7;0 0" dur="${mv.breathe}s"`) : ''}
183 <ellipse cx="34" cy="63" rx="19" ry="14" fill="${tunic}"/>
184 <path d="M 22,55 Q 34,60 46,55 L 46,60 Q 34,65 22,60 Z" fill="${tunicDark}" opacity="0.6"/>
185 <path d="M 29.5,49.5 L 34,55.5 L 38.5,49.5" fill="none" stroke="${tunicDark}" stroke-width="1.6" stroke-linejoin="round"/>
186 <rect x="30.5" y="41" width="7" height="9" rx="2" fill="${skin.shade}"/></g>`
187
188 const head = `
189 <circle cx="23.8" cy="33.5" r="2.4" fill="${skin.ear}"/>
190 <circle cx="44.2" cy="33.5" r="2.4" fill="${skin.ear}"/>
191 <ellipse cx="34" cy="32.5" rx="10.2" ry="11" fill="${skin.base}"/>
192 <path d="M 40,24.5 Q 45.5,32 40.5,42 Q 44.8,33 40,24.5 Z" fill="${skin.shade}" opacity="0.55"/>
193 <path d="M 23.4,30 Q 23,18.6 34,18.2 Q 45,18.6 44.6,30 Q 41,25.2 34,25.6 Q 27,25.2 23.4,30 Z" fill="${cap}"/>
194 <path d="M 23.4,30 Q 27,25.2 34,25.6 Q 41,25.2 44.6,30 Q 41,27.4 34,27.6 Q 27,27.4 23.4,30 Z" fill="${capDark}"/>`
195
196 const bandage =
197 !dead && hp < 50
198 ? `<rect x="25" y="26.2" width="18" height="3.4" rx="1.2" fill="#f3efe6" transform="rotate(-8 34 28)"/><circle cx="38.5" cy="27.4" r="0.9" fill="#d84a4a"/>`
199 : ''
200 const sweat =
201 !dead && hp < 25
202 ? `<path d="M 45.5,24 q 1.6,2.8 0,3.8 q -1.6,-1 0,-3.8 z" fill="#5ec4ff"><animate attributeName="opacity" values="1;0.2;1" dur="0.9s" repeatCount="indefinite"/></path>`
203 : ''
204 // Dead: X eyes and a halo that bobs above him, glinting.
205 const deadFace = dead
206 ? `<path d="M 28.4,30.6 l 3,3 M 31.4,30.6 l -3,3 M 36.6,30.6 l 3,3 M 39.6,30.6 l -3,3" stroke="${INK}" stroke-width="1.4" stroke-linecap="round"/>
207 <path d="M 31,39.4 L 37,39.4" stroke="${INK}" stroke-width="1.4" stroke-linecap="round"/>
208 <path d="M 35,39.4 q 0.6,2.6 2,0" fill="#e57373"/>
209 <g>${move(`values="0 0;0 -1.6;0 0" dur="2.4s"`)}
210 <ellipse cx="34" cy="14.6" rx="8" ry="2.2" fill="none" stroke="#f5d061" stroke-width="1.8">
211 <animate attributeName="stroke-opacity" values="1;0.55;1" dur="2.4s" repeatCount="indefinite"/>
212 </ellipse></g>`
213 : face(hp)
214
215 const headGroup = `<g>${move(mv.head)}${head}${bandage}${deadFace}${sweat}</g>`
216 const all = pick + body + headGroup
217 return mv.tremble ? `<g>${move(mv.tremble)}${all}</g>` : all
218}
219
220// The villager in a round avatar. His ring is coloured by his health, and a
221// bright arc orbits it.
222const avatar = (m: Mood, low: number | null) => {
223 const ring = m === 'dead' ? '#59616d' : low === null ? '#3a4350' : T[tone(low)][0]
224 const pulse =
225 m === 'critical' ? `<animate attributeName="stroke-opacity" values="1;0.3;1" dur="0.9s" repeatCount="indefinite"/>` : ''
226 const orbit =
227 m === 'dead'
228 ? ''
229 : `<circle cx="${AV.cx}" cy="${AV.cy}" r="${AV.r + 3.5}" fill="none" stroke="${ring}" stroke-width="2" stroke-linecap="round" stroke-dasharray="22 160" opacity="0.85" filter="url(#soft)">
230 <animateTransform attributeName="transform" type="rotate" values="0 ${AV.cx} ${AV.cy};360 ${AV.cx} ${AV.cy}" dur="${low !== null && low < 25 ? 1.2 : 3.2}s" repeatCount="indefinite"/>
231 </circle>`
232 return `
233 <circle cx="${AV.cx}" cy="${AV.cy}" r="${AV.r + 5}" fill="${ring}" opacity="0.10"/>
234 ${orbit}
235 <circle cx="${AV.cx}" cy="${AV.cy}" r="${AV.r}" fill="url(#avbg)"/>
236 <g clip-path="url(#avclip)">${villager(m, low ?? 100)}</g>
237 <circle cx="${AV.cx}" cy="${AV.cy}" r="${AV.r}" fill="none" stroke="${ring}" stroke-width="2.5">${pulse}</circle>`
238}
239
240// ---- the bars ----
241
242// A heart that beats, faster the lower the health.
243const heartIcon = (x: number, cy: number, fill: string, hp: number | null) => {
244 const beat = hp === null || hp <= 0 ? '' : `<animateTransform attributeName="transform" type="scale" values="1;1.28;1;1.16;1" keyTimes="0;0.14;0.3;0.44;1" dur="${hp < 25 ? 0.6 : hp < 50 ? 0.9 : 1.3}s" repeatCount="indefinite"/>`
245 return `<g transform="translate(${x + 6} ${cy})"><g>${beat}<path transform="translate(-6.2 -6.6) scale(0.62)" d="M10 18 L2.4 10.4 A5 5 0 0 1 10 3.6 A5 5 0 0 1 17.6 10.4 Z" fill="${fill}"/></g></g>`
246}
247const calendarIcon = (x: number, cy: number, fill: string) =>
248 `<g transform="translate(${x} ${cy - 6})"><rect x="0.5" y="1.5" width="11" height="10" rx="2" fill="none" stroke="${fill}" stroke-width="1.5"/><path d="M 0.5,5 L 11.5,5 M 3.5,0 L 3.5,3 M 8.5,0 L 8.5,3" stroke="${fill}" stroke-width="1.5" stroke-linecap="round"/></g>`
249
250// One row: icon and label, a living bar, the value and a reset chip. The bar
251// sweeps in from empty, energy stripes flow through it, a shine passes along
252// it now and then, and sparks drift off its glowing leading edge.
253// Sci-fi accents, all drawn over the card without moving anything on it.
254const HUD_CYAN = '#5ec4ff'
255
256// Thin targeting brackets at the card's four corners, pulsing gently.
257const cornerBrackets = () => {
258 const L = 14
259 const i = 6
260 const W = HUD_W
261 const H = HUD_H
262 const d = [
263 `M ${i},${i + L} L ${i},${i} L ${i + L},${i}`,
264 `M ${W - i - L},${i} L ${W - i},${i} L ${W - i},${i + L}`,
265 `M ${i},${H - i - L} L ${i},${H - i} L ${i + L},${H - i}`,
266 `M ${W - i - L},${H - i} L ${W - i},${H - i} L ${W - i},${H - i - L}`,
267 ].join(' ')
268 return `<path d="${d}" fill="none" stroke="${HUD_CYAN}" stroke-width="1.4" stroke-linecap="round" opacity="0.45">
269 <animate attributeName="opacity" values="0.25;0.6;0.25" dur="3.6s" repeatCount="indefinite"/>
270 </path>`
271}
272
273// A faint dot grid, and a light band that sweeps across like a hologram scan.
274const hologram = () => `
275 <g clip-path="url(#cardclip)">
276 <rect x="0" y="0" width="${HUD_W}" height="${HUD_H}" fill="url(#dots)"/>
277 <rect x="-120" y="0" width="90" height="${HUD_H}" fill="url(#scanband)">
278 <animate attributeName="x" values="-120;-120;${HUD_W + 30}" keyTimes="0;0.55;1" dur="7s" repeatCount="indefinite"/>
279 </rect>
280 </g>`
281
282// A dashed radar ring with tick marks, turning against the glowing arc.
283const radar = (m: Mood) =>
284 m === 'dead'
285 ? ''
286 : `<g opacity="0.55">
287 <circle cx="${AV.cx}" cy="${AV.cy}" r="${AV.r + 7.5}" fill="none" stroke="${HUD_CYAN}" stroke-width="0.9" stroke-dasharray="2 5">
288 <animateTransform attributeName="transform" type="rotate" values="360 ${AV.cx} ${AV.cy};0 ${AV.cx} ${AV.cy}" dur="12s" repeatCount="indefinite"/>
289 </circle>
290 ${[0, 90, 180, 270].map(a => `<line x1="${AV.cx}" y1="${AV.cy - AV.r - 5}" x2="${AV.cx}" y2="${AV.cy - AV.r - 10}" stroke="${HUD_CYAN}" stroke-width="1.2" transform="rotate(${a} ${AV.cx} ${AV.cy})"/>`).join('')}
291 </g>`
292
293// Electric arcs crackling along a full bar's edges.
294const zigzag = (x0: number, x1: number, y: number, amp: number, seed: number) => {
295 const pts: string[] = []
296 for (let x = x0, k = 0; x <= x1; x += 7, k++) {
297 const jitter = ((Math.sin((k + 1) * 12.9898 + seed * 78.233) * 43758.5453) % 1 + 1) % 1
298 pts.push(`${x.toFixed(1)},${(y + (k % 2 === 0 ? -1 : 1) * amp * (0.4 + jitter)).toFixed(1)}`)
299 }
300 return pts.join(' ')
301}
302const overcharge = (x0: number, x1: number, top: number) => `
303 <g>
304 <polyline points="${zigzag(x0, x1, top - 1, 2.4, 1)}" fill="none" stroke="#e8fbff" stroke-width="1.1" opacity="0">
305 <animate attributeName="opacity" values="0;0.95;0;0;0.8;0" keyTimes="0;0.08;0.16;0.5;0.58;0.66" dur="1.7s" repeatCount="indefinite"/>
306 </polyline>
307 <polyline points="${zigzag(x0, x1, top + BAR_H + 1, 2.4, 2)}" fill="none" stroke="${HUD_CYAN}" stroke-width="1.1" opacity="0">
308 <animate attributeName="opacity" values="0;0;0.9;0;0;0.7;0" keyTimes="0;0.25;0.33;0.41;0.75;0.83;0.91" dur="1.7s" repeatCount="indefinite"/>
309 </polyline>
310 </g>`
311
312// Flashing red chevrons warning that a bar is low.
313const redAlert = (x: number, cy: number) => `
314 <text x="${x}" y="${cy + 5}" ${UI} font-size="13" font-weight="900" letter-spacing="-1" fill="#ff4d5e">❯❯
315 <animate attributeName="opacity" values="1;0.15;1" dur="0.7s" repeatCount="indefinite"/>
316 </text>`
317
318const row = (bar: Bar, cy: number, id: string, delay: number) => {
319 const hp = bar.hp
320 const top = cy - BAR_H / 2
321 const colour = (hp === null ? T.faint : T[tone(hp)][0]) ?? T.faint
322 const light = (hp === null ? T.faint : T[tone(hp)][1]) ?? T.faint
323 const icon = bar.label === 'SESSION' ? heartIcon(ICON_X, cy, colour, hp) : calendarIcon(ICON_X + 1, cy, colour)
324 const label = `<text x="${LABEL_X}" y="${cy + 4}" ${UI} font-size="11.5" font-weight="700" letter-spacing="1.4" fill="${T.muted}">${bar.label}</text>`
325 const track = `<rect x="${BAR_X}" y="${top}" width="${BAR_W}" height="${BAR_H}" rx="${BAR_H / 2}" fill="${hp === null || hp >= 100 ? T.track : T.lost}" stroke="${T.trackEdge}" stroke-width="1"/>`
326 let ticks = ''
327 for (let k = 1; k < 10; k++) {
328 const x = BAR_X + (BAR_W * k) / 10
329 ticks += `<line x1="${x}" y1="${top + 2}" x2="${x}" y2="${top + BAR_H - 2}" stroke="#0b0e12" stroke-opacity="0.4" stroke-width="1.5"/>`
330 }
331 if (hp === null) {
332 return `${icon}${label}${track}${ticks}<text x="${VALUE_X}" y="${cy + 5}" ${UI} font-size="15" font-weight="600" fill="${T.faint}">—</text>`
333 }
334 const w = hp <= 0 ? 0 : Math.max(BAR_H, Math.round((BAR_W * hp) / 100))
335 const sweep = `begin="${delay}s" dur="1.1s" calcMode="spline" keyTimes="0;1" keySplines="0.16 1 0.3 1" fill="freeze"`
336 const pulse = hp < 25 && hp > 0 ? `<animate attributeName="opacity" values="1;0.55;1" dur="0.9s" repeatCount="indefinite"/>` : ''
337 const edge = BAR_X + w - BAR_H / 2
338 const sparks = [0, 1, 2]
339 .map(
340 k => `<circle cx="${edge}" cy="${cy}" r="1.4" fill="${light}" opacity="0">
341 <animate attributeName="cy" values="${cy};${cy - 13}" dur="1.4s" begin="${delay + 1.1 + k * 0.47}s" repeatCount="indefinite"/>
342 <animate attributeName="cx" values="${edge};${edge + (k - 1) * 5}" dur="1.4s" begin="${delay + 1.1 + k * 0.47}s" repeatCount="indefinite"/>
343 <animate attributeName="opacity" values="0;0.9;0" dur="1.4s" begin="${delay + 1.1 + k * 0.47}s" repeatCount="indefinite"/>
344 </circle>`,
345 )
346 .join('')
347 const fill =
348 w > 0
349 ? `<g>${pulse}
350 <clipPath id="clip-${id}"><rect x="${BAR_X}" y="${top - 1}" width="0" height="${BAR_H + 2}" rx="${BAR_H / 2}"><animate attributeName="width" values="0;${w}" ${sweep}/></rect></clipPath>
351 <g clip-path="url(#clip-${id})">
352 <rect x="${BAR_X}" y="${top}" width="${w}" height="${BAR_H}" rx="${BAR_H / 2}" fill="url(#${id})" filter="url(#soft)"/>
353 <rect x="${BAR_X}" y="${top}" width="${w}" height="${BAR_H}" rx="${BAR_H / 2}" fill="url(#stripes)"/>
354 <rect x="${BAR_X + 4}" y="${top + 2}" width="${Math.max(0, w - 10)}" height="3" rx="1.5" fill="#fff" opacity="0.32"/>
355 <rect x="${BAR_X - 60}" y="${top - 2}" width="46" height="${BAR_H + 4}" fill="url(#shine)">
356 <animate attributeName="x" values="${BAR_X - 60};${BAR_X - 60};${BAR_X + w + 10}" keyTimes="0;0.55;1" dur="3.4s" begin="${delay + 1.2}s" repeatCount="indefinite"/>
357 </rect>
358 </g>
359 <circle cx="${BAR_X + BAR_H / 2}" cy="${cy}" r="${BAR_H / 2 + 1.5}" fill="${light}" opacity="0.5" filter="url(#soft)">
360 <animate attributeName="cx" values="${BAR_X + BAR_H / 2};${edge}" ${sweep}/>
361 <animate attributeName="r" values="${BAR_H / 2};${BAR_H / 2 + 2.5};${BAR_H / 2}" dur="1.6s" repeatCount="indefinite"/>
362 </circle>
363 <circle cx="${BAR_X + BAR_H / 2}" cy="${cy}" r="${BAR_H / 2 - 2}" fill="#fff" opacity="0.85">
364 <animate attributeName="cx" values="${BAR_X + BAR_H / 2};${edge}" ${sweep}/>
365 </circle>
366 ${sparks}
367 </g>`
368 : ''
369 const chipText = bar.respawn
370 const isMax = hp >= 90
371 const value = isMax
372 ? `<text x="${VALUE_X}" y="${cy + 5.5}" ${UI} font-size="15" font-weight="800" letter-spacing="1.5" fill="${HUD_CYAN}" filter="url(#soft)">MAX<tspan dx="4" font-size="12" font-weight="600" letter-spacing="0" fill="${T.muted}">${hp}</tspan></text>`
373 : `<text x="${VALUE_X}" y="${cy + 5.5}" ${UI} font-size="17" font-weight="700" fill="${T.text}">${hp}<tspan font-size="12" font-weight="600" fill="${T.muted}">/100</tspan></text>`
374 const fx = isMax ? overcharge(BAR_X + 4, BAR_X + w - 4, top) : hp > 0 && hp < 25 ? redAlert(VALUE_X + 66 + 78 + 5, cy) : ''
375 const chipX = VALUE_X + 66
376 const CHIP_W = 78 // one width for both rows, so their edges line up
377 const chip = chipText
378 ? `<rect x="${chipX}" y="${cy - 10}" width="${CHIP_W}" height="20" rx="10" fill="${T.chip}" stroke="${T.trackEdge}"/>
379 <text x="${chipX + CHIP_W / 2}" y="${cy + 4.5}" text-anchor="middle" ${UI} font-size="12.5" font-weight="500" fill="${T.muted}">${chipText}</text>`
380 : ''
381 return icon + label + track + fill + ticks + fx + value + chip
382}
383
384// The contributor ranks, lowest first, as the draft rank sheet draws them.
385export type Rank = 'scout' | 'contributor' | 'innovator' | 'top' | 'god'
386type RankLook = { label: string; name: string; stops: [string, string, string]; edge: string; ink: string; glow?: string; shine?: true }
387export const RANKS: Record<Rank, RankLook> = {
388 scout: { label: '◇ SCOUT', name: 'Scout', stops: ['#d9e2ec', '#9aa9b8', '#6c7b8a'], edge: '#f1f5f9', ink: '#1b2430' },
389 contributor: { label: '✦ CONTRIBUTOR', name: 'Contributor', stops: ['#ffd2a8', '#d98a4e', '#a65a26'], edge: '#ffe7d1', ink: '#2e1606' },
390 innovator: { label: '◆ INNOVATOR', name: 'Innovator', stops: ['#c7f0ff', '#5ec4ff', '#2b8fd6'], edge: '#e6f7ff', ink: '#062033', glow: '#5ec4ff' },
391 top: { label: '★ OPEN', name: 'Top contributor', stops: ['#ffe27a', '#f7b733', '#e98a15'], edge: '#fff3c4', ink: '#2b1a03', glow: '#f7b733', shine: true },
392 god: { label: '⚡ GOD MODE', name: 'GOD MODE', stops: ['#ffe27a', '#f7b733', '#e98a15'], edge: '#fff3c4', ink: '#2b1a03', glow: '#f7b733', shine: true },
393}
394
395// The rank badge at the card's right end, past the reset times: a badge to
396// show, not a control. Its right edge sits 16 in from the card's, as the
397// avatar's left does; long names get a wider pill.
398const BADGE_H = 26
399const BADGE_Y = 19
400const badgeBox = (rank: Rank) => {
401 const w = Math.min(BADGE_ZONE, Math.max(108, Math.round(RANKS[rank].label.length * 7.6 + 28)))
402 return { x: 960 - 16 - w, w }
403}
404const rankBadge = (rank: Rank) => {
405 const look = RANKS[rank]
406 const { x, w } = badgeBox(rank)
407 const pill = `<rect x="${x}" y="${BADGE_Y}" width="${w}" height="${BADGE_H}" rx="${BADGE_H / 2}" fill="url(#badgegrad)"/>`
408 const shine = look.shine
409 ? `<g clip-path="url(#badgeclip)">
410 <rect x="${x - 40}" y="${BADGE_Y - 10}" width="22" height="${BADGE_H + 20}" fill="#fff" opacity="0.55" transform="skewX(-20)">
411 <animate attributeName="x" values="${x - 40};${x - 40};${x + w + 30}" keyTimes="0;0.6;1" dur="3s" repeatCount="indefinite"/>
412 </rect>
413 </g>`
414 : ''
415 return `
416 ${look.glow ? `<g filter="url(#badgeglow)">${pill}</g>` : pill}
417 ${shine}
418 <rect x="${x + 0.5}" y="${BADGE_Y + 0.5}" width="${w - 1}" height="${BADGE_H - 1}" rx="${(BADGE_H - 1) / 2}" fill="none" stroke="${look.edge}" stroke-opacity="0.6"/>
419 <text x="${x + w / 2}" y="${BADGE_Y + 17.5}" text-anchor="middle" ${UI} font-size="${look.label.length > 12 ? 11.5 : 12}" font-weight="800" letter-spacing="${look.label.length > 12 ? 0.9 : 1.2}" fill="${look.ink}">${look.label}</text>`
420}
421
422const badgeDefs = (rank: Rank) => {
423 const look = RANKS[rank]
424 const { x, w } = badgeBox(rank)
425 return `<linearGradient id="badgegrad" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="${look.stops[0]}"/><stop offset="0.55" stop-color="${look.stops[1]}"/><stop offset="1" stop-color="${look.stops[2]}"/></linearGradient>
426 <clipPath id="badgeclip"><rect x="${x}" y="${BADGE_Y}" width="${w}" height="${BADGE_H}" rx="${BADGE_H / 2}"/></clipPath>
427 <filter id="badgeglow" x="-20%" y="-60%" width="140%" height="220%"><feGaussianBlur in="SourceAlpha" stdDeviation="3" result="b"/><feFlood flood-color="${look.glow ?? '#000'}" flood-opacity="0.55"/><feComposite in2="b" operator="in" result="g"/><feMerge><feMergeNode in="g"/><feMergeNode in="SourceGraphic"/></feMerge></filter>`
428}
429
430const gradient = (id: string, hp: number | null) => {
431 const [a, b] = T[tone(hp ?? 100)]
432 return `<linearGradient id="${id}" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="${a}"/><stop offset="1" stop-color="${b}"/></linearGradient>`
433}
434
435// Two soft blobs of the health colour drifting slowly behind the card.
436const aurora = (colour: string) => `
437 <g clip-path="url(#cardclip)" opacity="0.5">
438 <ellipse cx="160" cy="34" rx="170" ry="46" fill="url(#aura)">
439 <animate attributeName="cx" values="120;420;120" dur="14s" repeatCount="indefinite"/>
440 </ellipse>
441 <ellipse cx="700" cy="30" rx="150" ry="40" fill="url(#aura2)">
442 <animate attributeName="cx" values="760;460;760" dur="18s" repeatCount="indefinite"/>
443 </ellipse>
444 </g>
445 <radialGradient id="aura"><stop offset="0" stop-color="${colour}" stop-opacity="0.16"/><stop offset="1" stop-color="${colour}" stop-opacity="0"/></radialGradient>
446 <radialGradient id="aura2"><stop offset="0" stop-color="#3b82f6" stop-opacity="0.10"/><stop offset="1" stop-color="#3b82f6" stop-opacity="0"/></radialGradient>`
447
448export const hudSvg = (all: Bar[], rank: Rank = 'god') => {
449 // Fit the bars to this badge, so the gap before it is the same for every rank.
450 BAR_W = 960 - 16 - badgeBox(rank).w - BADGE_GAP - 78 - 66 - 16 - BAR_X
451 VALUE_X = BAR_X + BAR_W + 16
452 const m = mood(all)
453 const dead = killer(all)
454 const known = all.flatMap(b => (b.hp === null ? [] : [b.hp]))
455 const low = known.length === 0 ? null : Math.min(...known)
456 const session = all[0] ?? { label: 'SESSION' as const, hp: null, respawn: '' }
457 const weekly = all[1] ?? { label: 'WEEKLY' as const, hp: null, respawn: '' }
458 const glow = (m === 'dead' ? '#59616d' : low === null ? '#3a4350' : T[tone(low)][0]) ?? '#3a4350'
459
460 const deathScreen = dead
461 ? `<g>
462 <rect x="0.5" y="0.5" width="${HUD_W - 1}" height="${HUD_H - 1}" rx="14" fill="#0b0d11" opacity="0.9"/>
463 <rect x="0.5" y="0.5" width="${HUD_W - 1}" height="${HUD_H - 1}" rx="14" fill="none" stroke="#ef4444" stroke-opacity="0.55"/>
464 ${avatar(m, 0)}
465 <text x="${ICON_X}" y="29" ${UI} font-size="18" font-weight="700" fill="${T.text}">Claude has been defeated<animate attributeName="opacity" values="0;1" dur="0.8s" fill="freeze"/></text>
466 <text x="${ICON_X}" y="49" ${UI} font-size="13" fill="${T.muted}">Out of ${dead.label === 'SESSION' ? 'session' : 'weekly'} hit points · ${backIn(dead.respawn)}</text>
467 </g>`
468 : ''
469
470 return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${HUD_W} ${HUD_H}" width="${HUD_W * 3}" height="${HUD_H * 3}" preserveAspectRatio="xMinYMin meet">
471 <defs>
472 <linearGradient id="card" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#1a2029"/><stop offset="1" stop-color="#12161c"/></linearGradient>
473 <clipPath id="cardclip"><rect x="0.5" y="0.5" width="${HUD_W - 1}" height="${HUD_H - 1}" rx="14"/></clipPath>
474 <pattern id="dots" width="10" height="10" patternUnits="userSpaceOnUse"><circle cx="1" cy="1" r="0.7" fill="#ffffff" opacity="0.05"/></pattern>
475 <linearGradient id="scanband" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="${HUD_CYAN}" stop-opacity="0"/><stop offset="0.5" stop-color="${HUD_CYAN}" stop-opacity="0.09"/><stop offset="1" stop-color="${HUD_CYAN}" stop-opacity="0"/></linearGradient>
476 <radialGradient id="cardglow" cx="0.04" cy="0.5" r="0.32"><stop offset="0" stop-color="${glow}" stop-opacity="0.16"/><stop offset="1" stop-color="${glow}" stop-opacity="0"/></radialGradient>
477 <radialGradient id="avbg" cx="0.5" cy="0.3" r="0.8"><stop offset="0" stop-color="#4b7d46"/><stop offset="0.65" stop-color="#2b4a2c"/><stop offset="1" stop-color="#1b2f1d"/></radialGradient>
478 ${badgeDefs(rank)}
479 <clipPath id="avclip"><circle cx="${AV.cx}" cy="${AV.cy}" r="${AV.r - 1}"/></clipPath>
480 <filter id="soft" x="-5%" y="-80%" width="110%" height="260%"><feGaussianBlur in="SourceGraphic" stdDeviation="2.4" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge></filter>
481 <pattern id="stripes" x="0" y="0" width="14" height="14" patternUnits="userSpaceOnUse" patternTransform="rotate(-35)">
482 <rect width="6" height="14" fill="#ffffff" opacity="0.16"/>
483 <animate attributeName="x" values="0;14" dur="0.9s" repeatCount="indefinite"/>
484 </pattern>
485 <linearGradient id="shine" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="#fff" stop-opacity="0"/><stop offset="0.5" stop-color="#fff" stop-opacity="0.55"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></linearGradient>
486 ${gradient('g0', session.hp)}
487 ${gradient('g1', weekly.hp)}
488 </defs>
489 <rect x="0.5" y="0.5" width="${HUD_W - 1}" height="${HUD_H - 1}" rx="14" fill="url(#card)" stroke="#2a313b"/>
490 ${aurora(glow)}
491 <rect x="0.5" y="0.5" width="${HUD_W - 1}" height="${HUD_H - 1}" rx="14" fill="url(#cardglow)"/>
492 <line x1="14" y1="1.5" x2="${HUD_W - 14}" y2="1.5" stroke="#ffffff" stroke-opacity="0.06"/>
493 ${hologram()}
494 ${radar(m)}
495 ${avatar(m, low)}
496 ${row(session, ROW_CY[0] ?? 20, 'g0', 0.15)}
497 ${row(weekly, ROW_CY[1] ?? 44, 'g1', 0.35)}
498 ${rankBadge(rank)}
499 ${cornerBrackets()}
500 ${deathScreen}
501</svg>`
502}
503types/index.d.ts 16 lines1export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
2
3declare module 'claude-code' {
4 interface PluginState {
5 'villager-hp': {
6 limits: Limit[]
7 // /hp <n> pins both bars to n HP to preview; null shows the real readings.
8 demo: number | null
9 // The clock, ticked each minute so the respawn countdowns move.
10 now: number
11 // The rank badge on the card: scout, contributor, innovator, top or god.
12 rank: 'scout' | 'contributor' | 'innovator' | 'top' | 'god'
13 }
14 }
15}
16