SLOPSHOPPER

pixel-witch

A chibi pixel witch who wanders your consoles, follows you between them, and sometimes picks fights

newbandspinnerrowsguardcommand
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · pixel-witch
› 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 › /witch ⎿ pixel-witch: The witch is hidden in this console. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

Claude Code mods

Mods for Claude Code, built on its function-hook plugin API: they draw inside the terminal and react to what Claude is doing.

Installing with an AI assistant: tell Claude Code "Install the pixel-witch mod from https://github.com/Kyubey64/claude-code-mods". The steps it follows are in INSTALL.md.

pixel-witch

A chibi pixel-art witch who lives in your terminal.

  • Wanders over the conversation (fullscreen) or a strip above the prompt, and reacts: a thinking bubble while Claude works, spell sparkles during tool calls, a ! when one fails, a happy hop when a reply lands, and a nap when things go quiet.
  • Drag her with the mouse and she grumbles ("hmph!", "put me down!") and sulks after. Right-click to hide her.
  • Follows you between consoles: she leaps out of one and drops into the one you are typing in.
  • Five styles, each its own drawing: classic (round chibi), starlight (huge floppy hat, glowing star, cloud of lavender hair), moonbroom (rides a broom side-saddle with a cat), bluebell (super-deformed, giant eyes, gem staff, cat on her shoulder), goldie (tall and slender, long golden hair, upright broom).
  • With two consoles open, a second witch sometimes visits. Rarely they fight: the victim's console is visually wrecked by glitch streaks until a giant hand comes down, grabs the attacker and hauls her off, and a new witch drops in. (Only the drawing; your text is never touched.)

Commands: /witch, /witch skin <name>, /witch summon, /witch fight.

usage-bar (session panel)

A panel docked on the right while the prompt stays at the bottom:

  • Usage: context fill, 5-hour and weekly limits with reset countdowns, as colored meters. Readings are shared between your consoles, and an optional tiny Haiku ping keeps them fresh while idle (pausing after an idle hour).
  • Session: model and effort, permission mode (auto, plan, ...), git branch, uptime, working or idle.
  • Agents: running subagents, then the last few finished.
  • Notifications: usage warnings, finished agents, and every mod's toasts, timestamped.

On narrow or non-fullscreen terminals it falls back to a one-line status bar. Commands: /hud, /usage-ping (ping now), /usage-ping off / on (automatic pings in every console).

Requirements

  • Claude Code 2.1.288 or newer. The function-hook API is early access and may change between releases.
  • The visuals are tuned for Claude Code's fullscreen terminal layout.

Development

Each mod is a plugin folder (.claude-plugin/plugin.json, hooks/, types/). Check one with:

claude plugin validate mods/pixel-witch
claude plugin test mods/pixel-witch

The witch styles are drawn in tools/witch_styles.py, which renders a preview sheet and regenerates mods/pixel-witch/hooks/styles.ts.

License

MIT

