I built a Pokemon game inside Claude Code's statusline

The idea was simple. Too simple, probably.
What if there was a Pokemon living in your Claude Code statusline?
Not a static icon. Not an emoji. A companion that actually evolves as you code. One that gains XP from your session tokens, levels up at the real game thresholds, and when it's fully evolved, gets released so a new wild encounter can take its place.
That was Friday morning. By Friday night, Tangela was staring back at me from the bottom of my terminal in full ANSI pixel art.
The statusline is a shell script
The first thing I had to learn: Claude Code's statusline is just a shell command. It receives session JSON on stdin (model, tokens, cost, context window) and prints whatever it wants to stdout. That's it. No framework, no component model.
TokenGolf already had one. A Python script that rendered cost, efficiency, and a context bar. But that was a status display, not a game.
Statusmon needed to be both.
The XP model went through three rewrites
The first version tracked XP through Claude Code hooks. A UserPromptSubmit hook counted prompts. A PostToolUse hook counted tool calls. Every tool call wrote to disk. That's 4 syscalls per tool call, on every single invocation. For a session with 100+ tool calls, that's a lot of pointless I/O just to increment a counter.
The second version derived XP from session token counts. The session JSON already has total_input_tokens and total_output_tokens. Just divide by a scaling factor. No hooks needed.
But the token counts reset each session. So I added prev_tokens to track the delta. That introduced a new bug: when a new session started, prev_tokens carried over from the last session, the delta went negative, and XP froze. The statusline looked fine in bash tests but never moved in the actual product.
The third version, the one that works, splits XP into banked_xp (persisted from previous sessions) and session_xp (computed live from current session tokens). The statusline computes the level from banked_xp + floor(tokens / 25000). Session start banks the previous session's XP. No prev_tokens. No delta tracking. No bugs.
// statusline.mjs
const sessionXp = tokensToXp(totalTokens)
const level = computeLevel((state.banked_xp || 0) + sessionXp)
Three tries to get a number right. Same lesson as TokenGolf: the math is easy. The lifecycle is hard.
Evolution uses the real PokeAPI data
I didn't want fake evolution levels. If Charmander evolves at level 16 in the games, it should evolve at level 16 in Statusmon.
PokeAPI has everything. Evolution chains with nested evolves_to arrays, min_level triggers, species data with genera and flavor text. The pokedex-promise-v2 wrapper made it clean. P.getEvolutionChainById(2) returns Bulbasaur's full chain with built-in caching.
The evolution logic walks the chain, flattens it into stages, and checks the current level against the next stage's min_level. For Pokemon that evolve by trade or item (no min_level), it defaults to level 20.
// evolution.mjs
function flattenChain(chainData) {
const stages = []
function walk(node) {
const speciesId = parseInt(
node.species.url.split("/").filter(Boolean).pop(),
10
)
let minLevel =
node.evolution_details?.[0]?.min_level || DEFAULT_EVOLVE_LEVEL
stages.push({ species: node.species.name, speciesId, minLevel })
if (node.evolves_to?.length > 0) walk(node.evolves_to[0])
}
walk(chainData.chain)
return stages
}
Branching evolutions like Eevee just take the first branch. Deterministic, simple.
The sprite renderer was the surprise hit
I expected the sprites to be a nice-to-have. They turned out to be the thing that made the whole project feel real.
The approach: download the 96x96 PNG from PokeAPI's sprite repo, decode it with pngjs (pure JS, zero native deps), downscale with bilinear interpolation, and convert to ANSI half-block characters. Each terminal character cell gets two vertical pixels. The upper half-block โ uses the foreground color as the top pixel and the background color as the bottom pixel.
// For each pair of vertical pixels:
if (top.a < 64 && bot.a < 64)
line += " " // both transparent
else if (top.a < 64)
line += `\x1b[38;2;${bot}mโ` // only bottom
else if (bot.a < 64)
line += `\x1b[38;2;${top}mโ` // only top
else line += `\x1b[38;2;${top};48;2;${bot}mโ` // both pixels
At 48x48 pixels (24 terminal rows), Pokemon are clearly recognizable. The bilinear interpolation smooths the downscaling so you get blended colors instead of jagged nearest-neighbor artifacts.
The sprite size is configurable. sprite_size in trainer.json. Set it to 16 for a compact 8-row display, or 96 for full 1:1 resolution at 48 rows.
Here's what the statusline actually looks like:
๐ฟ TANGELA #114 ยท Vine Pokemon ยท Gen 1
LV 13 -> 20
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โโโโโ โ โโ
โ โโโโโโโโโโโโโโ
โโโโ โโโโโโโโโโโโโ
โโโโโโโโโโโโโ โโโโโ
โโโโโโโโโโโโโโโโ
โ โโโโโโโโโโ โโ
โโโโโโโ โโโโโ
โโโโโ โโโโโโ
โโโโ
The actual terminal output is full true-color ANSI (16 million colors). The block characters above are just an approximation.
Claude Code's statusline renderer strips things
This was the biggest surprise of the day.
I tried putting text on the left and the sprite on the right. The text rows without content became leading whitespace, and Claude Code's statusline renderer stripped them. The sprite jumped to the left edge and overlapped with the text.
I tried inline PNG images via iTerm2's OSC 1337 protocol. The escape sequence was in the output. iTerm2 can render it. Claude Code's renderer ate it.
I tried custom emoji fonts via Nerd Fonts style PUA glyphs. macOS terminals don't render custom color glyphs from installed fonts. Not in any terminal I tested.
The solution that actually worked: text on top, sprite on bottom. Three compact lines of info, then the full sprite. No side-by-side alignment to break. The sprite rows always have ANSI content (even transparent pixels produce escape codes), so Claude Code never strips them.
That was the moment I stopped trying to be clever with the layout and just shipped the version that was correct every time.
The generation system
All 1025 Pokemon in the API is too many for a first-run experience. So Statusmon locks encounters to Gen 1, the original 151 Kanto Pokemon.
After 50 sessions, Gen 2 unlocks (adding Johto, #152-251). Every 50 sessions after that opens another generation. The encounter pool is cumulative. Unlocking Gen 2 doesn't remove Gen 1 Pokemon.
The filtering is simple: newEncounter() picks a random evolution chain, checks if the base form's species ID is within the generation cap, and retries if not. With Gen 1 covering about 28% of all chains, it averages around 3 attempts per encounter.
The Pokedex
Every Pokemon you train gets recorded to ~/.statusmon/pokedex.json when it's released. Original species, final evolution reached, max level, encounter date, release date, sessions trained. The /pokedex slash command lets Claude render your full history.
{
"species": "charmander",
"max_species": "charizard",
"max_level": 42,
"types": ["fire"],
"genus": "Lizard Pokemon",
"encountered_at": "2026-03-14",
"released_at": "2026-03-18",
"sessions": 12
}
The statusline used to read the pokedex file on every render just to get the count. That was an early mistake. Now dex_count is stored directly in trainer.json and incremented on release. One fewer file read on the hot path.
The stack
Two runtime dependencies. That's it.
- pngjs for PNG decoding (pure JS, no native compilation)
- pokedex-promise-v2 for the PokeAPI (built-in caching, clean promise API)
Dev tooling: esbuild for bundling, Vitest for tests (17 passing), ESLint + Prettier for formatting, Husky for pre-commit hooks that run lint + test + build on every commit.
The whole thing is a Claude Code plugin with a SessionStart hook for XP banking and a statusline command for rendering. No database, no native deps, no build step for users.
What shipped
By end of day:
- Full-color ANSI sprites at configurable resolution
- XP from session tokens, banked across sessions
- Evolution at real game levels via PokeAPI
- Gen 1 Kanto lock with generation progression
- Pokedex tracking all encountered Pokemon
- Type-colored UI with game-accurate RGB values
- Chains with TokenGolf's statusline
- ESLint, Prettier, Vitest (17 tests), Husky pre-commit
- GitHub Pages docs site
- Zero native dependencies
The statusline went from an empty directory to a working Claude Code plugin in one session. Not because the code was simple. The XP model alone took three rewrites. But the constraints were clear and the API was generous.
PokeAPI is one of those rare public APIs that has everything you need and nothing you don't. Every sprite, every evolution chain, every genus and flavor text. That's the difference between "fun idea" and "shipped today."
Statusmon is a Claude Code plugin. Install it with claude plugin marketplace add josheche/statusmon. Source and docs at github.com/josheche/statusmon.
Related Posts
The wizard asked you to commit a budget before you knew what the session needed. Par replaced that question with a better one: how efficient were you given what actually happened?
Expansion and hardening at the same time. More achievements, more hooks, more structure, and the release infrastructure to keep the rules from drifting.
By the end of Saturday, the score was believable. Sunday became the day of friction removal and deeper integration.
