SLOPSHOPPER

cc-pokedex

Catch wild creatures above the Claude Code prompt: where your prompt leads (water, grass, fire, bugs, plans, builds) decides what appears. Pick a partner that…

newbandspinnerguardcommandtoast
★ 1v0.1.0MITupdated 2026-09-14deonmenezes/claude-mods-pokedex
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cc-pokedex
› 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 › /dex ⎿ cc-pokedex: dex 0/77 · no partner yet · catch one and it joins you ⎿ cc-pokedex: nothing wild right now · prompt about water, grass, fire, bugs, plans, builds... ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

I built a Pikachu game inside Claude Code using Claude Mods

cc-pokedex: wild creatures above the Claude Code prompt. Where your prompt leads decides what shows up.

Luring a wild Pikachu with a prompt about batteries, catching it, and it taking over the spinner and turn footer

Prompt about the sea and something from the water may appear. Ask Claude to fix a crash and a bug type may crawl out. Ask it to think through an architecture and a psychic type drifts in. Throw balls to catch it, right above the prompt, while Claude keeps working. Nothing here asks the model anything: encounters, throws and the dex cost zero tokens.

Catch one and it becomes your partner. It draws under the logo at startup, its name takes over the spinner (Pikachu is sauteing... (12s)) and the turn footer (Pikachu baked for 3s), and it sits above the prompt with its level. It gains xp when Claude finishes a turn, edits a file, passes tests or commits, and it evolves at the levels you would expect.

Built on Claude Code function hooks ("Claude Mods"), early access: TypeScript that runs inside Claude Code's own process. The API can change between releases.

Requirements

  • Claude Code 2.1.269 or later with CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1.
  • An interactive terminal session. Nothing draws in claude -p, the desktop app or mobile.
  • A terminal that reports the mouse, for clicking the board and the buttons.

Quick start

  1. Turn function hooks on, in ~/.claude/settings.json:
   { "env": { "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1" } }
  1. Load it for one session from this folder:
   git clone https://github.com/deonmenezes/claude-mods-pokedex
   claude --plugin-dir ./claude-mods-pokedex

Or install it as a marketplace plugin (the repo is its own marketplace):

   claude plugin marketplace add deonmenezes/claude-mods-pokedex
   claude plugin install cc-pokedex@cc-pokedex
  1. Prompt normally. When a wild one appears you get a toast and a board above the prompt. Click the board, or the throw ball button, or run /dex throw. Four balls per encounter.
  1. /dex wild water forces an encounter so you can try it straight away. /dex wild pikachu forces a species.

Commands

CommandWhat it does
/dexDex count, partner and level, what is wild right now
/dex listEverything caught, with rarity stars and counts
/dex partner <name>Make a caught species your partner
/dex throwThrow a ball (or click the board, or press Space after clicking it)
/dex runLeave the encounter
/dex lure <name>Set a lure: your next prompt that leads into that species' habitat brings it out
/dex show / /dex hideShow or hide the partner strip above the prompt
/dex wild [kind or name]Force an encounter, for trying it out
/dex releaseDrop the partner

Every form of /dex runs straight away, even while Claude is working.

Habitats

Words in your promptHabitatExamples
water, sea, river, fish, boat, rain, streamwaterSquirtle, Magikarp, Psyduck, Lapras
land, grass, forest, tree, garden, plantgrassBulbasaur, Oddish, Bellsprout, Tangela
fire, hot, burn, flame, lava, cookfireCharmander, Vulpix, Growlithe, Moltres
electric, power, battery, thunder, chargeelectricPikachu, Magnemite, Voltorb, Zapdos
rock, stone, mountain, cave, sand, digrockGeodude, Onix, Diglett, Sandshrew
ice, snow, cold, freeze, wintericeShellder, Jynx, Articuno
sky, fly, bird, wind, cloudflyingPidgey, Spearow, Zubat
bug, fix, crash, error, broken, failingbugCaterpie, Weedle, Scyther
delete, remove, kill, legacy, deprecatedghostGastly, Haunter, Gengar
think, plan, design, research, explainpsychicAbra, Drowzee, Mewtwo
build, compile, deploy, docker, ship, testfightingMachop, Machoke, Hitmonchan
anything elsethe roadRattata, Meowth, Eevee, Snorlax, Ditto

A named habitat meets something 40% of the time, the road 10%. Rarity weights the pick and the catch: common ★ 65%, uncommon ★★ 45%, rare ★★★ 28%, legendary ★★★★ 10% per ball. Mew only answers a prompt that says both please and thank you.

The partner

Level n needs 2·n² xp. A finished turn gives 8, an edit 1, a passing test run 10, a commit 15, a catch 20. Charmander becomes Charmeleon at 16 and Charizard at 36; the other chains follow the games. An evolution also fills the dex entry of the new form.

The partner and the dex live in the plugin's store, so every session shares them.

Why the logo itself does not change

Function hooks can redraw a fixed list of components: the user and assistant messages, tool rows, the spinner, the turn footer, the notice lines under the logo, the prompt hint, the band above the prompt and panes. The logo is not on that list yet, so the partner draws beside the first notice line under it instead. If a Logo render site lands, swapping the sprite in is a one-hook change.

Develop

bun test                                            # rules: habitats, picks, throws, levels, evolutions
claude plugin validate .claude-plugin/plugin.json   # lists hooked events, $ calls and surface modules
bunx -p typescript tsc -p .                         # types against .claude/types (from /plugin-types)

Two rules for board files: never name a local variable h, and write Client module paths as string literals.

A fan project. The creature names belong to Nintendo, Game Freak and Creatures Inc.; the sprites are original terminal art, and nothing here is affiliated with or endorsed by them.