Source 8 files
hooks/register.tsx 503 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderElement, RenderInput } from 'claude-code'
3
4import { ALIVE_MS, FIGHT, SKINS, asWorld, chaosLevel, fightPhase, leaderOf, stepWorld } from './coven'
5import type { Ask, Console, World } from './coven'
6import { HAND_W, REGION_H, REGION_W } from './sprites'
7import type { Chaos, Mood, Skin, WitchPose } from '../types'
8
9const isHidden = atom({ plugin: 'pixel-witch', key: 'isHidden' } as const, false)
10const pose = atom({ plugin: 'pixel-witch', key: 'pose' } as const, {
11  x: 4,
12  up: 0,
13  facing: 1,
14  mood: 'wander',
15  isWalking: false,
16  skin: 'classic',
17  isShown: false,
18} as WitchPose)
19// The transcript row she is drawn from, `<component>:<requestId>`.
20const anchor = atom({ plugin: 'pixel-witch', key: 'anchor' } as const, null as string | null)
21const chaos = atom({ plugin: 'pixel-witch', key: 'chaos' } as const, { level: 0, seed: 1 } as Chaos)
22
23const TICK_MS = 200
24const SYNC_MS = 1000
25const SLEEP_AFTER_TICKS = Math.round(90_000 / TICK_MS)
26// Rows the fullscreen layout keeps under the transcript (prompt, band, footer).
27const BOTTOM_SLOT = 8
28
29type OnScreen = { first: number; last: number; of: number }
30type Site = { onScreen: OnScreen; seq: number }
31
32// What she knows of the screen, from the drawings she has seen.
33const sites = new Map<string, Site>()
34const seqOf = new Map<string, number>()
35let nextSeq = 0
36let isFullscreen = false
37let columns = 80
38let rows = 24
39
40// This console's place in the coven.
41const coven = {
42  dir: '',
43  me: '',
44  activeAt: 0,
45  asks: [] as Ask[],
46  world: undefined as World | undefined,
47  isWorking: false,
48}
49
50function noteSite(key: string, onScreen: OnScreen | null | undefined) {
51  if (!seqOf.has(key)) seqOf.set(key, nextSeq++)
52  if (onScreen) sites.set(key, { onScreen, seq: seqOf.get(key) ?? 0 })
53  else if (onScreen === null) sites.delete(key)
54}
55
56// The lowest row on screen: one cut off by the viewport's bottom edge, else
57// the newest.
58function pickAnchor(): string | null {
59  if (!isFullscreen) return null
60  let best: [string, number] | undefined
61  for (const [key, row] of sites) {
62    const isCut = row.onScreen.last < row.onScreen.of - 1
63    const score = isCut ? Number.MAX_SAFE_INTEGER : row.seq
64    if (best === undefined || score > best[1]) best = [key, score]
65  }
66
67  return best?.[0] ?? null
68}
69
70const viewRows = () => (isFullscreen ? Math.max(REGION_H, rows - BOTTOM_SLOT) : REGION_H)
71
72// Her own region's top row from the top of the view, and the hand's height.
73function handRows(at: WitchPose): number {
74  const witchTop = viewRows() - at.up - REGION_H
75  return Math.min(40, Math.max(5, witchTop + 3))
76}
77
78// Everything she draws over one console: the wreckage of an attack, the
79// hand, and her; `floor` is the site row her feet stand on.
80function overlay($: EngineInterface, e: RenderInput, at: WitchPose, wreck: Chaos, floor: number) {
81  if (e.surface !== 'terminal') return []
82  const { Box, Client } = $.ui.resolve(e)
83  const top = floor - at.up - REGION_H
84  const parts: RenderElement[] = []
85  if (wreck.level > 0) {
86    const height = viewRows()
87    parts.push(
88      <Box position="absolute" top={floor - height} left={0}>
89        <Client
90          key="chaos"
91          module="./chaos-client.tsx"
92          width={columns}
93          height={height}
94          props={{ columns, rows: height, level: wreck.level, seed: wreck.seed }}
95        />
96      </Box>,
97    )
98  }
99  if (at.isShown && at.hand) {
100    const tall = handRows(at)
101    parts.push(
102      <Box position="absolute" top={top - tall + 3 - at.hand.drop} left={at.x + 6}>
103        <Client
104          key="hand"
105          module="./hand-client.tsx"
106          width={HAND_W}
107          height={tall}
108          props={{ rows: tall, isClosed: at.hand.isClosed }}
109        />
110      </Box>,
111    )
112  }
113  if (at.isShown) {
114    parts.push(
115      <Box position="absolute" top={top} left={at.x}>
116        <Client key="witch" module="./witch-client.tsx" width={REGION_W} height={REGION_H} props={at} />
117      </Box>,
118    )
119  }
120
121  return parts
122}
123
124async function site($: EngineInterface, e: RenderInput, pass: () => Promise<RenderElement>) {
125  const key = `${e.component}:${e.requestId}`
126  const props = e.props as { onScreen?: OnScreen | null }
127  noteSite(key, props.onScreen)
128  if (e.viewport?.isFullscreen !== undefined) isFullscreen = e.viewport.isFullscreen
129  if (e.viewport) rows = e.viewport.rows
130
131  if ((await read($, anchor)) !== key || (await read($, isHidden))) return pass()
132  const at = sites.get(key)
133  const tree = await pass()
134  if (at === undefined || e.surface !== 'terminal') return tree
135  const parts = overlay($, e, await read($, pose), await read($, chaos), at.onScreen.last + 1)
136  if (parts.length === 0) return tree
137  const { Box } = $.ui.resolve(e)
138
139  return (
140    <Box flexDirection="column">
141      {tree}
142      {parts}
143    </Box>
144  )
145}
146
147function parse(text: unknown): unknown {
148  try {
149    return JSON.parse(String(text))
150  } catch {
151    return undefined
152  }
153}
154
155// One heartbeat: tell the coven this console lives (and what it asked for),
156// read the others, and if this console leads, move the world on a tick.
157async function sync($: EngineInterface, skin: Skin | 'random'): Promise<World> {
158  const now = await $.clock.now()
159  if (coven.dir === '') {
160    const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? '.'
161    coven.dir = `${home.replace(/\\/g, '/')}/.claude/pixel-witch-coven`
162    coven.me = await $.session.id()
163  }
164  const mine: Console = { id: coven.me, seen: now, active: coven.activeAt, asks: coven.asks }
165  await $.fs.write(`${coven.dir}/consoles/${coven.me}.json`, JSON.stringify(mine))
166
167  const consoles: Console[] = []
168  for (const entry of await $.fs.list(`${coven.dir}/consoles`)) {
169    if (!entry.name.endsWith('.json') || now - entry.mtimeMs > ALIVE_MS) continue
170    const heard = parse(await $.fs.read(`${coven.dir}/consoles/${entry.name}`))
171    if (heard && typeof (heard as Console).id === 'string') consoles.push(heard as Console)
172  }
173
174  const worldPath = `${coven.dir}/world.json`
175  const stored = (await $.fs.exists(worldPath)) ? parse(await $.fs.read(worldPath)) : undefined
176  let world = stored === undefined ? (coven.world ?? asWorld(undefined)) : asWorld(stored)
177  if (leaderOf(consoles, now) === coven.me) {
178    const next = stepWorld(world, consoles, now, Math.random, skin)
179    if (JSON.stringify(next) !== JSON.stringify(world)) await $.fs.write(worldPath, JSON.stringify(next))
180    world = next
181  }
182  coven.world = world
183  coven.asks = coven.asks.filter(ask => ask.at > (world.handled[coven.me] ?? 0))
184
185  return world
186}
187
188async function markActive($: EngineInterface) {
189  coven.activeAt = await $.clock.now()
190}
191
192function isSkin(name: string): name is Skin {
193  return (SKINS as string[]).includes(name)
194}
195
196export const register: Register = (on, options) => {
197  const skinOption = String(options.skin ?? 'classic')
198  const configSkin: Skin | 'random' = isSkin(skinOption) ? skinOption : 'random'
199
200  let tick = 0
201  let x = 4
202  let up = 0
203  let facing: 1 | -1 = 1
204  let target = { x: 4, up: 0 }
205  let pause = 0
206  let isHeld = false
207  let activeTools = 0
208  let lastActivity = 0
209  let flash: { mood: Mood; until: number } | undefined
210  // Whether a witch is in this console, and which one.
211  let presence: 'gone' | 'arriving' | 'here' | 'leaving' = 'gone'
212  let witchId = -1
213  let skin: Skin = configSkin === 'random' ? 'classic' : configSkin
214  let isSolo = false
215  let now = 0
216
217  const touch = () => {
218    lastActivity = tick
219  }
220  const startFlash = (mood: Mood, ms: number) => {
221    flash = { mood, until: tick + Math.round(ms / TICK_MS) }
222    touch()
223  }
224  const xMax = () => Math.max(0, columns - REGION_W)
225  const upMax = () => (isFullscreen ? Math.max(0, rows - BOTTOM_SLOT - REGION_H) : 0)
226  const clamp = (n: number, max: number) => Math.min(Math.max(0, Math.round(n)), max)
227
228  function arrive(id: number, as: Skin) {
229    witchId = id
230    skin = as
231    presence = 'arriving'
232    target = { x: Math.floor(Math.random() * (xMax() + 1)), up: 0 }
233    if (isFullscreen) {
234      x = target.x
235      up = upMax() + REGION_H
236    } else {
237      x = -REGION_W
238      up = 0
239    }
240  }
241
242  // What the coven's world means for this console.
243  function apply(world: World) {
244    const mine = world.witches.find(w => w.home === coven.me)
245    const fight = world.fight
246    if (mine) {
247      const isHauledOff = fight?.attacker === mine.id && ['gone', 'over'].includes(fightPhase(fight, now))
248      if (!isHauledOff && (presence === 'gone' || presence === 'leaving' || mine.id !== witchId)) {
249        arrive(mine.id, mine.skin)
250      }
251      skin = mine.skin
252    } else if (presence === 'here' || presence === 'arriving') {
253      presence = 'leaving'
254    }
255  }
256
257  function fightRole(): { phase: ReturnType<typeof fightPhase>; role: 'attacker' | 'victim'; t: number } | undefined {
258    const fight = coven.world?.fight
259    if (!fight || isSolo) return undefined
260    const role = fight.attacker === witchId ? 'attacker' : fight.victim === witchId ? 'victim' : undefined
261    if (role === undefined) return undefined
262
263    return { phase: fightPhase(fight, now), role, t: now - fight.start }
264  }
265
266  function mood(): Mood {
267    if (isHeld) return 'grabbed'
268    const fight = fightRole()
269    if (fight?.role === 'attacker') {
270      if (fight.phase === 'cast') return 'attack'
271      if (fight.phase === 'chaos') return 'cheer'
272      if (fight.phase === 'hand') return 'oops'
273      return 'grabbed'
274    }
275    if (fight?.role === 'victim' && ['cast', 'chaos', 'hand'].includes(fight.phase)) return 'scared'
276    if (flash && tick < flash.until) return flash.mood
277    if (activeTools > 0) return 'cast'
278    if (coven.isWorking) return 'think'
279    if (tick - lastActivity > SLEEP_AFTER_TICKS) return 'sleep'
280    return 'wander'
281  }
282
283  function hidden(): WitchPose {
284    return { x, up, facing, mood: 'wander', isWalking: false, skin, isShown: false }
285  }
286
287  function step(): WitchPose {
288    tick += 1
289    if (presence === 'gone') return hidden()
290
291    if (presence === 'arriving') {
292      if (isFullscreen && up > target.up) {
293        up = Math.max(target.up, up - 2)
294      } else if (!isFullscreen && x < target.x) {
295        facing = 1
296        x = Math.min(target.x, x + 3)
297      } else {
298        presence = 'here'
299        startFlash('cheer', 1200)
300      }
301      return { x, up, facing, mood: 'fall', isWalking: !isFullscreen, skin, isShown: true }
302    }
303
304    if (presence === 'leaving') {
305      if (isFullscreen) up += 2
306      else x += 3
307      if ((isFullscreen && up > upMax() + REGION_H + 2) || (!isFullscreen && x > columns)) presence = 'gone'
308      return { x, up, facing: 1, mood: 'jump', isWalking: false, skin, isShown: presence !== 'gone' }
309    }
310
311    const fight = fightRole()
312    if (fight?.role === 'attacker' && (fight.phase === 'hand' || fight.phase === 'yank')) {
313      const reach = (fight.t - FIGHT.handStart) / (FIGHT.grabAt - FIGHT.handStart)
314      const at: WitchPose = { x, up, facing, mood: mood(), isWalking: false, skin, isShown: true }
315      if (fight.phase === 'hand') {
316        return { ...at, hand: { drop: Math.round((1 - Math.min(1, reach)) * handRows(at)), isClosed: false } }
317      }
318      up += 1
319      return { ...at, up, hand: { drop: 0, isClosed: true } }
320    }
321    if (fight?.role === 'attacker' && fight.phase !== 'cast' && fight.phase !== 'chaos') {
322      presence = 'gone'
323      return hidden()
324    }
325
326    x = clamp(x, xMax())
327    up = clamp(up, upMax())
328    const now = mood()
329    const isAtTarget = x === target.x && up === target.up
330    if (now !== 'wander' || isHeld) {
331      return { x, up, facing, mood: now, isWalking: false, skin, isShown: true }
332    }
333    if (pause > 0) {
334      pause -= 1
335    } else if (isAtTarget) {
336      pause = 10 + Math.floor(Math.random() * 30)
337      // Back to the floor half the time, up the screen the rest.
338      const goUp = Math.random() < 0.5 ? 0 : Math.floor(Math.random() * (upMax() + 1))
339      target = { x: Math.floor(Math.random() * (xMax() + 1)), up: goUp }
340    } else {
341      if (target.x !== x) {
342        facing = target.x > x ? 1 : -1
343        x += facing
344      }
345      if (target.up !== up && tick % 2 === 0) up += target.up > up ? 1 : -1
346    }
347
348    return { x, up, facing, mood: now, isWalking: pause === 0 && !isAtTarget, skin, isShown: true }
349  }
350
351  on('session.start', async ($, e, next) => {
352    await $.command.register({
353      name: 'witch',
354      description: 'The pixel witch: hide, show, skin <name>, summon, fight',
355    })
356    now = await $.clock.now()
357    coven.activeAt = now
358
359    $.clock.every(TICK_MS, () => {
360      const stepped = step()
361      void update($, pose, old => (JSON.stringify(old) === JSON.stringify(stepped) ? old : stepped))
362      const pick = pickAnchor()
363      void update($, anchor, old => (old === pick ? old : pick))
364    })
365
366    $.clock.every(SYNC_MS, () => {
367      void (async () => {
368        now = await $.clock.now()
369        try {
370          apply(await sync($, configSkin))
371          isSolo = false
372        } catch {
373          // No shared folder: she lives in this console alone.
374          isSolo = true
375          if (presence === 'gone') arrive(0, skin)
376        }
377        const fight = coven.world?.fight
378        const victim = fight && coven.world?.witches.find(w => w.id === fight.victim)
379        const level = fight && victim?.home === coven.me && !isSolo ? chaosLevel(fight, now) : 0
380        const rounded = Math.round(level * 20) / 20
381        const seed = fight?.start ?? 1
382        await update($, chaos, old => (old.level === rounded && old.seed === seed ? old : { level: rounded, seed }))
383      })()
384    })
385    $.ui.invalidate('ui.render')
386
387    return next(e)
388  })
389
390  on('command.run', { command: 'witch' }, async ($, e) => {
391    const [verb = '', arg = ''] = e.args.trim().toLowerCase().split(/\s+/)
392    const at = await $.clock.now()
393    await markActive($)
394    if (verb === 'skin') {
395      if (!isSkin(arg)) return { text: `Skins: ${SKINS.join(', ')}.` }
396      coven.asks.push({ kind: 'skin', skin: arg, at })
397      skin = arg
398      return { text: `The witch changes into ${arg}.` }
399    }
400    if (verb === 'summon' || verb === 'fight') {
401      coven.asks.push({ kind: verb, at })
402      return {
403        text:
404          verb === 'summon'
405            ? 'A second witch is called to another console (needs two consoles open).'
406            : 'The witches are spoiling for a fight (needs two consoles open).',
407      }
408    }
409    const wasHidden = await read($, isHidden)
410    const hide = verb === 'hide' ? true : verb === 'show' ? false : !wasHidden
411    await update($, isHidden, () => hide)
412
413    return { text: hide ? 'The witch is hidden in this console.' : 'The witch is back.' }
414  })
415
416  on('ui.message', async ($, e, next) => {
417    if (e.element !== 'witch') return next(e)
418    await markActive($)
419    const data = e.data as { type?: string; x?: number; up?: number }
420    if (data.type === 'hide') {
421      isHeld = false
422      await update($, isHidden, () => true)
423      $.ui.toast('The witch hides in this console. /witch show brings her back.')
424      return {}
425    }
426    if (typeof data.x === 'number') x = clamp(data.x, xMax())
427    if (typeof data.up === 'number') up = clamp(data.up, upMax())
428    if (data.type === 'grab' || data.type === 'drag') {
429      isHeld = true
430      touch()
431    } else if (data.type === 'drop') {
432      isHeld = false
433      target = { x, up }
434      pause = 15
435      startFlash('huff', 2500)
436    }
437    const mood: Mood = isHeld ? 'grabbed' : 'huff'
438    await update($, pose, old => ({ ...old, x, up, mood, isWalking: false }))
439
440    return {}
441  })
442
443  // Typing in a console makes it the active one: the witch follows.
444  on('prompt.edit', async ($, e, next) => {
445    await markActive($)
446
447    return next(e)
448  })
449
450  on('prompt.submit', async ($, e, next) => {
451    await markActive($)
452    coven.isWorking = true
453    touch()
454
455    return next(e)
456  })
457
458  on('tool.call', async ($, e, next) => {
459    activeTools += 1
460    touch()
461    try {
462      const ran = await next(e)
463      if (ran.deny !== undefined || ran.isError) startFlash('oops', 1500)
464      return ran
465    } finally {
466      activeTools = Math.max(0, activeTools - 1)
467    }
468  })
469
470  on('turn.complete', ($, e, next) => {
471    coven.isWorking = false
472    startFlash('cheer', 1800)
473
474    return next(e)
475  })
476
477  // Fullscreen: she rides on the lowest transcript row on screen and paints
478  // over the whole viewport from there.
479  on('ui.render', { component: 'UserMessage' }, ($, e, next) => site($, e, () => next(e)))
480  on('ui.render', { component: 'AssistantMessage' }, ($, e, next) => site($, e, () => next(e)))
481  on('ui.render', { component: 'ToolUse' }, ($, e, next) => site($, e, () => next(e)))
482  on('ui.render', { component: 'ToolResult' }, ($, e, next) => site($, e, () => next(e)))
483  on('ui.render', { component: 'ToolGroup' }, ($, e, next) => site($, e, () => next(e)))
484  on('ui.render', { component: 'CommandOutput' }, ($, e, next) => site($, e, () => next(e)))
485  on('ui.render', { component: 'TurnDuration' }, ($, e, next) => site($, e, () => next(e)))
486  on('ui.render', { component: 'InfoNotice' }, ($, e, next) => site($, e, () => next(e)))
487
488  // Main screen: nothing paints outside its own row, so she gets a strip
489  // above the prompt instead.
490  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
491    if (e.viewport?.isFullscreen !== undefined) isFullscreen = e.viewport.isFullscreen
492    columns = e.props.bodyColumns
493    const isQuiet =
494      isFullscreen || e.props.hasSurvey || e.props.maxRows < REGION_H || (await read($, isHidden))
495    if (isQuiet || e.surface !== 'terminal') return next(e)
496
497    const parts = overlay($, e, await read($, pose), await read($, chaos), REGION_H)
498    const { Box } = $.ui.resolve(e)
499
500    return <Box height={REGION_H}>{parts}</Box>
501  })
502}
503
hooks/coven.ts 213 lines
1// The coven: every console running the witch shares one world through files
2// in a folder all of them read. Each console writes its own heartbeat; the
3// living console with the smallest id leads and alone writes the world:
4// which witch lives in which console, and any fight under way.
5
6import type { Skin } from '../types'
7
8export const SKINS: Skin[] = ['classic', 'starlight', 'moonbroom', 'bluebell', 'goldie']
9
10export type Ask = { kind: 'summon' | 'fight' | 'skin'; skin?: Skin; at: number }
11
12export type Console = { id: string; seen: number; active: number; asks?: Ask[] }
13
14export type Witch = { id: number; skin: Skin; home: string; since: number; leaveAt?: number }
15
16export type Fight = { attacker: number; victim: number; start: number }
17
18// witches[0] follows the person from console to console; a second one, when
19// there is one, keeps to another console.
20export type World = {
21  witches: Witch[]
22  fight?: Fight
23  nextId: number
24  handled: Record<string, number>
25}
26
27export const ALIVE_MS = 6000
28// Per leader tick (one a second): a second witch drops by about every seven
29// minutes while two consoles are open, and two witches fall out about once in
30// ten minutes together.
31export const SUMMON_CHANCE = 1 / 420
32export const FIGHT_CHANCE = 1 / 600
33export const VISIT_MS: [number, number] = [5 * 60_000, 15 * 60_000]
34
35// A fight, in milliseconds from its start.
36export const FIGHT = {
37  castEnd: 3000, // the attacker winds up a spell in her console
38  chaosFull: 8000, // the victim's console is wrecked by here
39  handStart: 12_000, // a hand reaches down for the attacker
40  grabAt: 14_500, // and grabs her: the wreckage clears
41  removeAt: 18_000, // she is hauled off-screen
42  newAt: 20_000, // a new witch drops into her console
43}
44
45export type FightPhase = 'cast' | 'chaos' | 'hand' | 'yank' | 'gone' | 'over'
46
47export function fightPhase(fight: Fight, now: number): FightPhase {
48  const t = now - fight.start
49  if (t < FIGHT.castEnd) return 'cast'
50  if (t < FIGHT.handStart) return 'chaos'
51  if (t < FIGHT.grabAt) return 'hand'
52  if (t < FIGHT.removeAt) return 'yank'
53  if (t < FIGHT.newAt) return 'gone'
54  return 'over'
55}
56
57// How wrecked the victim's console is, 0 to 1.
58export function chaosLevel(fight: Fight, now: number): number {
59  const t = now - fight.start
60  if (t < FIGHT.castEnd) return 0
61  if (t < FIGHT.chaosFull) return (t - FIGHT.castEnd) / (FIGHT.chaosFull - FIGHT.castEnd)
62  if (t < FIGHT.grabAt) return 1
63  if (t < FIGHT.grabAt + 1000) return 1 - (t - FIGHT.grabAt) / 1000
64
65  return 0
66}
67
68export function emptyWorld(): World {
69  return { witches: [], nextId: 1, handled: {} }
70}
71
72export function asWorld(value: unknown): World {
73  const v = value as Partial<World> | undefined
74  if (!v || !Array.isArray(v.witches) || typeof v.nextId !== 'number') return emptyWorld()
75  return { witches: v.witches, fight: v.fight, nextId: v.nextId, handled: v.handled ?? {} }
76}
77
78export function alive(consoles: Console[], now: number): Console[] {
79  return consoles.filter(c => now - c.seen < ALIVE_MS)
80}
81
82export function leaderOf(consoles: Console[], now: number): string | undefined {
83  return alive(consoles, now)
84    .map(c => c.id)
85    .sort()[0]
86}
87
88function pick<T>(list: T[], rand: () => number): T | undefined {
89  return list[Math.floor(rand() * list.length)]
90}
91
92function otherSkin(not: Skin | undefined, rand: () => number): Skin {
93  return pick(
94    SKINS.filter(s => s !== not),
95    rand,
96  ) ?? 'classic'
97}
98
99// One leader tick: the world as it stands after the consoles' heartbeats,
100// their asks, the clock and the dice.
101export function stepWorld(
102  before: World,
103  consoles: Console[],
104  now: number,
105  rand: () => number,
106  skin: Skin | 'random',
107): World {
108  const living = alive(consoles, now)
109  if (living.length === 0) return before
110  const world: World = {
111    ...before,
112    witches: before.witches.map(w => ({ ...w })),
113    handled: { ...before.handled },
114  }
115  const isAlive = (id: string) => living.some(c => c.id === id)
116  const active = [...living].sort((a, b) => b.active - a.active)[0]
117  if (active === undefined) return before
118  const spawn = (home: string, skinOf: Skin, leaveAt?: number): Witch => ({
119    id: world.nextId++,
120    skin: skinOf,
121    home,
122    since: now,
123    ...(leaveAt === undefined ? {} : { leaveAt }),
124  })
125  const firstSkin = skin === 'random' ? otherSkin(undefined, rand) : skin
126  const visitEnd = () => now + VISIT_MS[0] + rand() * (VISIT_MS[1] - VISIT_MS[0])
127  const startFight = () => {
128    const [a, b] = world.witches
129    if (!a || !b || world.fight) return
130    const attackerFirst = rand() < 0.5
131    world.fight = { attacker: (attackerFirst ? a : b).id, victim: (attackerFirst ? b : a).id, start: now }
132  }
133  const summon = () => {
134    const follower = world.witches[0]
135    if (!follower || world.witches.length > 1) return
136    const home = pick(
137      living.filter(c => c.id !== follower.home),
138      rand,
139    )
140    if (home) world.witches.push(spawn(home.id, otherSkin(follower.skin, rand), visitEnd()))
141  }
142
143  // What the consoles asked for since the last tick.
144  for (const c of living) {
145    for (const ask of c.asks ?? []) {
146      if (ask.at <= (world.handled[c.id] ?? 0)) continue
147      world.handled[c.id] = ask.at
148      if (ask.kind === 'skin' && ask.skin && world.witches[0]) world.witches[0].skin = ask.skin
149      if (ask.kind === 'summon') summon()
150      if (ask.kind === 'fight') {
151        if (world.witches.length < 2) summon()
152        startFight()
153      }
154    }
155  }
156
157  if (world.witches.length === 0) world.witches.push(spawn(active.id, firstSkin))
158
159  if (world.fight) {
160    const fight = world.fight
161    if (fightPhase(fight, now) === 'over') {
162      // The attacker is gone; a new witch takes her place in her console.
163      const at = world.witches.findIndex(w => w.id === fight.attacker)
164      const old = world.witches[at]
165      if (old) {
166        const home = isAlive(old.home) ? old.home : active.id
167        const fresh = spawn(home, otherSkin(old.skin, rand), at === 0 ? undefined : visitEnd())
168        world.witches[at] = fresh
169      }
170      world.fight = undefined
171    }
172
173    return world
174  }
175
176  // A witch whose console closed: the follower goes to the active one, a
177  // visitor to some other console or away.
178  const follower = world.witches[0]
179  if (follower && !isAlive(follower.home)) {
180    follower.home = active.id
181    follower.since = now
182  }
183  const visitor = world.witches[1]
184  if (visitor) {
185    const elsewhere = living.filter(c => c.id !== world.witches[0]?.home)
186    const isOver = visitor.leaveAt !== undefined && now >= visitor.leaveAt
187    if (isOver || elsewhere.length === 0) {
188      world.witches.splice(1, 1)
189    } else if (!isAlive(visitor.home) || visitor.home === world.witches[0]?.home) {
190      visitor.home = (pick(elsewhere, rand) ?? elsewhere[0])?.id ?? visitor.home
191      visitor.since = now
192    }
193  }
194
195  // The follower goes where the person is; if the visitor lives there, they
196  // trade consoles.
197  const lead = world.witches[0]
198  if (lead && lead.home !== active.id) {
199    const guest = world.witches[1]
200    if (guest && guest.home === active.id) {
201      guest.home = lead.home
202      guest.since = now
203    }
204    lead.home = active.id
205    lead.since = now
206  }
207
208  if (world.witches.length === 1 && living.length >= 2 && rand() < SUMMON_CHANCE) summon()
209  else if (world.witches.length === 2 && rand() < FIGHT_CHANCE) startFight()
210
211  return world
212}
213
hooks/sprites.ts 241 lines
1// The witch's pixels. Two pixels stack in each terminal cell (upper and lower
2// half blocks); the sprite sits centered in a REGION_W x REGION_H cell region
3// with room around her for sparkles, bubbles and grumbles. Each skin is its
4// own drawing (styles.ts), not a recolor.
5
6import { STYLES } from './styles'
7import type { Eyes, LegFrame } from './styles'
8import type { Skin } from '../types'
9
10export type { Eyes } from './styles'
11
12export const REGION_W = 24
13export const REGION_H = 13
14const HEIGHT_PX = REGION_H * 2
15// Glyph places are written for a 16-wide sprite at column 2 of a 20 x 12
16// region; they move with the sprite's place in the bigger region.
17const GLYPH_ROW_SHIFT = REGION_H - 12
18
19export type Wand = 'hold' | 'raise' | 'none'
20export type Legs = LegFrame | 'sit'
21
22export type Glyph = { col: number; row: number; text: string; color: string }
23
24export type Scene = {
25  skin: Skin
26  facing: 1 | -1
27  legs: Legs
28  eyes: Eyes
29  wand: Wand
30  lift: number
31  glyphs: Glyph[]
32}
33
34export type Cell = { ch: string; fg: string; bg?: string }
35
36export function spriteRows(scene: Pick<Scene, 'legs' | 'eyes' | 'wand'> & { skin?: Skin }): string[] {
37  const art = STYLES[scene.skin ?? 'classic']
38  const legs = scene.legs === 'sit' ? [] : art.legs[scene.legs]
39  const rows = [...art.body, ...legs].map(row => row.split(''))
40  const wand = scene.wand === 'raise' ? art.raise : scene.wand === 'none' ? art.none : []
41  for (const [x, y, ch] of [...art.eyes[scene.eyes], ...wand]) {
42    const row = rows[y]
43    if (row !== undefined && x >= 0 && x < row.length) row[x] = ch
44  }
45
46  return rows.map(row => row.join(''))
47}
48
49export function spriteLeft(skin: Skin): number {
50  return Math.floor((REGION_W - STYLES[skin].width) / 2)
51}
52
53// The region's cells, row by row; undefined where nothing is drawn.
54export function drawScene(scene: Scene): (Cell | undefined)[][] {
55  const art = STYLES[scene.skin]
56  const width = art.width
57  const left = spriteLeft(scene.skin)
58  const pixels: (string | undefined)[] = new Array(REGION_W * HEIGHT_PX)
59  const rows = spriteRows(scene)
60  const top = HEIGHT_PX - rows.length - scene.lift
61  rows.forEach((line, y) => {
62    for (let c = 0; c < width; c++) {
63      const color = art.colors[line[c] ?? '.']
64      if (color === undefined) continue
65      const px = left + (scene.facing === 1 ? c : width - 1 - c)
66      const py = top + y
67      if (py < 0 || py >= HEIGHT_PX) continue
68      pixels[py * REGION_W + px] = color
69    }
70  })
71
72  const cells = toCells(pixels, REGION_W, REGION_H)
73  const colShift = left - 2 + Math.max(0, width - 16)
74  for (const glyph of scene.glyphs) {
75    const col = glyph.col + (glyph.col >= 10 ? colShift : left - 2)
76    const start = scene.facing === 1 ? col : REGION_W - col - glyph.text.length
77    const line = cells[glyph.row + GLYPH_ROW_SHIFT]
78    if (line === undefined) continue
79    ;[...glyph.text].forEach((ch, i) => {
80      const at = start + i
81      if (at >= 0 && at < REGION_W) line[at] = { ch, fg: glyph.color }
82    })
83  }
84
85  return cells
86}
87
88// Pixels (width x rows*2, row-major) to half-block cells.
89function toCells(pixels: (string | undefined)[], width: number, rows: number): (Cell | undefined)[][] {
90  const cells: (Cell | undefined)[][] = []
91  for (let row = 0; row < rows; row++) {
92    const line: (Cell | undefined)[] = []
93    for (let col = 0; col < width; col++) {
94      const upper = pixels[row * 2 * width + col]
95      const lower = pixels[(row * 2 + 1) * width + col]
96      if (upper === undefined && lower === undefined) line.push(undefined)
97      else if (lower === undefined) line.push({ ch: '▀', fg: upper ?? '' })
98      else if (upper === undefined) line.push({ ch: '▄', fg: lower })
99      else if (upper === lower) line.push({ ch: '█', fg: upper })
100      else line.push({ ch: '▀', fg: upper, bg: lower })
101    }
102    cells.push(line)
103  }
104
105  return cells
106}
107
108// The hand that comes for a witch who started a fight: a long sleeve from
109// the top of its region down to a glove, open while it reaches, a fist once
110// it has her.
111export const HAND_W = 12
112const HAND_COLORS: Record<string, string> = {
113  K: '#2b1b3d', W: '#fbf7ff', g: '#c9c0dd', C: '#ffc94d', s: '#6b3fa0', l: '#8a5cd6',
114}
115const SLEEVE = '..KsslssK...'
116const GLOVE_OPEN = [
117  '.KCCCCCCCK..',
118  '.KWWWWWWWK..',
119  'KWWWWWWWWWK.',
120  'KWgWWgWWgWK.',
121  'KWWKWWKWWKWK',
122  'KWWKWWKWWKWK',
123  '.KK.KK.KK.K.',
124]
125const GLOVE_FIST = [
126  '.KCCCCCCCK..',
127  '.KWWWWWWWK..',
128  'KWWWWWWWWWK.',
129  'KWgWWgWWgWK.',
130  'KWWWWWWWWWWK',
131  '.KWgWgWgWWK.',
132  '..KKKKKKKK..',
133]
134
135export function drawHand(rows: number, isClosed: boolean): (Cell | undefined)[][] {
136  const glove = isClosed ? GLOVE_FIST : GLOVE_OPEN
137  const height = rows * 2
138  const sleeve = Array.from({ length: Math.max(0, height - glove.length) }, () => SLEEVE)
139  const lines = [...sleeve, ...glove].slice(-height)
140  const pixels: (string | undefined)[] = new Array(HAND_W * height)
141  lines.forEach((line, y) => {
142    for (let x = 0; x < HAND_W; x++) {
143      const color = HAND_COLORS[line[x] ?? '.']
144      if (color !== undefined) pixels[y * HAND_W + x] = color
145    }
146  })
147
148  return toCells(pixels, HAND_W, rows)
149}
150
151// The wreckage an attack leaves on a console: torn rows of glitch glyphs
152// that thicken with `level` and shift every frame.
153const GLITCH = [...'█▓▒░#%&@$*!?<>/\\|=+~^']
154const GLITCH_FG = ['#ff3b6b', '#ff7ad9', '#7af6ff', '#b388ff', '#ffe14d', '#ffffff']
155const GLITCH_BG = ['#1a0010', '#12001f', '#000000', '#2b0030', '#001a1f']
156
157function hash(a: number, b: number, c: number, d: number): number {
158  let h = (a * 374761393 + b * 668265263 + c * 2147483647 + d * 1274126177) | 0
159  h = Math.imul(h ^ (h >>> 13), 1274126177)
160  h ^= h >>> 16
161
162  return (h >>> 0) / 4294967296
163}
164
165// At most this many streaks are drawn: each is one run of Text, and a
166// surface module's tree must stay under 100,000 characters serialized.
167export const CHAOS_MAX_STREAKS = 360
168
169export function drawChaos(
170  width: number,
171  rows: number,
172  level: number,
173  seed: number,
174  frame: number,
175): (Cell | undefined)[][] {
176  const cells: (Cell | undefined)[][] = Array.from({ length: rows }, () => new Array(width).fill(undefined))
177  if (level <= 0) return cells
178  const beat = Math.floor(frame / 2)
179  const pickFrom = (list: string[], roll: number) => list[Math.floor(roll * list.length)] ?? '#000000'
180  // Streaks per row grow with the level; the budget is shared across rows.
181  const perRow = Math.max(1, Math.min(Math.floor(CHAOS_MAX_STREAKS / Math.max(1, rows)), Math.ceil(level * 6)))
182  for (let row = 0; row < rows; row++) {
183    const line = cells[row] ?? []
184    // Some rows tear whole once the attack is in full swing: one streak.
185    const isTorn = hash(seed, row, beat, 7) < level * level * 0.3
186    const streaks = isTorn ? 1 : perRow
187    for (let i = 0; i < streaks; i++) {
188      if (!isTorn && hash(seed, row, i, beat + 1) > level * 0.9) continue
189      const length = isTorn ? width : 2 + Math.floor(hash(seed, row, i, beat + 2) * (4 + level * 18))
190      const start = isTorn ? 0 : Math.floor(hash(seed, row, i, beat + 3) * Math.max(1, width - length))
191      const fg = pickFrom(GLITCH_FG, hash(seed, row, i, beat + 5))
192      const bg = pickFrom(GLITCH_BG, hash(seed, row, i, beat + 9))
193      for (let col = start; col < Math.min(width, start + length); col++) {
194        line[col] = { ch: pickFrom(GLITCH, hash(seed, col, row, beat + 11)), fg, bg }
195      }
196    }
197  }
198
199  return cells
200}
201
202// One run per stretch of same-colored cells, for drawing as Text.
203export type Run = { row: number; col: number; text: string; fg: string; bg?: string }
204
205export function toRuns(cells: (Cell | undefined)[][]): Run[] {
206  const runs: Run[] = []
207  cells.forEach((line, row) => {
208    let run: Run | undefined
209    line.forEach((cell, col) => {
210      if (cell === undefined) {
211        run = undefined
212        return
213      }
214      if (run && run.fg === cell.fg && run.bg === cell.bg) {
215        run.text += cell.ch
216        return
217      }
218      run = { row, col, text: cell.ch, fg: cell.fg, bg: cell.bg }
219      runs.push(run)
220    })
221  })
222
223  return runs
224}
225
226// Runs up to a serialized-size budget: each drawn as a positioned Box around
227// a Text, so it costs its own JSON plus the wrapping elements.
228export const RUN_OVERHEAD = 220
229
230export function capRuns(runs: Run[], budget = 60_000): Run[] {
231  const kept: Run[] = []
232  let size = 0
233  for (const run of runs) {
234    size += JSON.stringify(run).length + RUN_OVERHEAD
235    if (size > budget) break
236    kept.push(run)
237  }
238
239  return kept
240}
241
hooks/chaos-client.tsx 38 lines
1import type { ClientModule } from 'claude-code'
2
3import { capRuns, drawChaos, toRuns } from './sprites'
4
5export type ChaosProps = { columns: number; rows: number; level: number; seed: number }
6
7type Local = { frame: number }
8
9// The wreckage over a console whose witch was attacked: it flickers on its
10// own clock; the plugin only says how bad it is.
11const Chaos: ClientModule<ChaosProps, Local> = (props, surface) => {
12  const { Box, Text } = surface.elements
13  const local = surface.state ?? { frame: 0 }
14  if (surface.state === undefined) {
15    surface.setState(local)
16    surface.every(120, () => surface.setState({ frame: (surface.state?.frame ?? 0) + 1 }))
17  }
18  const runs = capRuns(toRuns(drawChaos(props.columns, props.rows, props.level, props.seed, local.frame)))
19
20  return (
21    <Box width={props.columns} height={props.rows}>
22      {runs.map(run => (
23        <Box position="absolute" top={run.row} left={run.col}>
24          {run.bg === undefined ? (
25            <Text color={run.fg}>{run.text}</Text>
26          ) : (
27            <Text color={run.fg} backgroundColor={run.bg}>
28              {run.text}
29            </Text>
30          )}
31        </Box>
32      ))}
33    </Box>
34  )
35}
36
37export default Chaos
38
hooks/hand-client.tsx 29 lines
1import type { ClientModule } from 'claude-code'
2
3import { HAND_W, drawHand, toRuns } from './sprites'
4
5export type HandProps = { rows: number; isClosed: boolean }
6
7const Hand: ClientModule<HandProps> = (props, surface) => {
8  const { Box, Text } = surface.elements
9  const runs = toRuns(drawHand(props.rows, props.isClosed))
10
11  return (
12    <Box width={HAND_W} height={props.rows}>
13      {runs.map(run => (
14        <Box position="absolute" top={run.row} left={run.col}>
15          {run.bg === undefined ? (
16            <Text color={run.fg}>{run.text}</Text>
17          ) : (
18            <Text color={run.fg} backgroundColor={run.bg}>
19              {run.text}
20            </Text>
21          )}
22        </Box>
23      ))}
24    </Box>
25  )
26}
27
28export default Hand
29
hooks/witch-client.tsx 177 lines
1import type { ClientModule } from 'claude-code'
2
3import { REGION_H, REGION_W, drawScene, toRuns } from './sprites'
4import type { Glyph, Scene } from './sprites'
5import type { WitchPose as WitchProps } from '../types'
6
7type Local = { frame: number; grab?: { x: number; y: number } }
8
9const STAR = '#fff27a'
10const PINK = '#ff8fa3'
11const MINT = '#8ff0c8'
12const SKY = '#9fd0ff'
13const LILAC = '#c9b3ff'
14const RED = '#ff4d6d'
15const RAINBOW = [STAR, PINK, MINT, SKY, LILAC]
16const GRUMBLES = ['hmph!', 'hey!!', 'put me down!', 'rude...']
17
18export function sceneFor(props: WitchProps, frame: number, isHeld: boolean): Scene {
19  const facing = props.facing
20  const blink = frame % 32 < 2 ? 'blink' : 'open'
21  const beat = Math.floor(frame / 3)
22  const base: Scene = { skin: props.skin ?? 'classic', facing, legs: 'stand', eyes: blink, wand: 'hold', lift: 0, glyphs: [] }
23  const mood = isHeld ? 'grabbed' : props.mood
24
25  switch (mood) {
26    case 'wander': {
27      const legs = props.isWalking ? (beat % 2 === 0 ? 'walk1' : 'walk2') : 'stand'
28      const twinkle: Glyph[] = frame % 24 < 3 ? [{ col: 18, row: 5, text: '·', color: STAR }] : []
29      return { ...base, legs, lift: props.isWalking && beat % 2 === 1 ? 1 : 0, glyphs: twinkle }
30    }
31    case 'think': {
32      const bubble: Glyph[] = [
33        { col: 15, row: 3, text: '.', color: LILAC },
34        { col: 16, row: 2, text: 'o', color: LILAC },
35        { col: 17, row: 1, text: 'O', color: LILAC },
36        { col: 18, row: 0, text: '?', color: SKY },
37      ]
38      return { ...base, glyphs: bubble.slice(0, (beat % 5) + 1) }
39    }
40    case 'cast': {
41      const spots: [number, number][] = [[18, 3], [16, 2], [19, 5], [17, 1], [19, 2]]
42      const glyphs = spots.map(([col, row], i) => ({
43        col,
44        row,
45        text: (i + beat) % 2 === 0 ? '*' : '+',
46        color: RAINBOW[(i + beat) % RAINBOW.length] ?? STAR,
47      }))
48      return { ...base, wand: 'raise', glyphs: glyphs.slice(0, 2 + (beat % 4)) }
49    }
50    case 'cheer': {
51      const spots: [number, number][] = [[0, 2], [1, 5], [18, 1], [19, 4], [17, 0], [0, 7]]
52      const glyphs = spots.map(([col, row], i) => ({
53        col,
54        row: (row + beat) % 8,
55        text: i % 2 === 0 ? '*' : '+',
56        color: RAINBOW[(i + beat) % RAINBOW.length] ?? STAR,
57      }))
58      return { ...base, eyes: 'happy', wand: 'raise', lift: [0, 1, 2, 1][frame % 4] ?? 0, glyphs }
59    }
60    case 'oops': {
61      const glyphs: Glyph[] = [{ col: 3, row: 4, text: '•', color: SKY }]
62      if (beat % 2 === 0) glyphs.push({ col: 15, row: 0, text: '!', color: RED })
63      return { ...base, eyes: 'blink', glyphs }
64    }
65    case 'sleep': {
66      const zs: Glyph[] = [
67        { col: 14, row: 4, text: 'z', color: SKY },
68        { col: 15, row: 3, text: 'z', color: SKY },
69        { col: 16, row: 2, text: 'Z', color: LILAC },
70      ]
71      return { ...base, legs: 'sit', eyes: 'blink', wand: 'none', glyphs: zs.slice(0, (Math.floor(frame / 5) % 4)) }
72    }
73    case 'grabbed': {
74      const grumble = GRUMBLES[Math.floor(frame / 14) % GRUMBLES.length] ?? 'hmph!'
75      const glyphs: Glyph[] = [{ col: Math.min(14, REGION_W - grumble.length), row: 0, text: grumble, color: PINK }]
76      if (frame % 4 < 2) glyphs.push({ col: 15, row: 2, text: '╬', color: RED })
77      return {
78        ...base,
79        legs: frame % 2 === 0 ? 'kick1' : 'kick2',
80        eyes: 'angry',
81        wand: 'none',
82        lift: frame % 2,
83        glyphs,
84      }
85    }
86    case 'huff': {
87      const glyphs: Glyph[] = [{ col: 14, row: 0, text: 'hmph.', color: PINK }]
88      if (beat % 3 === 0) glyphs.push({ col: 15, row: 2, text: '╬', color: RED })
89      return { ...base, eyes: 'angry', glyphs }
90    }
91    // Leaping off to another console.
92    case 'jump': {
93      const trail: Glyph[] = [{ col: 3, row: 10, text: '˚', color: STAR }, { col: 15, row: 11, text: '·', color: SKY }]
94      return { ...base, legs: 'kick2', eyes: 'happy', wand: 'raise', lift: 2, glyphs: trail }
95    }
96    // Dropping in from above.
97    case 'fall': {
98      const whoosh: Glyph[] = [{ col: 5, row: 0, text: '!', color: STAR }]
99      return { ...base, legs: frame % 2 === 0 ? 'kick1' : 'kick2', eyes: 'open', wand: 'raise', glyphs: whoosh }
100    }
101    // Winding up a spell at the other witch's console.
102    case 'attack': {
103      const bolt = ['>', '>>', '>>>', '>>>>'][beat % 4] ?? '>'
104      const glyphs: Glyph[] = [
105        { col: 16, row: 4, text: bolt, color: RED },
106        { col: 15, row: 2, text: '╬', color: RED },
107        { col: 18, row: 6, text: '*', color: RAINBOW[beat % RAINBOW.length] ?? STAR },
108      ]
109      return { ...base, eyes: 'angry', wand: 'raise', lift: frame % 2, glyphs }
110    }
111    // Her console is being wrecked.
112    case 'scared': {
113      const glyphs: Glyph[] = [
114        { col: 3, row: 4, text: '•', color: SKY },
115        { col: 14, row: 0, text: frame % 6 < 3 ? 'eek!' : 'EEK!', color: PINK },
116      ]
117      return { ...base, eyes: 'blink', wand: 'none', lift: frame % 3 === 0 ? 1 : 0, glyphs }
118    }
119  }
120}
121
122const Witch: ClientModule<WitchProps, Local> = (props, surface) => {
123  const { Box, Text } = surface.elements
124  const local = surface.state ?? { frame: 0 }
125  if (surface.state === undefined) {
126    surface.setState(local)
127    surface.every(150, () => {
128      const now = surface.state ?? { frame: 0 }
129      surface.setState({ ...now, frame: now.frame + 1 })
130    })
131  }
132
133  surface.onPointer(event => {
134    const now = surface.state ?? { frame: 0 }
135    // A right click sends her away from this console.
136    if (event.type === 'down' && event.button === 'right') {
137      surface.post({ type: 'hide' })
138      return
139    }
140    if (event.type === 'down' && event.button === 'left') {
141      surface.setState({ ...now, grab: { x: event.x, y: event.y } })
142      surface.post({ type: 'grab' })
143      return
144    }
145    if (now.grab === undefined) return
146    const x = props.x + event.x - now.grab.x
147    const up = props.up - (event.y - now.grab.y)
148    if (event.type === 'move') {
149      surface.post({ type: 'drag', x, up })
150    } else if (event.type === 'up') {
151      surface.setState({ frame: now.frame })
152      surface.post({ type: 'drop', x, up })
153    }
154  })
155
156  const scene = sceneFor(props, local.frame, local.grab !== undefined)
157  const runs = toRuns(drawScene(scene))
158
159  return (
160    <Box width={REGION_W} height={REGION_H}>
161      {runs.map(run => (
162        <Box position="absolute" top={run.row} left={run.col}>
163          {run.bg === undefined ? (
164            <Text color={run.fg}>{run.text}</Text>
165          ) : (
166            <Text color={run.fg} backgroundColor={run.bg}>
167              {run.text}
168            </Text>
169          )}
170        </Box>
171      ))}
172    </Box>
173  )
174}
175
176export default Witch
177
hooks/styles.ts 231 lines
1// Generated from the witch style designs: one drawing per style, facing
2// right. Patches are [x, y, letter] over body + legs rows.
3
4import type { Skin } from '../types'
5
6export type Patch = [x: number, y: number, ch: string]
7export type LegFrame = 'stand' | 'walk1' | 'walk2' | 'kick1' | 'kick2'
8export type Eyes = 'open' | 'blink' | 'happy' | 'angry'
9
10export type StyleArt = {
11  width: number
12  colors: Record<string, string>
13  body: string[]
14  legs: Record<LegFrame, string[]>
15  eyes: Record<Eyes, Patch[]>
16  raise: Patch[]
17  none: Patch[]
18}
19
20export const STYLES: Record<Skin, StyleArt> = {
21  // Round chibi: pointy purple hat, pink hair, star wand
22  classic: {
23    width: 16,
24    colors: {"K": "#2b1b3d", "H": "#5b2f99", "h": "#8a5cd6", "d": "#3f1f73", "B": "#ffc94d", "b": "#d99a2b", "*": "#fff6a0", "A": "#ff9fcf", "a": "#e06aa8", "S": "#ffe0c7", "W": "#ffffff", "E": "#3a2a6a", "P": "#ff9bb8", "Q": "#ff4d6d", "M": "#c4566e", "R": "#4a2c80", "r": "#6f48b0", "X": "#ff6f9a", "T": "#fff4fa", "F": "#3b2440", "V": "#a0703a", "Y": "#ffe46b"},
25    body: [
26      '..........KK....',
27      '.........KhK....',
28      '........KHHK....',
29      '.......KHhHK....',
30      '......KHHhHHK...',
31      '.....KHHhHHHK...',
32      '....KdBBB*BBdK..',
33      'KKKdHHHHHHHHHdKK',
34      '.KaAAAAAAAAAAaK.',
35      '.KAAaSSSSSaAAAK.',
36      '.KAaSWESSSWESaK.',
37      '.KAaSEESSSEESaK.',
38      '.KAAPSSSMSSSPAK.',
39      '..KASSSSSSSSAK.Y',
40      '...KRRRXXRRRK.V.',
41      '..KRrRRRRRRRRSK.',
42      '.KRRrRRRRRRRRRK.',
43      '.KRRRRRRRRRRRRK.',
44      '..KBbBbBbBbBbK..',
45    ],
46    legs: {
47      stand: ["....KTK..KTK....", "....KFFK.KFFK..."],
48      walk1: ["...KTK....KTK...", "...KFFK...KFFK.."],
49      walk2: [".....KTKKTK.....", ".....KFFKFFK...."],
50      kick1: ["....KTK...KTK...", "...KFFK....KFFK."],
51      kick2: [".....KTK.KTK....", ".....KFFKKFFK..."],
52    },
53    eyes: {
54      open: [],
55      blink: [[5, 10, "S"], [6, 10, "S"], [5, 11, "K"], [6, 11, "K"], [10, 10, "S"], [11, 10, "S"], [10, 11, "K"], [11, 11, "K"]],
56      happy: [[5, 10, "K"], [6, 10, "K"], [5, 11, "S"], [6, 11, "S"], [4, 11, "K"], [7, 11, "K"], [10, 10, "K"], [11, 10, "K"], [10, 11, "S"], [11, 11, "S"], [9, 11, "K"], [12, 11, "K"]],
57      angry: [[5, 10, "S"], [6, 10, "S"], [5, 11, "S"], [6, 11, "S"], [5, 10, "K"], [6, 11, "K"], [5, 11, "E"], [10, 10, "S"], [11, 10, "S"], [10, 11, "S"], [11, 11, "S"], [11, 10, "K"], [10, 11, "K"], [11, 11, "E"], [7, 12, "K"], [8, 12, "K"], [9, 12, "K"], [4, 12, "Q"], [12, 12, "Q"]],
58    },
59    raise: [[15, 13, "."], [14, 14, "."], [13, 15, "R"], [15, 8, "Y"], [15, 9, "V"], [15, 10, "V"], [15, 11, "V"], [15, 12, "S"]],
60    none: [[15, 13, "."], [14, 14, "."], [13, 15, "R"]],
61  },
62  // Huge floppy hat with glowing star, cloud of lavender hair, cyan glow
63  starlight: {
64    width: 20,
65    colors: {"K": "#141529", "H": "#262a52", "h": "#3a4383", "d": "#1a1d3b", "C": "#33d6e0", "c": "#1f8f99", "*": "#f08cff", "+": "#ffd6ff", "A": "#c9a2f2", "a": "#9a6fd0", "L": "#e3ccff", "S": "#fbe3d6", "W": "#ffffff", "E": "#3b2f6b", "P": "#ffb3c8", "Q": "#ff4d6d", "M": "#b85a7a", "R": "#1e2f45", "r": "#2b4560", "T": "#262a52", "F": "#33d6e0", "V": "#3a4383", "Y": "#7ff6ff"},
66    body: [
67      '..........KKK.......',
68      '........KKHHHKK.....',
69      '.......KHHhHHHHKK...',
70      '......KHHHhHHHHHHK..',
71      '.....KHHHH+HhHKKHHK.',
72      '.....KHHH***HHK..KHK',
73      '....KHHH**+**HHK..K.',
74      '....KHHHH***HHHK....',
75      '...KdHHHHH*HHHHdK...',
76      '.KKcCCCCCCCCCCCCcKK.',
77      'KHHHHHHHHHHHHHHHHHHK',
78      '.KCCCCCCCCCCCCCCCCK.',
79      '.KLAAaAAAAAAAAaAALK.',
80      'KAAAaASSSSSSSSaAAAAK',
81      'KALAaSSWESSWESSaAAAK',
82      'KAAASSSEESSEESSaALAK',
83      'KAaAaSSPSSMSPSSAAaAK',
84      'KAALAKSSSSSSSSKAALAK',
85      '.KAAKRrRRCCRRrRKAAK.',
86      '.KaKRRRRRCCRRRRRKaK.',
87      '..KKRRrRRRRRRrRRKK..',
88    ],
89    legs: {
90      stand: [".....KTK..KTK.......", ".....KFK..KFK......."],
91      walk1: ["....KTK....KTK......", "....KFK....KFK......"],
92      walk2: ["......KTKKTK........", "......KFKKFK........"],
93      kick1: ["....KTK.....KTK.....", "...KFK.......KFK...."],
94      kick2: ["......KTK.KTK.......", "......KFK.KFK......."],
95    },
96    eyes: {
97      open: [],
98      blink: [[7, 14, "S"], [8, 14, "S"], [7, 15, "K"], [8, 15, "K"], [11, 14, "S"], [12, 14, "S"], [11, 15, "K"], [12, 15, "K"]],
99      happy: [[7, 14, "K"], [8, 14, "K"], [7, 15, "S"], [8, 15, "S"], [6, 15, "K"], [9, 15, "K"], [11, 14, "K"], [12, 14, "K"], [11, 15, "S"], [12, 15, "S"], [10, 15, "K"], [13, 15, "K"]],
100      angry: [[7, 14, "S"], [8, 14, "S"], [7, 15, "S"], [8, 15, "S"], [7, 14, "K"], [8, 15, "K"], [7, 15, "E"], [11, 14, "S"], [12, 14, "S"], [11, 15, "S"], [12, 15, "S"], [12, 14, "K"], [11, 15, "K"], [12, 15, "E"], [9, 16, "K"], [10, 16, "K"], [11, 16, "K"], [7, 16, "Q"], [12, 16, "Q"]],
101    },
102    raise: [[18, 9, "Y"], [19, 9, "K"], [18, 10, "V"], [18, 11, "V"]],
103    none: [],
104  },
105  // Side-saddle on a broom, streaming magenta hair, long coat, cat on the broom
106  moonbroom: {
107    width: 22,
108    colors: {"K": "#1d1828", "H": "#4f4766", "h": "#6f6690", "d": "#3a3350", "B": "#ff4fd8", "G": "#ffc94d", "A": "#e85ad8", "a": "#a8329e", "L": "#ff9df0", "S": "#f6d9cc", "W": "#ffffff", "E": "#5a2a6a", "P": "#ff9bb8", "Q": "#ff4d6d", "M": "#c4566e", "R": "#4a4652", "r": "#6a6674", "g": "#a9a4b5", "V": "#8a5a2e", "v": "#b07a3e", "Z": "#c9a46a", "z": "#8f7442", "c": "#100c14", "y": "#ffe14d", "F": "#2a2232"},
109    body: [
110      '............KK........',
111      '...........KhHK.......',
112      '..........KHHHK.......',
113      '.........KHhHHHK......',
114      '........KHHhHHHHK..K..',
115      '......KKBBBBBBBBBK..G.',
116      '...KKKHHHHHHHHHHHHKKG.',
117      '....KKddddddddddKK....',
118      '...KAAAAAAAAAAAK......',
119      '..KAaAAAAAAaAAAK......',
120      '.KAaAAAAKSSSSSSK......',
121      'KAaAAAAAKWESWESK......',
122      'KaAAaAAAKEESEESK......',
123      'KAaLAaAAKPSMSPSK......',
124      '.KaAAaAAAKSSSSK.......',
125      '..KAaAAaKRRSSKK.......',
126      '...KAaAKRRRRRRK.c..c..',
127      '....KKKRRgRRRSSKcycy..',
128      'zZzZzKVVVVVVVVVVvcccvK',
129    ],
130    legs: {
131      stand: [".zZzZ.KRRRRRRK.SSK....", ".......KRRrRRK..SSK...", ".......KKKKKK...KFFK.."],
132      walk1: ["zZzZz.KRRRRRRK.SSK....", ".......KRRrRRK..SSK...", ".......KKKKKK...KFFK.."],
133      walk2: [".zZzZ.KRRRRRRK.SSK....", ".......KRRrRRK.SSK....", ".......KKKKKK..KFFK..."],
134      kick1: [".zZzZ.KRRRRRRK.SS.K...", ".......KRRrRRK...SSK..", ".......KKKKKK.....KFFK"],
135      kick2: [".zZzZ.KRRRRRRKSSK.....", ".......KRRrRRKSSK.....", ".......KKKKKKKFFK....."],
136    },
137    eyes: {
138      open: [],
139      blink: [[9, 11, "S"], [10, 11, "S"], [9, 12, "K"], [10, 12, "K"], [12, 11, "S"], [13, 11, "S"], [12, 12, "K"], [13, 12, "K"]],
140      happy: [[9, 11, "K"], [10, 11, "K"], [9, 12, "S"], [10, 12, "S"], [8, 12, "K"], [11, 12, "K"], [12, 11, "K"], [13, 11, "K"], [12, 12, "S"], [13, 12, "S"], [11, 12, "K"], [14, 12, "K"]],
141      angry: [[9, 11, "S"], [10, 11, "S"], [9, 12, "S"], [10, 12, "S"], [9, 11, "K"], [10, 12, "K"], [9, 12, "E"], [12, 11, "S"], [13, 11, "S"], [12, 12, "S"], [13, 12, "S"], [13, 11, "K"], [12, 12, "K"], [13, 12, "E"], [10, 13, "K"], [11, 13, "K"], [12, 13, "K"], [9, 13, "Q"], [13, 13, "Q"]],
142    },
143    raise: [],
144    none: [],
145  },
146  // Super-deformed: giant head and eyes, buckled hat, cat on shoulder, gem staff
147  bluebell: {
148    width: 20,
149    colors: {"K": "#1a2340", "H": "#2f4f9a", "h": "#4a72c8", "d": "#203a78", "G": "#c8d0e0", "g": "#8a93a8", "A": "#5a3f94", "a": "#3d2a6e", "S": "#ffe4d2", "W": "#ffffff", "E": "#1e2440", "I": "#4a5a8a", "P": "#ffb0b8", "Q": "#ff4d6d", "M": "#c46a6e", "R": "#2f4f9a", "r": "#5a82d8", "X": "#c8d0e0", "F": "#5a3a26", "T": "#1e2440", "V": "#6b4226", "v": "#8f5c36", "Y": "#3ee86b", "y": "#b6ffcb", "c": "#151018", "o": "#ffd23f"},
150    body: [
151      '..........KKKK......',
152      '........KKhHHHK.....',
153      '.......KHHhHHHHK....',
154      '......KHHhHHHHHHK...',
155      '......KHHHHHHKKHHK..',
156      '.....KddGGGddK..KHK.',
157      '....KHHGddGHHHK..KK.',
158      '..KKHHHHHHHHHHHKK...',
159      '.KHHHHHHHHHHHHHHHK..',
160      'yKKAAAAAAAAAAAAKK...',
161      'YKAAAaAAAAaAAAAAK...',
162      'YKAaSSSSSSSSSaAAK...',
163      'VKASSKKSSSSKKSaAK...',
164      'vKASSEWSSSSEWSSAK...',
165      'VKASSEISSSSEISSAKc.c',
166      'vKAPSEESSMSEESPAKccc',
167      'VKKASSSSSSSSSSAKoco.',
168      'v.KKKRRXXXRRRKKccc..',
169      'V..KRRrRRRRRrRRKKc..',
170      'v..KRRrRRRRRrRRK....',
171    ],
172    legs: {
173      stand: ["V...KKKKKKKKKK......", "v....KFK..KFK......."],
174      walk1: ["V...KKKKKKKKKK......", "v...KFK....KFK......"],
175      walk2: ["V...KKKKKKKKKK......", "v......KFKFK........"],
176      kick1: ["....KKKKKKKKKK......", "...KFK......KFK....."],
177      kick2: ["....KKKKKKKKKK......", ".....KFK..KFK......."],
178    },
179    eyes: {
180      open: [],
181      blink: [[5, 12, "S"], [6, 12, "S"], [5, 13, "S"], [6, 13, "S"], [5, 14, "S"], [6, 14, "S"], [5, 15, "K"], [6, 15, "K"], [11, 12, "S"], [12, 12, "S"], [11, 13, "S"], [12, 13, "S"], [11, 14, "S"], [12, 14, "S"], [11, 15, "K"], [12, 15, "K"]],
182      happy: [[5, 12, "K"], [6, 12, "K"], [5, 13, "S"], [6, 13, "S"], [5, 14, "S"], [6, 14, "S"], [5, 15, "S"], [6, 15, "S"], [4, 13, "K"], [7, 13, "K"], [11, 12, "K"], [12, 12, "K"], [11, 13, "S"], [12, 13, "S"], [11, 14, "S"], [12, 14, "S"], [11, 15, "S"], [12, 15, "S"], [10, 13, "K"], [13, 13, "K"]],
183      angry: [[5, 12, "S"], [6, 12, "S"], [5, 13, "S"], [6, 13, "S"], [5, 14, "S"], [6, 14, "S"], [5, 15, "S"], [6, 15, "S"], [5, 12, "K"], [6, 13, "K"], [5, 15, "E"], [11, 12, "S"], [12, 12, "S"], [11, 13, "S"], [12, 13, "S"], [11, 14, "S"], [12, 14, "S"], [11, 15, "S"], [12, 15, "S"], [12, 12, "K"], [11, 13, "K"], [12, 15, "E"], [8, 15, "K"], [9, 15, "K"], [10, 15, "K"], [3, 15, "Q"], [14, 15, "Q"]],
184    },
185    raise: [[0, 9, "."], [0, 10, "."], [0, 11, "."], [0, 12, "."], [0, 13, "."], [0, 14, "."], [0, 15, "."], [0, 16, "."], [0, 17, "."], [0, 18, "."], [0, 19, "."], [1, 2, "y"], [1, 3, "Y"], [1, 4, "Y"], [1, 5, "V"], [1, 6, "v"], [1, 7, "V"], [1, 8, "v"], [2, 9, "S"]],
186    none: [[0, 9, "."], [0, 10, "."], [0, 11, "."], [0, 12, "."], [0, 13, "."], [0, 14, "."], [0, 15, "."], [0, 16, "."], [0, 17, "."], [0, 18, "."], [0, 19, "."]],
187  },
188  // Tall and slender, long golden hair, fitted dress, tall upright broom
189  goldie: {
190    width: 16,
191    colors: {"K": "#141826", "H": "#22405e", "h": "#356086", "d": "#172c44", "B": "#101420", "A": "#f2c46b", "a": "#c08a36", "L": "#ffe3a1", "S": "#f8dcc6", "s": "#e8b9a0", "W": "#ffffff", "E": "#3a6aa8", "P": "#f2a0a0", "Q": "#ff4d6d", "M": "#b8606a", "R": "#1f2442", "r": "#343c6c", "X": "#8a2a3a", "T": "#2a2438", "F": "#101018", "V": "#2a1c14", "Z": "#b07a3e", "z": "#7a5228"},
192    body: [
193      '.............zZz',
194      '.......KK....ZzZ',
195      '......KHHK...zZz',
196      '.....KHhHK...ZzZ',
197      '....KHHhHHK...V.',
198      '..KKKBBBBBKKK.V.',
199      'KKHHHHHHHHHHHKV.',
200      '...KAAAAAAAK..V.',
201      '...KALSSSAAK..V.',
202      '..KAASESESAaK.V.',
203      '..KAASSMSSAaK.V.',
204      '..KAAKSSSKAAK.V.',
205      '..KAaKRXRKAaK.V.',
206      '..KAAKRRRRKAKSV.',
207      '..KAaRrRRRRKSK..',
208      '...KAKRRRRRKK.V.',
209      '...KaKXXXXXK..V.',
210      '....KRRrRRRK..V.',
211      '...KRRrRRRRRK.V.',
212      '...KRRRRRRRRK.V.',
213    ],
214    legs: {
215      stand: [".....KsK.KsK..V.", ".....KTK.KTK..V.", ".....KTK.KTK..V.", "....KFFK.KFFK.V."],
216      walk1: ["....KsK...KsK.V.", "....KTK...KTK.V.", "...KTK.....KTKV.", "...KFFK....KFFK."],
217      walk2: [".....KsKKsK...V.", ".....KTKKTK...V.", "......KTTK....V.", ".....KFFFFK...V."],
218      kick1: ["....KsK...KsK...", "...KTK.....KTK..", "..KTK.......KTK.", "..KFFK......KFFK"],
219      kick2: [".....KsK.KsK....", ".....KTK..KTK...", "......KTK.KTK...", "......KFFKKFFK.."],
220    },
221    eyes: {
222      open: [],
223      blink: [[6, 9, "K"], [8, 9, "K"]],
224      happy: [[6, 9, "K"], [8, 9, "K"]],
225      angry: [[6, 9, "S"], [6, 9, "K"], [8, 9, "S"], [8, 9, "K"], [7, 10, "K"]],
226    },
227    raise: [],
228    none: [[13, 0, "."], [14, 0, "."], [15, 0, "."], [13, 1, "."], [14, 1, "."], [15, 1, "."], [13, 2, "."], [14, 2, "."], [15, 2, "."], [13, 3, "."], [14, 3, "."], [15, 3, "."], [14, 4, "."], [14, 5, "."], [14, 6, "."], [14, 7, "."], [14, 8, "."], [14, 9, "."], [14, 10, "."], [14, 11, "."], [14, 12, "."], [14, 13, "."], [14, 14, "."], [14, 15, "."], [14, 16, "."], [14, 17, "."], [14, 18, "."], [14, 19, "."], [14, 20, "."], [14, 21, "."], [14, 22, "."], [14, 23, "."], [13, 13, "K"]],
229  },
230}
231
types/index.d.ts 40 lines
1export type Mood =
2  | 'wander'
3  | 'think'
4  | 'cast'
5  | 'cheer'
6  | 'oops'
7  | 'sleep'
8  | 'grabbed'
9  | 'huff'
10  | 'jump'
11  | 'fall'
12  | 'attack'
13  | 'scared'
14
15export type Skin = 'classic' | 'starlight' | 'moonbroom' | 'bluebell' | 'goldie'
16
17// Where she is: `x` columns from the left, `up` rows from the transcript's
18// floor to her feet. `hand` is the hand that comes for her after a fight:
19// `drop` rows still to come down, `isClosed` once it has her.
20export type WitchPose = {
21  x: number
22  up: number
23  facing: 1 | -1
24  mood: Mood
25  isWalking: boolean
26  skin: Skin
27  isShown: boolean
28  hand?: { drop: number; isClosed: boolean }
29}
30
31// How wrecked this console's screen is after an attack (0 to 1), and the
32// seed its wreckage is drawn from.
33export type Chaos = { level: number; seed: number }
34
35declare module 'claude-code' {
36  interface PluginState {
37    'pixel-witch': { isHidden: boolean; pose: WitchPose; anchor: string | null; chaos: Chaos }
38  }
39}
40