You call a crypto API. What comes back looks roughly like this:
[
{ "symbol": "BTC",
"quote": { "USD": {
"price": 95432.17,
"percent_change_24h": 1.83,
"market_cap": 1.879e12,
// ...fifteen more fields
}}},
{ "symbol": "ETH", ... },
// ...97 more of these
]
Ninety-nine coins. Updating constantly. Each one a little bag of numbers. On its own, this is data. In a human brain, it is static.
That's the fundamental data visualization problem, and crypto has it worse than almost any other domain. Too many tickers, too fast, updating in real time, all fighting for your attention, most of them worthless, a few of them the difference between a vacation and a liquidation email.
The standard answers are tables and line charts. Both are useless past a certain density. A table of 99 rows and 8 columns is a Rorschach test. A chart with 99 overlapping lines is a hairball.
So. What if the data had a body?
WHY CITIES
There's a concept in cognitive science called ambient display: the idea that some information is best delivered not as numbers on a screen, but as a visual environment you glance at. A weather mobile in your hallway. A smart bulb that shifts color when your portfolio tanks. A fishtank whose lights pulse faster when the market's rallying.
Cities are almost embarrassingly good at this. Humans evolved to parse skylines in milliseconds. A skyline gives you scale (how tall?), density (how many?), lighting (what state?), and geography (what's where?) — all at a single glance. You don't have to read anything. You just see it.
For crypto, the mapping is obvious once you spot it. Each coin has a size (market cap). Each coin has a state (up, down, flat). Each coin has a rank. Map those to height, window color, and position — and you've got a visualization that delivers a week of numbers in one second of looking.
That's the premise of BittCity. Now let's talk about how it actually gets built.
THE PIPELINE, IN SEVEN UNSURPRISING STEPS
Every live data project has the same spine. This one is no different.
CoinMarketCap's /cryptocurrency/listings/latest endpoint with limit=99. That's it. The entire top of the market in one HTTP call.Upstash Redis with a 20-minute TTL. Why? Because a free-tier CMC API key has hard rate limits, and hitting the upstream on every visitor request is both expensive and pointless — prices don't change meaningfully in 30 seconds for this use case.fetch()s the cached JSON. If cache is warm, ~100ms. If it needs a refresh from CMC, ~800ms. Either way, the user sees data fast.{ Name, Ticker, Price, _24h, _7d, _30d, _90d, _cap, _r }. Underscore-prefixed keys are the numeric versions; non-prefixed keys are pre-formatted display strings. This small trick — always keeping display-formatted versions alongside the numbers — saves you from reformatting on every render._7d. Taps "MKT CAP" → sort by _cap. Taps rank → stable default order. The array reorders. That's all.<canvas> at 30 FPS with pixel-perfect rendering mode.None of these steps are novel. What makes the result interesting is what happens inside step 7.
THE KEY TRICK: DETERMINISTIC RANDOMNESS
Here's a problem you hit immediately when building something like this. You want each building to look unique — different window patterns, different details, different roof textures. If every building looked identical you'd have Mondrian, not a city.
The naive solution is Math.random(). Roll dice for each window on each render. Except — every re-render, every timeframe switch, every sort, every data refresh — the whole city re-rolls. Windows blink on and off randomly. Building details shuffle. It looks broken.
A skyline should be stable. It should be the same city every time you look at it. Bitcoin's tower should always have the same window arrangement. You walk away, you come back, it's there.
The fix is a seeded pseudo-random number generator, where the seed is derived from something stable about each coin. In BittCity, it's basically ticker.charCodeAt(0) combined with a constant. Same ticker → same seed → same windows, forever.
function srand(seed) {
let s = seed | 0;
return () => {
s = s * 1103515245 + 12345 & 0x7fffffff;
return (s >>> 16) / 32768;
};
}
That's a Linear Congruential Generator. It's from 1968. It is not cryptographically secure. For drawing pixel windows, it is absolutely perfect.
BTC always has the same window pattern. ETH always has the same. When you sort, switch timeframes, or refresh — the city stays stable. It feels solid. This one change — deterministic seeds — is what separates "data viz that feels alive" from "data viz that feels glitchy."
CANVAS VS SVG VS DOM — A SHORT RANT
For 99 pixel buildings, each with ~30 windows, you're rendering roughly 3000 rectangles at 30fps. Your options:
- DOM: each building as a
<div>with inline styles. Great for debugging. Awful at this scale. The browser will wheeze. - SVG: DOM-like but vector. Better than DOM, still not great. You're creating and destroying thousands of nodes on every state change unless you're very careful.
- Canvas: draw to a bitmap buffer directly. Fastest by a wide margin. Loses semantic structure — no "this element is Bitcoin" unless you track it yourself. You write more code, but the code runs at 60fps on a potato laptop.
BittCity uses canvas. For any data viz where you have more than ~50 animated elements, canvas is almost always the right answer. Set ctx.imageSmoothingEnabled = false and you get pixel-perfect rendering for free, which means you can zoom without blur — critical for this aesthetic.
MAKING IT FEEL ALIVE WITHOUT LYING ABOUT THE DATA
This is the hard part.
Static data is boring. A perfectly accurate skyline of coin market caps, rendered once and frozen, is a chart. You'd look at it for ten seconds and leave.
But if you over-animate the data — wiggle the buildings, strobe the windows, add motion for its own sake — you're lying. The price didn't just change. Don't tell the user's eyeballs it did.
The trick is to animate the environment, not the data. In BittCity:
- The stars twinkle, but the buildings don't flicker.
- A comet streaks across the sky on a timer, unrelated to any coin.
- A UFO blinks in periodically. Doesn't mean anything.
- A flock of pixel birds passes at dawn. Purely decorative.
- The sun and moon cycle over a long loop, painting the sky different colors.
None of this is data. All of it is motion. Your brain receives the "this is alive" signal without getting misleading information about the market. That's the central principle of ambient data display:
The world moves, the data sits still.
You can stare at the skyline for a minute and it feels dynamic. Nothing is lying to you. When Bitcoin actually moves, you'll notice — because everything else was still before.
PERFORMANCE GOTCHAS YOU WILL ABSOLUTELY HIT
If you try to build something like this, you will hit these in roughly this order:
- Garbage collection jank. Allocating new objects every frame causes GC pauses, which show up as stutters. Pre-allocate your arrays. Reuse objects. Your framerate will thank you.
- Retina scaling weirdness. On a 2× DPR screen, canvas is fuzzy unless you manually scale the backing buffer. Set
canvas.width = rect.width * dpr, thenctx.scale(dpr, dpr)at init. Don't forget or everything looks Vaseline. - Mobile viewport changes. Mobile browsers resize the viewport on scroll as the URL bar hides. Your canvas will shift and jitter unless you debounce resize events properly.
- Touch vs mouse events. Handle both, explicitly. Don't assume devicePixelRatio tells you everything. Test on an actual phone, not Chrome's device emulator.
- Loading state flicker. The moment data arrives, if you don't handle it carefully, the whole city "pops" into existence and feels broken. Fade from a neutral loading state. Twenty lines of code, massive UX payoff.
HOW TO START BUILDING SOMETHING LIKE THIS
You don't need BittCity's exact stack. A minimal setup:
- Data source with a free tier: CoinMarketCap, CoinGecko, DeFi Llama, Binance's public API, or on-chain via a node provider if you want to be extra.
- Cache layer, if you expect traffic: Upstash, Cloudflare KV, or just an in-memory cache on a long-running Node process.
- Canvas in vanilla JS. You don't need React. You don't need D3. D3 is great for certain things; for 30fps pixel art, it's overkill and the abstraction actively fights you.
- LCG or xorshift PRNG for deterministic randomness. Three lines of code. Don't pull
seedrandomfrom npm for this — you're making the problem more complicated than it is. requestAnimationFramefor your render loop. NeversetInterval. Tab switches and battery-saving modes will betray you.
That's the whole stack. Everything else is taste.
The interesting thing about data art isn't the tech — the tech is almost trivial. It's the decision. The decision to stop treating data as rows and start treating it as environment. A field you walk through. A city you glance at. A weather system you feel without ever reading a forecast.
Crypto markets are perfect for this because they're public, continuous, structurally emotional, and already loaded with metaphors about towers and altitude and climbing and falling. The metaphors were always there. Someone just had to render them.
If you want to see the full thesis in motion, it's running right here: the BittCity live skyline. If you want the cultural companion piece on why crypto interfaces all look the way they do: Web3 Aesthetics and the Retro Obsession. If you want to play the game built on the same canvas engine: Crypto Jump.