Building Bitcoin Runner
Bitcoin Runner is a 2D auto-runner with a fixed-length, score-attack structure — not an endless runner, since there's no fail state to run out the clock against and every level is a finite, named dataset with a real finish line. The terrain isn't procedurally generated — it's real Bitcoin price history. You auto-run across a pixel-art Mountain Range shaped by actual daily closing prices, and the only input you have is a bet: jump Up ("Buy") or Down ("Sell") to predict whether the market is about to rise or fall before you land. Guess right and you land safely with a leveraged gain; guess wrong and you land safely too, just poorer — there's no fail state, no death. The game never punishes you for playing, only for guessing wrong.
This is a design post-mortem, written mid-development rather than after the fact. The game is live at bitcoin.certifiable.media and still very much in progress — twelve levels exist, each mapped to a real, named era of Bitcoin's price history (Bull Run 2017, the Covid Crash, crypto winter, and so on), but several are still running placeholder terrain art. Consider this an invitation to poke at the rough edges as much as a description of what's finished.
By the numbers so far: 114 tracked issues (89 closed, 23 open), 138 commits, built across roughly ten days (2026-08-12 to 2026-08-21).
The core idea
The mechanic is simple on purpose: press Up or Down, land, find out if you were right. What makes it more than a coin flip is that every jump is a real leveraged trade against real price data. The position itself is denominated in Bitcoin, not dollars: jumping converts a flat $10,000 into however much BTC that actually buys at the exact historical price the instant you take off — a lot of BTC during a crash-era level where price is low, a sliver of one during a bull-run level where price is high — then holds that BTC quantity fixed until you land. The dollar figure you see the whole time is a live conversion of that real BTC holding back to USD at whatever the in-game historical price is at that moment, the same math a real position's mark-to-market value would use. Landing correctly closes it at a profit (amplified by your current Leverage), landing wrong closes it at a loss. Leverage steps up by 1 on every correct guess with no ceiling — a hot streak can compound into an enormous position — and steps down on every miss, floored at 1x so a bad run can't zero out your exposure. Dice pickups scattered across the terrain multiply Leverage outright on a 1-6 roll, a much bigger and riskier swing than the steady per-landing step.
The Wallet is a genuine dollar profit-and-loss balance, not an abstract score. It can go, and is meant to go, deeply negative on a bad run — High Scores tracks separate "High" and "Low" leaderboards per level for exactly that reason, because a spectacularly bad run is still a story worth keeping. Losses are deliberately unbounded, the same as gains: there's no floor on how negative the Wallet can go, and no auto-liquidation that closes out a losing position early. Real leveraged trading doesn't work that way — margin calls and liquidations exist precisely to stop a loss before it goes unbounded — but this isn't real trading, and letting a bad streak crater all the way down is a lot more fun to watch (and recover from) than getting stopped out quietly.
Every run is a fixed 90 seconds regardless of level, but each level has its own real difficulty curve: scroll speed and jump duration both scale up to roughly 2.7x from level 1 to level 12. The trickiest piece of the whole design was making a run's pacing exactly match what the level-select screen promises — every level shows its entire real date range on the price chart before you play it, and every run needs to actually traverse that whole range in exactly 90 seconds, at a speed that's also a genuine difficulty knob. Those two constraints fight each other, and getting them to agree took two prior designs that each solved half the problem before landing on the current one (see Lessons, below).
Why real price data
The terrain isn't just "wavy hills that look nice" — it's literally Bitcoin's own price chart, redrawn as a mountain range you run across. Bull Run 2017's climb to ~$19.8k and subsequent crash is a genuinely thrilling shape to traverse; the Covid Crash's near-vertical drop is a genuinely stomach-dropping one. That authenticity was the whole point from the first brainstorm — a generic procedural terrain generator would have been easier to build and much less interesting to play.
Twelve non-overlapping, real, named eras were selected from the full historical series (2011-08-22 through present), each with its own pixel-art color theme and mood (molten lava for a crash, blizzard snow for crypto winter, neon for a speculative bull run). That data-to-visual mapping — deciding where an era starts and ends, how price normalizes to screen height, and how much of each range fits into a 90-second run — went through more revisions than any other single system in the game.
Biggest lessons learned
A dataset can be technically correct and still feel wrong. The first scoring model paid out raw price-delta times multiplier. It felt fine on paper and unfair in practice: a level where Bitcoin traded at $5 could never produce swings as dramatic, in absolute dollar terms, as a level trading at $60,000, even for an identical percentage move. The fix was reframing every trade as a fixed $10,000 notional position — same dollar risk on every jump, on every level, regardless of the era's price magnitude — so the game's difficulty curve is about timing and leverage, not accidents of Bitcoin's own price history.
Solving two constraints separately doesn't work when they're actually coupled. Getting a level's real difficulty-curve speed to coexist with "every run shows the whole dataset in exactly 90 seconds" took three attempts: first, ending a run whenever the data ran out (broke the fixed-duration promise); then, looping the terrain to fill fixed time (lost the sense that you were traversing a real, finite history); finally, solving for horizontal point-spacing per level so speed stays an independent difficulty knob and the terrain simply compresses or stretches to fit — both constraints satisfied at once, by construction rather than by fiat.
Desktop testing can hide GPU limits that mobile devices enforce. One character's spritesheet was built as a single flat strip 14,740 pixels wide. It rendered perfectly everywhere it was tested — because every desktop GPU used for testing supports textures far past that width. It rendered as a solid black box on the one real Android phone it was actually tried on, because mobile GPUs enforce a much lower maximum texture size. The fix was reflowing the sheet into a grid instead of a strip; the real lesson was that "it works on my machine" means almost nothing for GPU-level constraints, and the fix shipped only after being confirmed on the actual device that reported the bug.
An AI-generated asset doesn't automatically share scale, color, or fitness-for-purpose with another AI-generated asset, even from the same prompt family. Level 9's terrain needed a wooden bridge motif to sit convincingly over a snow-buried village. Two separate close-up generations of "a bridge" and a separate wide shot of "a village" turned out to have no common real-world scale between them — nothing tying their pixel-per-meter together — which only became obvious once a candidate was pasted in at literal 1:1 scale with zero resizing. The eventual fix was hand-drawing the bridge tile with code instead of generating it, anchored explicitly to the same price-line data the rest of the terrain already respects.
Licenses can quietly veto a design decision made in good faith. The original audio plan named Strudel.cc based on how it was described in the project's own tooling docs. Implementing it surfaced that Strudel is AGPL-3.0 — a real conflict for a private repository, not a style preference — caught only once actual implementation started. The pivot to hand-written Web Audio API oscillators cost an afternoon; not catching it before shipping would have cost considerably more.
Generated art that looks great at native resolution can fail completely at the size it's actually used. Replacing the game's stock favicon seemed like the smallest possible task — right up until a photorealistic 3D coin render, however nicely lit at full size, turned to mud the moment it was scaled down to a 16-pixel browser tab icon, no matter how it was resized, sharpened, or hand-pixeled from scratch afterward. The asset that finally worked wasn't a new generation at all — it was the game's own already-shipped, already-approved in-game Bitcoin pickup sprite, simply recolored to true Bitcoin orange and shipped at a size the browser could scale on its own. The working answer was already sitting in the project; the miss was reaching for something new before checking what already existed and was already good.
Claude can't reliably judge whether an image is actually good. Across dozens of generated sprites, terrain textures, and backgrounds, the recurring failure mode wasn't generating something broken — it was Claude looking at a technically-valid result (right dimensions, right palette, no obvious glitches) and judging it "done" when it wasn't actually fit for purpose: a pixel sprite that didn't read clearly at gameplay scale, a terrain texture that looked fine in isolation but wrong once tiled, art that was simply off in a way a human eye catches instantly and an AI's own self-assessment doesn't. The fix wasn't a better prompt or a smarter check — it was a workflow change: every image-generation step now requires an explicit human review before the result is used, rather than trusting the model's own judgment that an image looks right.
What's next
The single largest piece of unfinished work is real terrain art for six of the twelve levels (Frozen Peaks, Industrial Wasteland, Neon Metropolis, Solar Flare, To the Moon, and House of Cards/Lincoln Memorial) — they're playable today on placeholder art while the same real, price-data-fitted pipeline used for the finished levels gets rolled out to each.
Past that, the next real design frontier is a "Player-driven exploration" epic: reworking scoring around live, continuously-updating delta pricing rather than only discrete landings, building out a full achievements/stars/unlockable-character system (one achievement — 1000x Leverage — is live today; a "Pro Trader" achievement for a run of ten consecutive good trades is designed but not built), and deciding what triggers meaningful in-run feedback beyond the current landing-by-landing verdict.
A handful of known visual bugs are queued too — a couple of levels' character-select backgrounds and near-parallax layers don't quite fit their panels yet.
If you're playtesting: the run is 90 seconds, there's no way to lose, and the interesting failure mode is getting greedy with Leverage right before a level's biggest swing goes the wrong way. Try it, break it, and tell us what felt wrong.
The stack, for anyone curious
- Engine: Phaser 3 (
^3.90.0, JavaScript, ES modules) — scaffolded from Phaser's own officialphaserjs/template-viterather than hand-rolled, so the build tooling stays a known quantity instead of a bespoke one. - Build/dev server: Vite (
^6.3.1), with terser for production minification. - Rendering: WebGL via Phaser,
pixelArt: true(nearest-neighbor filtering, no mipmaps) so pixel-art sprites stay crisp instead of blurring when scaled. - Audio: two distinct pipelines. Every sound effect is plain Web Audio API — hand-written oscillators, zero asset files, zero dependencies (see the Strudel/AGPL lesson above for why this isn't the library-based approach originally planned). Music is the opposite: real MP3 files, one per level plus a menu theme, generated on Suno.
- State management: a single
GameStatesingleton (score, leverage, level, clock, pause state) that every system reads from and mutates through events, plus anEventBus(a PhaserEventEmitter) so no two gameplay systems ever import each other directly — Terrain, Runner, Scoring, HUD, and Audio all only talk to the bus. - Persistence:
localStorage, no backend — settings (sound/music/character), per-level high/low scores, and achievement unlocks are all client-side only. There's no server anywhere in this stack; the whole game is static files. - Data: twelve static JSON files, one per level, each a slice of a merged real-world Bitcoin daily-close price series — fetched once at development time, not queried live at runtime, so a level's terrain is identical every time it's played.
- Testing: Playwright (
@playwright/test ^1.62.1), driving the actual built game in a real browser rather than mocking Phaser. The game exposes two debug hooks specifically for this —window.render_game_to_text()returns a JSON snapshot of live game state, andwindow.advanceTime(ms)steps the simulation deterministically — so tests assert on real state transitions instead of screenshots or guesswork. - Deployment: static build via
vite build, deployed to Vercel through thevercelCLI (vercel --prod), deliberately not wired to Vercel's Git integration — deploys happen on demand, not on every push, by explicit choice. - Art tooling: a locally-run ComfyUI instance (Qwen-Image + a pixel-art LoRA) for backgrounds and terrain textures, Blender for a couple of procedurally-scripted 3D props (the spinning coin pickup, the tumbling die, both built via Blender's Python API rather than modeled by hand), and Higgsfield.ai for the playable character spritesheets.
- Project management: beads (
bd), a CLI issue tracker, for all task tracking — every feature, bug, and design decision mentioned in this article traces back to a real issue in the project's own.beads/history, which is also where most of the "lessons learned" above were mined from. - Development process: built with Claude Code, using the
make-gameskill suite (a set of packaged workflows for AI-assisted game development) for session structure, milestone planning, and architecture conventions, plus its narrowerphaser,game-audio, andgame-qaskills for engine-, audio-, and testing-specific patterns.
-- Claude