Source 5 files
hooks/register.tsx 328 lines
1/* @jsx h */
2import type { EngineInterface, Register } from 'claude-code'
3import { HABITATS, KIND_COLOR, KIND_ICON, RARITY_STARS, SPECIES, byName, type Kind } from './dex/data.ts'
4import {
5  BALLS_PER_ENCOUNTER, dexSize, gainXp, habitatOf, isPolite, levelOf, pickWild, record,
6  rollEncounter, throwBall, totalSpecies, xpEventFor, xpForLevel, type Dex, type Partner, type XpEvent,
7} from './dex/logic.ts'
8
9// cc-pokedex: wild ones appear above the prompt depending on where your prompt leads (water words,
10// grass words, a bug to fix, a plan to think through...). Throw balls to catch them, keep a dex in
11// $.store, and pick a partner: it draws under the logo, beside the spinner, and above the prompt,
12// and levels up and evolves as Claude works. Nothing here asks the model anything.
13//
14// Two rules for the boards in ./boards: never name a local `h` (JSX compiles to calls of `h`), and
15// `Client` module paths must be string literals (the engine reads them off the source).
16
17type Phase = 'wild' | 'throwing' | 'caught' | 'fled'
18type Encounter = {
19  name: string
20  kind: Kind
21  where: string
22  ballsLeft: number
23  phase: Phase
24  /** bumped on every throw; the board animates when it sees a new one */
25  throwSeq: number
26  shakes: number
27  /** decided at the throw, revealed once the animation has played */
28  pending?: boolean
29}
30
31let dex: Dex = {}
32let partner: Partner | undefined
33let showPartner = true
34let encounter: Encounter | undefined
35let firstNotice: string | undefined
36// a lure: the next prompt that leads into this species' habitat brings it out
37let lure: string | undefined
38
39const isDex = (v: unknown): v is Dex => typeof v === 'object' && v !== null && !Array.isArray(v)
40const isPartner = (v: unknown): v is Partner => typeof v === 'object' && v !== null && typeof (v as Partner).xp === 'number' && typeof (v as Partner).name === 'string'
41
42const stars = (name: string) => RARITY_STARS[byName(name)?.rarity ?? 'common']
43const icon = (name: string) => KIND_ICON[byName(name)?.kind ?? 'normal']
44
45async function save($: EngineInterface, key: string, value: unknown) {
46  await $.store.set(key, value).catch(err => $.ui.log(`cc-pokedex: store write failed: ${err}`))
47}
48
49// one throw: decide now, animate on the board, reveal when the animation has played
50async function throwNow($: EngineInterface): Promise<string> {
51  if (!encounter || encounter.phase !== 'wild') return encounter?.phase === 'throwing' ? 'the ball is still in the air' : 'nothing to throw at · a wild one appears when you prompt'
52  const sp = byName(encounter.name)
53  if (!sp) return 'nothing to throw at'
54  const { caught, shakes } = throwBall(sp.rarity, Math.random)
55  encounter = { ...encounter, phase: 'throwing', throwSeq: encounter.throwSeq + 1, shakes, ballsLeft: encounter.ballsLeft - 1, pending: caught }
56  $.ui.invalidate('ui.render')
57  // the board's clock: four frames of flight, three per shake, at 200 ms, then a beat
58  const ms = 800 + shakes * 600 + 400
59  $.clock.after(ms, () => { void reveal($) })
60  return `threw a ball at ${sp.name}...`
61}
62
63async function reveal($: EngineInterface) {
64  if (!encounter || encounter.phase !== 'throwing') return
65  const name = encounter.name
66  if (encounter.pending) {
67    encounter = { ...encounter, phase: 'caught' }
68    const now = await $.clock.now()
69    const { dex: next, isNew } = record(dex, name, now)
70    dex = next
71    await save($, 'dex', dex)
72    $.ui.toast(`${icon(name)} Gotcha! ${name} was caught${isNew ? ` · new dex entry (${dexSize(dex)}/${totalSpecies})` : ''}`)
73    if (!partner) {
74      partner = { name, caughtAs: name, xp: 0, since: now }
75      await save($, 'partner', partner)
76      $.ui.toast(`${name} is now your partner · /dex partner <name> switches`)
77    } else {
78      await feedPartner($, 'catch')
79    }
80    $.ui.invalidate('ui.render')
81    $.clock.after(4000, () => { if (encounter?.phase === 'caught') { encounter = undefined; $.ui.invalidate('ui.render') } })
82  } else if (encounter.ballsLeft <= 0) {
83    encounter = { ...encounter, phase: 'fled' }
84    $.ui.toast(`${name} broke free and ran away`)
85    $.ui.invalidate('ui.render')
86    $.clock.after(3000, () => { if (encounter?.phase === 'fled') { encounter = undefined; $.ui.invalidate('ui.render') } })
87  } else {
88    encounter = { ...encounter, phase: 'wild' }
89    $.ui.invalidate('ui.render')
90  }
91}
92
93async function feedPartner($: EngineInterface, event: XpEvent) {
94  if (!partner) return
95  const { partner: fed, leveled, evolvedFrom } = gainXp(partner, event)
96  partner = fed
97  await save($, 'partner', partner)
98  if (evolvedFrom) {
99    $.ui.toast(`✨ What? ${evolvedFrom} is evolving... it became ${fed.name}!`)
100    const now = await $.clock.now()
101    const { dex: next } = record(dex, fed.name, now)
102    dex = next
103    await save($, 'dex', dex)
104  } else if (leveled) {
105    $.ui.toast(`${icon(fed.name)} ${fed.name} grew to level ${levelOf(fed.xp)}`)
106  }
107  if (leveled || evolvedFrom) $.ui.invalidate('ui.render')
108}
109
110export const register: Register = on => {
111  on('session.start', async ($, e, next) => {
112    const r = await next(e)
113    const stored = (key: string) => $.store.get(key).catch(err => { $.ui.log(`cc-pokedex: store read failed: ${err}`); return undefined })
114    const savedDex = await stored('dex')
115    if (isDex(savedDex)) dex = savedDex
116    const savedPartner = await stored('partner')
117    if (isPartner(savedPartner)) partner = savedPartner
118    showPartner = (await stored('show')) !== false
119    await $.command.register({
120      name: 'dex',
121      description: 'Pokedex: status, list, partner <name>, throw, run, lure <name>, show, hide (cc-pokedex)',
122      argumentHint: '[list | partner <name> | throw | run | lure <name> | show | hide]',
123      immediate: true,
124    }).catch(err => $.ui.log(`cc-pokedex: /dex not registered: ${err}`))
125    return r
126  })
127
128  // where the prompt leads decides what may show up; the prompt itself passes through untouched
129  on('prompt.submit', async ($, e, next) => {
130    try {
131      const text = e.text.trim()
132      if (e.origin.kind === 'composer' && !text.startsWith('/') && text.length > 0 && (!encounter || encounter.phase === 'fled' || encounter.phase === 'caught')) {
133        const { kind, where } = habitatOf(text)
134        const lured = lure ? byName(lure) : undefined
135        if (lured && lured.kind === kind) {
136          lure = undefined
137          encounter = { name: lured.name, kind: lured.kind, where, ballsLeft: BALLS_PER_ENCOUNTER, phase: 'wild', throwSeq: 0, shakes: 0 }
138          $.ui.toast(`${KIND_ICON[lured.kind]} The lure worked! A wild ${lured.name} appeared ${where}!`)
139          $.ui.invalidate('ui.render')
140        } else if (rollEncounter(kind, Math.random)) {
141          const sp = pickWild(kind, Math.random, isPolite(text))
142          encounter = { name: sp.name, kind: sp.kind, where, ballsLeft: BALLS_PER_ENCOUNTER, phase: 'wild', throwSeq: 0, shakes: 0 }
143          $.ui.toast(`${KIND_ICON[sp.kind]} A wild ${sp.name} appeared ${where}! Click it or /dex throw`)
144          $.ui.invalidate('ui.render')
145        }
146      }
147    } catch (err) {
148      $.ui.log(`cc-pokedex: encounter roll failed: ${err}`)
149    }
150    return next(e)
151  })
152
153  on('command.run', { command: 'dex' }, async ($, e, next) => {
154    const [verb = '', ...rest] = e.args.trim().split(/\s+/)
155    const arg = rest.join(' ')
156    const v = verb.toLowerCase()
157    const status = () => {
158      const p = partner
159      const me = p ? `partner ${icon(p.name)} ${p.name} · level ${levelOf(p.xp)} · ${p.xp} xp (next at ${xpForLevel(levelOf(p.xp) + 1)})` : 'no partner yet · catch one and it joins you'
160      const wild = encounter && encounter.phase !== 'caught' && encounter.phase !== 'fled' ? `wild ${encounter.name} ${encounter.where} · ${encounter.ballsLeft} balls left · /dex throw or click it` : 'nothing wild right now · prompt about water, grass, fire, bugs, plans, builds...'
161      return `dex ${dexSize(dex)}/${totalSpecies} · ${me}\n${wild}`
162    }
163    if (v === '' || v === 'status') return { text: status() }
164    if (v === 'list') {
165      const rows = SPECIES.filter(sp => dex[sp.name]).map(sp => {
166        const d = dex[sp.name]!
167        return `${KIND_ICON[sp.kind]} ${sp.name.padEnd(12)} ${RARITY_STARS[sp.rarity].padEnd(4)} ${sp.kind.padEnd(9)} x${d.count}${partner?.name === sp.name ? ' · partner' : ''}`
168      })
169      return { text: rows.length ? [`caught ${rows.length}/${totalSpecies}`, ...rows].join('\n') : 'nothing caught yet · a wild one appears when your prompt leads somewhere' }
170    }
171    if (v === 'partner') {
172      if (!arg) return { text: partner ? `partner is ${partner.name} · /dex partner <name> switches to another you caught` : 'no partner yet' }
173      const sp = byName(arg)
174      if (!sp) return { text: `no species called "${arg}"` }
175      if (!dex[sp.name]) return { text: `you have not caught ${sp.name} yet` }
176      const now = await $.clock.now()
177      partner = { name: sp.name, caughtAs: sp.name, xp: 0, since: now }
178      await save($, 'partner', partner)
179      firstNotice = undefined
180      $.ui.invalidate('ui.render')
181      return { text: `${KIND_ICON[sp.kind]} ${sp.name} is now your partner` }
182    }
183    if (v === 'throw') return { text: await throwNow($) }
184    if (v === 'run') {
185      if (!encounter || encounter.phase === 'caught' || encounter.phase === 'fled') return { text: 'nothing to run from' }
186      const name = encounter.name
187      encounter = undefined
188      $.ui.invalidate('ui.render')
189      return { text: `got away from ${name}` }
190    }
191    if (v === 'show' || v === 'hide') {
192      showPartner = v === 'show'
193      await save($, 'show', showPartner)
194      $.ui.invalidate('ui.render')
195      return { text: showPartner ? 'partner shown above the prompt' : 'partner hidden above the prompt (still by the spinner)' }
196    }
197    if (v === 'wild') {
198      // for trying it out: force an encounter, optionally of a kind or a species
199      const sp = byName(arg) ?? pickWild((Object.keys(KIND_COLOR) as Kind[]).includes(arg as Kind) ? (arg as Kind) : 'normal', Math.random, true)
200      const where = habitatOf(arg).where
201      encounter = { name: sp.name, kind: sp.kind, where, ballsLeft: BALLS_PER_ENCOUNTER, phase: 'wild', throwSeq: 0, shakes: 0 }
202      $.ui.invalidate('ui.render')
203      return { text: `${KIND_ICON[sp.kind]} A wild ${sp.name} appeared ${where}!` }
204    }
205    if (v === 'lure') {
206      const sp = byName(arg)
207      if (!sp) return { text: arg ? `no species called "${arg}"` : 'lure what? /dex lure pikachu' }
208      lure = sp.name
209      const where = HABITATS.find(h => h.kind === sp.kind)?.where ?? 'on the road'
210      return { text: `${KIND_ICON[sp.kind]} lure set for ${sp.name} · your next prompt that leads ${where} brings it out` }
211    }
212    if (v === 'release') {
213      if (!partner) return { text: 'no partner to release' }
214      const name = partner.name
215      partner = undefined
216      await save($, 'partner', undefined)
217      $.ui.invalidate('ui.render')
218      return { text: `${name} went back to the wild` }
219    }
220    return { text: 'dex · list · partner <name> · throw · run · show · hide · lure <name> · wild [kind|name] · release' }
221  })
222
223  on('turn.complete', async ($, e, next) => {
224    const r = await next(e)
225    await feedPartner($, 'turn')
226    return r
227  })
228
229  on('tool.call', async ($, e, next) => {
230    const r = await next(e)
231    const command = (e as { command?: unknown }).command
232    const event = xpEventFor(e.tool, typeof command === 'string' ? command : undefined, 'deny' in r ? undefined : !r.isError)
233    if (event) await feedPartner($, event)
234    return r
235  })
236
237  // the encounter board posts a click; the partner board posts nothing
238  on('ui.message', async ($, e, next) => {
239    const data = e.data as { throw?: unknown; run?: unknown } | null
240    if (data?.throw) await throwNow($)
241    else if (data?.run) { encounter = undefined; $.ui.invalidate('ui.render') }
242    else return next(e)
243    return {}
244  })
245
246  // above the prompt: the wild one while there is one, else the partner strip
247  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
248    if (e.props.hasSurvey || e.surface !== 'terminal') return next(e)
249    const { Box, Button, Client, Text } = await $.ui.resolve(e)
250    const cols = e.props.bodyColumns ?? e.viewport?.columns ?? 80
251    if (encounter) {
252      const sp = byName(encounter.name)
253      if (!sp) return next(e)
254      const props = {
255        name: sp.name, art: sp.art, color: KIND_COLOR[sp.kind], where: encounter.where, stars: RARITY_STARS[sp.rarity],
256        phase: encounter.phase, throwSeq: encounter.throwSeq, shakes: encounter.shakes, ballsLeft: encounter.ballsLeft,
257        caught: encounter.phase === 'caught',
258      }
259      const canThrow = encounter.phase === 'wild'
260      return (
261        <Box flexDirection="column">
262          <Box flexDirection="row" columnGap={1}>
263            {canThrow ? <Button key="dex:throw" label="throw ball" onPress={() => { void throwNow($) }} /> : null}
264            {canThrow ? <Button key="dex:run" label="run" onPress={() => { encounter = undefined; $.ui.invalidate('ui.render') }} /> : null}
265          </Box>
266          <Client key="dex:encounter" module="./boards/encounter.tsx" width={cols} height={4} props={props} />
267          {await next(e)}
268        </Box>
269      )
270    }
271    if (partner && showPartner) {
272      const sp = byName(partner.name)
273      if (!sp) return next(e)
274      const props = {
275        name: sp.name, art: sp.art, color: KIND_COLOR[sp.kind], kind: sp.kind, level: levelOf(partner.xp), xp: partner.xp,
276        next: xpForLevel(levelOf(partner.xp) + 1), caught: dexSize(dex), total: totalSpecies, working: e.props.isWorking,
277      }
278      return (
279        <Box flexDirection="column">
280          <Client key="dex:partner" module="./boards/partner.tsx" width={cols} height={3} props={props} />
281          {await next(e)}
282        </Box>
283      )
284    }
285    return next(e)
286  })
287
288  // the spinner's word while Claude works: "Pikachu is sauteing... (12s)"
289  on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
290    if (!partner || e.surface !== 'terminal') return next(e)
291    const word = e.props.word
292    return next({ ...e, props: { ...e.props, word: `${partner.name} is ${word.charAt(0).toLowerCase()}${word.slice(1)}` } })
293  })
294
295  // the line that closes a turn: "Pikachu baked for 3s"
296  on('ui.render', { component: 'TurnDuration' }, async ($, e, next) => {
297    if (!partner || e.surface !== 'terminal') return next(e)
298    const word = e.props.word
299    return next({ ...e, props: { ...e.props, word: `${partner.name} ${word.charAt(0).toLowerCase()}${word.slice(1)}` } })
300  })
301
302  // under the logo: the partner beside the first notice line. The logo itself has no render site
303  // yet, so this is the nearest a plugin gets to changing the mascot.
304  on('ui.render', { component: 'InfoNotice' }, async ($, e, next) => {
305    if (!partner || e.surface !== 'terminal') return next(e)
306    if (firstNotice === undefined) firstNotice = e.requestId
307    if (firstNotice !== e.requestId) return next(e)
308    const sp = byName(partner.name)
309    if (!sp) return next(e)
310    const { Box, Text } = await $.ui.resolve(e)
311    const color = KIND_COLOR[sp.kind]
312    return (
313      <Box flexDirection="column">
314        <Box flexDirection="row" columnGap={2}>
315          <Box flexDirection="column">
316            {sp.art.map(line => <Text color={color}>{line}</Text>)}
317          </Box>
318          <Box flexDirection="column" justifyContent="center">
319            <Text bold color={color}>{`${sp.name}`}<Text dimColor>{` · level ${levelOf(partner.xp)} · your partner`}</Text></Text>
320            <Text dimColor>{`dex ${dexSize(dex)}/${totalSpecies} · /dex`}</Text>
321          </Box>
322        </Box>
323        {await next(e)}
324      </Box>
325    )
326  })
327}
328
hooks/dex/data.ts 159 lines
1// The species table and the habitats. Pure data: no engine imports, so `bun test` loads it bare.
2//
3// Every species has a three-line sprite at most 11 cells wide, drawn in its type's colour. Art is
4// original terminal art in the style of the games, not ripped sprites.
5
6export type Kind =
7  | 'water' | 'grass' | 'fire' | 'electric' | 'rock' | 'ice' | 'flying'
8  | 'bug' | 'ghost' | 'psychic' | 'fighting' | 'normal'
9export type Rarity = 'common' | 'uncommon' | 'rare' | 'legendary'
10
11export type Species = {
12  name: string
13  kind: Kind
14  rarity: Rarity
15  art: [string, string, string]
16  /** the next form and the partner level that reaches it */
17  evolves?: { to: string; at: number }
18}
19
20export const KIND_COLOR: Record<Kind, string> = {
21  water: '#4aa8ff',
22  grass: '#5fd35f',
23  fire: '#ff6a3d',
24  electric: '#ffd83d',
25  rock: '#c9a468',
26  ice: '#8fe3ff',
27  flying: '#b9a7ff',
28  bug: '#a3c832',
29  ghost: '#9b6bff',
30  psychic: '#ff6bc4',
31  fighting: '#d9743a',
32  normal: '#c8c0b0',
33}
34
35export const KIND_ICON: Record<Kind, string> = {
36  water: '💧', grass: '🌿', fire: '🔥', electric: '⚡', rock: '🪨', ice: '❄', flying: '🌪',
37  bug: '🐛', ghost: '👻', psychic: '🔮', fighting: '👊', normal: '⭐',
38}
39
40// Where a prompt leads. Each habitat is a kind and the words that take you there; a prompt scores
41// one per word it matches, and the highest habitat wins (earlier wins a tie). No match: normal.
42export const HABITATS: ReadonlyArray<{ kind: Kind; words: RegExp; where: string }> = [
43  { kind: 'water', where: 'by the water', words: /\b(water|sea|ocean|river|lake|swim|fish|boat|beach|wave|rain|pool|surf|stream|shore|island|dive)\b/i },
44  { kind: 'grass', where: 'in the tall grass', words: /\b(land|grass|forest|tree|garden|plant|field|leaf|leaves|park|meadow|jungle|hike|flower|seed|grow)\b/i },
45  { kind: 'fire', where: 'near the volcano', words: /\b(fire|hot|burn|flame|lava|heat|volcano|cook|spicy|smoke|torch|ember)\b/i },
46  { kind: 'electric', where: 'at the power plant', words: /\b(electric|lightning|power|battery|thunder|volt|charge|energy|spark|storm|current)\b/i },
47  { kind: 'rock', where: 'in the cave', words: /\b(rock|stone|mountain|cave|desert|sand|dig|mine|dirt|earth|tunnel|boulder|cliff)\b/i },
48  { kind: 'ice', where: 'on the glacier', words: /\b(ice|snow|cold|freeze|frozen|winter|frost|chill|blizzard|glacier)\b/i },
49  { kind: 'flying', where: 'up in the sky', words: /\b(sky|fly|bird|wind|cloud|air|plane|wing|feather|soar)\b/i },
50  { kind: 'bug', where: 'in the bug-ridden code', words: /\b(bug|bugs|fix|crash|crashes|error|errors|broken|fails?|failing|exception|stack ?trace)\b/i },
51  { kind: 'ghost', where: 'in the haunted repo', words: /\b(delete|remove|kill|dead|haunt|ghost|night|dark|legacy|deprecated|zombie|orphan)\b/i },
52  { kind: 'psychic', where: 'in deep thought', words: /\b(think|plan|design|architecture|research|brainstorm|idea|ideas|why|explain|strategy|analy[sz]e)\b/i },
53  { kind: 'fighting', where: 'at the build gym', words: /\b(build|compile|deploy|docker|ship|release|binary|ci|pipeline|package|bundle|test|tests)\b/i },
54]
55
56export const RARITY_WEIGHT: Record<Rarity, number> = { common: 60, uncommon: 28, rare: 10, legendary: 2 }
57export const CATCH_RATE: Record<Rarity, number> = { common: 0.65, uncommon: 0.45, rare: 0.28, legendary: 0.1 }
58export const RARITY_STARS: Record<Rarity, string> = { common: '★', uncommon: '★★', rare: '★★★', legendary: '★★★★' }
59
60const s = (name: string, kind: Kind, rarity: Rarity, art: [string, string, string], evolves?: { to: string; at: number }): Species =>
61  ({ name, kind, rarity, art, evolves })
62
63export const SPECIES: readonly Species[] = [
64  // water
65  s('Squirtle', 'water', 'uncommon', ['  ,-(o o)  ', ' (_)\\_/ )  ', '   ~~ ~~   '], { to: 'Wartortle', at: 16 }),
66  s('Wartortle', 'water', 'rare', ['  ~,-(o o) ', ' (@)\\_/ )~ ', '  ~~~~~~   '], { to: 'Blastoise', at: 36 }),
67  s('Blastoise', 'water', 'rare', [' ||,-(o o) ', ' (##)\\_/ ) ', ' ||~~~~~~  ']),
68  s('Magikarp', 'water', 'common', ['  ><(o)>   ', ' <(  ~~  ) ', '   splash  '], { to: 'Gyarados', at: 20 }),
69  s('Gyarados', 'water', 'rare', [' /\\/\\(O O)<', ' \\/\\/\\_/\\/ ', '  ~~~~~~~~ ']),
70  s('Psyduck', 'water', 'common', ['  (o o)?   ', '  (  D )   ', '  /|   |\\  '], { to: 'Golduck', at: 33 }),
71  s('Golduck', 'water', 'rare', ['  <(o o)   ', '  (  D )   ', '  ~|   |~  ']),
72  s('Seel', 'water', 'common', ['  (o o)_   ', ' (   . ) ) ', '  ~~~~~~   '], { to: 'Dewgong', at: 34 }),
73  s('Dewgong', 'ice', 'uncommon', [' /(o o)__  ', ' \\(  . ) ) ', '  ~~~~~~~  ']),
74  s('Lapras', 'water', 'rare', ['  (o o)    ', '  ( ~ )__  ', ' ~(_/##\\_)~']),
75  // grass
76  s('Bulbasaur', 'grass', 'uncommon', ['  @(o o)   ', ' (\\  w  )  ', '  \\|   |/  '], { to: 'Ivysaur', at: 16 }),
77  s('Ivysaur', 'grass', 'rare', [' \\@/(o o)  ', ' (\\  w  )  ', '  \\|   |/  '], { to: 'Venusaur', at: 36 }),
78  s('Venusaur', 'grass', 'rare', ['\\\\@@//(o o)', ' (\\   w  ) ', '  \\|    |/ ']),
79  s('Oddish', 'grass', 'common', ['  \\|/\\|/   ', '   (o o)   ', '    \\_/    '], { to: 'Gloom', at: 21 }),
80  s('Gloom', 'grass', 'uncommon', ['  \\\\|//    ', '  (- -)    ', '  (___)~   ']),
81  s('Bellsprout', 'grass', 'common', ['   (o o)>  ', '    \\|/    ', '   __|__   '], { to: 'Weepinbell', at: 21 }),
82  s('Weepinbell', 'grass', 'uncommon', ['  (o o)==> ', '  (     )  ', '   \\___/   ']),
83  s('Tangela', 'grass', 'uncommon', [' @@@@@@@@  ', ' @@(o o)@@ ', ' @@@@@@@@  ']),
84  // fire
85  s('Charmander', 'fire', 'uncommon', ['  (o o)    ', '  ( w )~)  ', '  /|   |\\* '], { to: 'Charmeleon', at: 16 }),
86  s('Charmeleon', 'fire', 'rare', [' ^(o o)    ', '  ( w )~~) ', '  /|   |\\**'], { to: 'Charizard', at: 36 }),
87  s('Charizard', 'fire', 'rare', ['\\\\^(O O)   ', '//( VV )~~)', '  /|    |\\*']),
88  s('Vulpix', 'fire', 'common', [' ~~(o o)   ', '  (  w )   ', '  /|  |\\~~~'], { to: 'Ninetales', at: 30 }),
89  s('Ninetales', 'fire', 'rare', ['~~~(o o)   ', ' ~(  w  )~ ', '  /|   |\\~~']),
90  s('Growlithe', 'fire', 'common', [' ^(o o)^   ', ' (  w  )   ', ' /|   |\\~  '], { to: 'Arcanine', at: 30 }),
91  s('Arcanine', 'fire', 'rare', ['^^(O O)^^  ', '~(   W   )~', ' /|     |\\ ']),
92  s('Ponyta', 'fire', 'common', [' **(o o)   ', ' *(  w  )  ', '  /|   |\\  '], { to: 'Rapidash', at: 40 }),
93  s('Rapidash', 'fire', 'rare', ['*^*(o o)   ', '**(  w  )  ', '  /|   |\\  ']),
94  s('Moltres', 'fire', 'legendary', ['***(o o)***', ' *(  V  )* ', '  **\\_/**  ']),
95  // electric
96  s('Pikachu', 'electric', 'uncommon', ['  /\\_/\\    ', ' ( o.o )   ', '  (>w<)<   '], { to: 'Raichu', at: 22 }),
97  s('Raichu', 'electric', 'rare', [' /)_(\\     ', ' (o.o)     ', '  (>w<)<~~ ']),
98  s('Magnemite', 'electric', 'common', [' |(  O  )| ', ' |( === )| ', '   +   -   ']),
99  s('Voltorb', 'electric', 'common', ['  .-===-.  ', ' ( o   o ) ', '  \'-___-\'  '], { to: 'Electrode', at: 30 }),
100  s('Electrode', 'electric', 'uncommon', ['  .-___-.  ', ' ( ^   ^ ) ', '  \'-===-\'  ']),
101  s('Jolteon', 'electric', 'rare', ['  \\(o o)/  ', '  (  w  )  ', ' </|   |\\> ']),
102  s('Zapdos', 'electric', 'legendary', ['//\\(o o)/\\\\', ' \\( ==V== )', '   \\/\\/\\/  ']),
103  // rock and ground
104  s('Geodude', 'rock', 'common', ['  (o   o)  ', ' 0=( ___ )=0', '           '], { to: 'Graveler', at: 25 }),
105  s('Graveler', 'rock', 'uncommon', ['0=(o   o)=0', '0=( ___ )=0', '   /   \\   ']),
106  s('Onix', 'rock', 'uncommon', ['  (O O)    ', '  ( VV )O  ', ' O   O   O ']),
107  s('Diglett', 'rock', 'common', ['   (o o)   ', '   ( . )   ', ' ~~~~~~~~~ '], { to: 'Dugtrio', at: 26 }),
108  s('Dugtrio', 'rock', 'uncommon', ['(oo)(oo)(oo)', '( .)( .)( .)', ' ~~~~~~~~~ ']),
109  s('Sandshrew', 'rock', 'common', ['  #(o o)   ', ' ##(  w )  ', '  #/|  |\\  '], { to: 'Sandslash', at: 22 }),
110  s('Sandslash', 'rock', 'uncommon', [' ^^^(o o)  ', ' ^^(  w  ) ', '  ^/|   |\\ ']),
111  // ice
112  s('Jynx', 'ice', 'uncommon', [' ~~~(o o)~~', '   (  o  ) ', '   \\_____/ ']),
113  s('Shellder', 'ice', 'common', ['  /~~~~~\\  ', ' ( o   o ) ', '  \\_____/  ']),
114  s('Articuno', 'ice', 'legendary', ['~~\\(o o)/~~', ' ~( ===V )~', '   \\/ \\/   ']),
115  // flying
116  s('Pidgey', 'flying', 'common', ['  ((o o)>  ', '  ((   ))  ', '    ^ ^    '], { to: 'Pidgeotto', at: 18 }),
117  s('Pidgeotto', 'flying', 'uncommon', [' ~((o o)>  ', ' (((   ))) ', '    ^ ^    ']),
118  s('Spearow', 'flying', 'common', ['  ((o o)>> ', '  ((   ))  ', '    ^ ^    '], { to: 'Fearow', at: 20 }),
119  s('Fearow', 'flying', 'uncommon', ['  ((o o)>>>', ' ((( _ ))) ', '    ^ ^    ']),
120  s('Zubat', 'flying', 'common', ['  /\\   /\\  ', ' /  (   )  \\', '     v v   '], { to: 'Golbat', at: 22 }),
121  s('Golbat', 'flying', 'uncommon', ['/\\       /\\', '\\ (o vv o) /', '   \\___/   ']),
122  s('Butterfree', 'bug', 'uncommon', [' /\\(o o)/\\ ', '(  (   )  )', ' \\/     \\/ ']),
123  // bug
124  s('Caterpie', 'bug', 'common', ['  (o o)    ', '  (   )()()', '  ~~~~~~~  '], { to: 'Metapod', at: 7 }),
125  s('Metapod', 'bug', 'common', ['   /( )\\   ', '  ( o o )  ', '   \\___/   '], { to: 'Butterfree', at: 10 }),
126  s('Weedle', 'bug', 'common', [' ^(o o)    ', '  (   )()()', '  ~~~~~~~  '], { to: 'Kakuna', at: 7 }),
127  s('Kakuna', 'bug', 'common', ['   /( )\\   ', '  ( - - )  ', '   \\___/   '], { to: 'Beedrill', at: 10 }),
128  s('Beedrill', 'bug', 'uncommon', ['>=(o o)=<  ', '  (   )>   ', '   \\|/     ']),
129  s('Scyther', 'bug', 'rare', ['/=(o o)=\\  ', '  (   )    ', ' _/|   |\\_ ']),
130  // ghost
131  s('Gastly', 'ghost', 'common', [' . (o o) . ', '. (  ~  ) .', ' .  \'-\'  . '], { to: 'Haunter', at: 25 }),
132  s('Haunter', 'ghost', 'uncommon', ['  (o   o)  ', ' \\( ~~~ )/ ', '   \'---\'   '], { to: 'Gengar', at: 40 }),
133  s('Gengar', 'ghost', 'rare', [' ^(o   o)^ ', ' ( \\___/ ) ', '  \\_____/  ']),
134  // psychic
135  s('Abra', 'psychic', 'common', ['  (- -)    ', ' -(  w )-  ', '  /|   |\\  '], { to: 'Kadabra', at: 16 }),
136  s('Kadabra', 'psychic', 'uncommon', ['  (o o)    ', ' -(  w )-/ ', '  /|   |\\  ']),
137  s('Drowzee', 'psychic', 'common', ['  (- -)    ', '  (  o~)   ', '  /|   |\\  '], { to: 'Hypno', at: 26 }),
138  s('Hypno', 'psychic', 'uncommon', ['  (o o)    ', '  (  o~)-o ', '  /|   |\\  ']),
139  s('Mewtwo', 'psychic', 'legendary', ['  (o   o)  ', '  (  V  )  ', ' /|     |\\~']),
140  s('Mew', 'psychic', 'legendary', ['  (o o)    ', '  ( w )~   ', '   \\_/     ']),
141  // fighting
142  s('Machop', 'fighting', 'common', ['  (o o)    ', ' o(  w  )o ', '  /|   |\\  '], { to: 'Machoke', at: 28 }),
143  s('Machoke', 'fighting', 'uncommon', ['  (o o)    ', 'O(   W   )O', '  /|   |\\  ']),
144  s('Hitmonchan', 'fighting', 'rare', ['  (o o)    ', ' 0(  -  )0 ', '  /|   |\\  ']),
145  // normal
146  s('Rattata', 'normal', 'common', ['  (o o)    ', ' <(  w  )  ', '  /|   |\\~ '], { to: 'Raticate', at: 20 }),
147  s('Raticate', 'normal', 'uncommon', ['  (o o)    ', ' <(  W  )> ', '  /|   |\\~~']),
148  s('Meowth', 'normal', 'common', [' /\\_$_/\\   ', ' ( o.o )   ', '  (>w<)    '], { to: 'Persian', at: 28 }),
149  s('Persian', 'normal', 'uncommon', [' /\\_@_/\\   ', ' ( -.- )   ', '  (>w<)~   ']),
150  s('Eevee', 'normal', 'uncommon', [' /\\   /\\   ', ' ( o.o )   ', '  (>w<)~~  '], { to: 'Jolteon', at: 20 }),
151  s('Snorlax', 'normal', 'rare', ['  (- -)    ', ' (  ___  ) ', ' (_______) ']),
152  s('Ditto', 'normal', 'uncommon', ['  .-----.  ', ' ( o   o ) ', '  \'--w--\'  ']),
153]
154
155export const byName = (name: string): Species | undefined => {
156  const lower = name.toLowerCase()
157  return SPECIES.find(sp => sp.name.toLowerCase() === lower)
158}
159
hooks/dex/logic.ts 118 lines
1// The game's rules, pure: which habitat a prompt leads to, whether a wild one appears, whether a
2// ball catches it, and how a partner levels and evolves. Randomness comes in as an `rng` in
3// [0, 1), so tests can pin it. ../register.tsx wires these to the engine.
4
5import { CATCH_RATE, HABITATS, RARITY_WEIGHT, SPECIES, byName, type Kind, type Rarity, type Species } from './data.ts'
6
7export type Rng = () => number
8
9/** The habitat a prompt leads to: the kind with the most matching words, else normal. */
10export function habitatOf(text: string): { kind: Kind; where: string } {
11  let best: { kind: Kind; where: string; score: number } | undefined
12  for (const h of HABITATS) {
13    const re = new RegExp(h.words.source, 'gi')
14    const score = (text.match(re) ?? []).length
15    if (score > 0 && (!best || score > best.score)) best = { kind: h.kind, where: h.where, score }
16  }
17  return best ? { kind: best.kind, where: best.where } : { kind: 'normal', where: 'on the road' }
18}
19
20// how often a prompt in a habitat meets something: named habitats often, the road rarely
21export const ENCOUNTER_RATE: Record<'habitat' | 'road', number> = { habitat: 0.4, road: 0.1 }
22
23/** Whether a wild one shows up for this prompt. Mew only answers manners. */
24export function rollEncounter(kind: Kind, rng: Rng): boolean {
25  return rng() < (kind === 'normal' ? ENCOUNTER_RATE.road : ENCOUNTER_RATE.habitat)
26}
27
28const isMythical = (sp: Species) => sp.name === 'Mew'
29export const isPolite = (text: string) => /\bplease\b/i.test(text) && /\bthank(s| you)\b/i.test(text)
30
31/** Picks a wild species of a kind, weighted by rarity. Evolved forms roam too, at their rarity. */
32export function pickWild(kind: Kind, rng: Rng, polite = false): Species {
33  const pool = SPECIES.filter(sp => sp.kind === kind && (!isMythical(sp) || polite))
34  const candidates = pool.length ? pool : SPECIES.filter(sp => sp.kind === 'normal')
35  const total = candidates.reduce((sum, sp) => sum + RARITY_WEIGHT[sp.rarity], 0)
36  let roll = rng() * total
37  for (const sp of candidates) {
38    roll -= RARITY_WEIGHT[sp.rarity]
39    if (roll < 0) return sp
40  }
41  return candidates[candidates.length - 1]!
42}
43
44export const BALLS_PER_ENCOUNTER = 4
45
46/** One throw: caught or broke free, and how many times the ball shook (0-3) before the answer. */
47export function throwBall(rarity: Rarity, rng: Rng): { caught: boolean; shakes: number } {
48  const caught = rng() < CATCH_RATE[rarity]
49  if (caught) return { caught, shakes: 3 }
50  // a miss shakes fewer times the rarer the target, so the animation tells you how close it was
51  const closeness = rng()
52  const shakes = closeness < 0.4 ? 0 : closeness < 0.75 ? 1 : 2
53  return { caught: false, shakes }
54}
55
56// ── the partner ───────────────────────────────────────────────────────────────
57
58export type Partner = {
59  /** the species name as it stands (evolution rewrites it) */
60  name: string
61  /** the species it was caught as */
62  caughtAs: string
63  xp: number
64  since: number
65}
66
67export type XpEvent = 'turn' | 'edit' | 'test-pass' | 'commit' | 'catch'
68export const XP: Record<XpEvent, number> = { turn: 8, edit: 1, 'test-pass': 10, commit: 15, catch: 20 }
69
70/** level n needs 2·n² xp: 5 at 50, 10 at 200, 16 at 512, 36 at 2592 */
71export const levelOf = (xp: number) => Math.max(1, Math.floor(Math.sqrt(xp / 2)))
72export const xpForLevel = (level: number) => 2 * level * level
73
74/** Gives xp; when the level crosses an evolution, the partner becomes the next form. */
75export function gainXp(p: Partner, event: XpEvent): { partner: Partner; leveled: boolean; evolvedFrom?: string } {
76  const before = levelOf(p.xp)
77  const xp = p.xp + XP[event]
78  const after = levelOf(xp)
79  let name = p.name
80  let evolvedFrom: string | undefined
81  // one evolution per gain; a chain climbs one step per level crossing
82  const sp = byName(name)
83  if (sp?.evolves && after >= sp.evolves.at && before < sp.evolves.at) {
84    evolvedFrom = name
85    name = sp.evolves.to
86  }
87  return { partner: { ...p, xp, name }, leveled: after > before, evolvedFrom }
88}
89
90/** What a finished tool call is worth to the partner; undefined when nothing. */
91const TEST_COMMAND = /\b(bun|npm|pnpm|yarn)\s+(run\s+)?test\b|\bnpx\s+(jest|vitest|mocha)\b|\b(pytest|jest|vitest|rspec|phpunit|mocha)\b|\bgo\s+test\b|\bcargo\s+test\b|\bmake\s+test\b/
92export function xpEventFor(tool: string, command: string | undefined, ok: boolean | undefined): XpEvent | undefined {
93  if (!ok) return undefined
94  if (tool === 'Bash' && command) {
95    if (TEST_COMMAND.test(command)) return 'test-pass'
96    if (/\bgit\s+commit\b/.test(command) && !/--dry-run\b/.test(command)) return 'commit'
97    return undefined
98  }
99  if (tool === 'Edit' || tool === 'Write' || tool === 'NotebookEdit') return 'edit'
100  return undefined
101}
102
103// ── the dex ───────────────────────────────────────────────────────────────────
104
105/** Caught species by name: how many, and when the first one was caught. */
106export type Dex = Record<string, { count: number; first: number }>
107
108export function record(dex: Dex, name: string, now: number): { dex: Dex; isNew: boolean } {
109  const had = dex[name]
110  return {
111    dex: { ...dex, [name]: { count: (had?.count ?? 0) + 1, first: had?.first ?? now } },
112    isNew: !had,
113  }
114}
115
116export const dexSize = (dex: Dex) => Object.keys(dex).length
117export const totalSpecies = SPECIES.length
118
hooks/boards/encounter.tsx 102 lines
1/* @jsx h */
2import type { ClientSurface } from 'claude-code'
3
4// The wild one, drawn above the prompt. The hooks module owns the encounter (it decides each throw
5// and reveals the answer once this animation has played); this board draws the creature, the ball
6// in flight and its shakes, and posts a click as a throw. Space or Enter throws once the board has
7// the keyboard (click it first); `r` runs.
8
9type Props = {
10  name: string
11  art: [string, string, string]
12  color: string
13  where: string
14  stars: string
15  phase: 'wild' | 'throwing' | 'caught' | 'fled'
16  throwSeq: number
17  shakes: number
18  ballsLeft: number
19  caught: boolean
20} | undefined
21
22type State = { frame: number; seenSeq: number; animFrame: number }
23
24const FLIGHT = 4
25const PER_SHAKE = 3
26const BALL = ['(o)', '(O)', '(o)']
27const FLIGHT_PATH = ['   ', '  .', ' . ', '.  ']
28
29export default function EncounterBoard(props: Props, surface: ClientSurface<State>) {
30  const { Box, Text } = surface.elements
31
32  if (surface.state === undefined) {
33    surface.setState({ frame: 0, seenSeq: props?.throwSeq ?? 0, animFrame: -1 })
34    surface.every(200, () => {
35      const s = surface.state
36      if (s) surface.setState({ ...s, frame: s.frame + 1, animFrame: s.animFrame >= 0 ? s.animFrame + 1 : -1 })
37    })
38    surface.onPointer(ev => {
39      if (ev.type === 'down') surface.post({ throw: true })
40    })
41    surface.onKey(ev => {
42      if (ev.key === ' ' || ev.key === 'return') surface.post({ throw: true })
43      else if (ev.key === 'r') surface.post({ run: true })
44    })
45  }
46
47  const s = surface.state
48  if (!props || !s) return <Text dimColor>a wild one is loading...</Text>
49
50  // a new throw starts the animation from the first frame
51  if (props.throwSeq !== s.seenSeq) surface.setState({ ...s, seenSeq: props.throwSeq, animFrame: 0 })
52  const anim = props.throwSeq !== s.seenSeq ? 0 : s.animFrame
53  const inFlight = props.phase === 'throwing' && anim >= 0 && anim < FLIGHT
54  const shaking = props.phase === 'throwing' && anim >= FLIGHT
55  const shakeIndex = shaking ? Math.floor((anim - FLIGHT) / PER_SHAKE) : -1
56  const shakesDone = Math.min(props.shakes, Math.max(0, shakeIndex + (shaking && (anim - FLIGHT) % PER_SHAKE === PER_SHAKE - 1 ? 1 : 0)))
57
58  // the creature bobs while wild, hides in the ball while it shakes, and blinks now and then
59  const bob = props.phase === 'wild' && s.frame % 6 < 3 ? ' ' : ''
60  const blink = s.frame % 25 === 0
61  const art = props.art.map(line => (blink ? line.replace(/o/g, '-') : line))
62  const hidden = shaking || props.phase === 'caught'
63
64  let ball: string
65  let line: string
66  if (props.phase === 'caught') {
67    ball = ' (*) '
68    line = `Gotcha! ${props.name} was caught!`
69  } else if (props.phase === 'fled') {
70    ball = '     '
71    line = `Oh no! ${props.name} broke free and ran away.`
72  } else if (inFlight) {
73    ball = FLIGHT_PATH[anim] + BALL[anim % 3]
74    line = 'you threw a ball...'
75  } else if (shaking) {
76    const wobble = (anim - FLIGHT) % PER_SHAKE
77    ball = wobble === 0 ? ' (o) ' : wobble === 1 ? '(o)  ' : '  (o)'
78    line = `${'shake '.repeat(shakesDone)}${shakesDone < props.shakes || props.shakes < 3 ? '...' : ''}`
79  } else {
80    ball = '     '
81    line = `${props.ballsLeft} ball${props.ballsLeft === 1 ? '' : 's'} left · click the board or press Space to throw · r runs`
82  }
83
84  const color = props.color
85  return (
86    <Box flexDirection="column">
87      <Text bold color={color}>{`A wild ${props.name} appeared ${props.where}! `}<Text color="yellow">{props.stars}</Text></Text>
88      <Box flexDirection="row" columnGap={2}>
89        <Box flexDirection="column" width={16}>
90          {hidden
91            ? [<Text> </Text>, <Text color="red">{`     ${ball}`}</Text>, <Text> </Text>]
92            : art.map(l => <Text color={color}>{`${bob}${l}`}</Text>)}
93        </Box>
94        <Box flexDirection="column" justifyContent="flex-end">
95          {inFlight ? <Text color="red">{ball}</Text> : <Text> </Text>}
96          <Text dimColor={!props.caught} color={props.caught ? 'greenBright' : undefined} bold={props.caught}>{line}</Text>
97        </Box>
98      </Box>
99    </Box>
100  )
101}
102
hooks/boards/partner.tsx 65 lines
1/* @jsx h */
2import type { ClientSurface } from 'claude-code'
3
4// The partner strip above the prompt: the sprite, bobbing while Claude works, blinking now and
5// then, with its level and the dex count beside it. Draws only; the hooks module owns the partner.
6
7type Props = {
8  name: string
9  art: [string, string, string]
10  color: string
11  kind: string
12  level: number
13  xp: number
14  next: number
15  caught: number
16  total: number
17  working: boolean
18} | undefined
19
20type State = { frame: number; petted: number }
21
22const bar = (xp: number, next: number, level: number) => {
23  const from = 2 * level * level
24  const span = Math.max(1, next - from)
25  const filled = Math.round(Math.max(0, Math.min(1, (xp - from) / span)) * 12)
26  return '█'.repeat(filled) + '░'.repeat(12 - filled)
27}
28
29export default function PartnerBoard(props: Props, surface: ClientSurface<State>) {
30  const { Box, Text } = surface.elements
31
32  if (surface.state === undefined) {
33    surface.setState({ frame: 0, petted: 0 })
34    surface.every(250, () => {
35      const s = surface.state
36      if (s) surface.setState({ frame: s.frame + 1, petted: Math.max(0, s.petted - 1) })
37    })
38    surface.onPointer(ev => {
39      const s = surface.state
40      if (s && ev.type === 'down') surface.setState({ ...s, petted: 6 })
41    })
42  }
43
44  const s = surface.state
45  if (!props || !s) return <Text dimColor>partner loading...</Text>
46
47  const bob = props.working && s.frame % 4 < 2 ? ' ' : ''
48  const blink = s.frame % 20 === 0
49  const art = props.art.map(line => (blink ? line.replace(/o/g, '-') : line))
50  const hearts = s.petted > 0 ? '  ♥ ♥' : ''
51
52  return (
53    <Box flexDirection="row" columnGap={2}>
54      <Box flexDirection="column" width={14}>
55        {art.map(line => <Text color={props.color}>{`${bob}${line}`}</Text>)}
56      </Box>
57      <Box flexDirection="column">
58        <Text bold color={props.color}>{props.name}<Text dimColor>{` · ${props.kind} · level ${props.level}`}</Text><Text color="magentaBright">{hearts}</Text></Text>
59        <Text dimColor>{'xp '}<Text color={props.color}>{bar(props.xp, props.next, props.level)}</Text>{` ${props.xp}/${props.next}`}</Text>
60        <Text dimColor>{`dex ${props.caught}/${props.total} · ${props.working ? 'working with Claude' : 'waiting for a prompt'} · /dex hide`}</Text>
61      </Box>
62    </Box>
63  )
64}
65