This site uses one functional cookie to keep feature rollouts consistent for you. Nothing is set until you choose. See the privacy notice.
Dev notes
I already had a Pokémon TCG browser and a whole Fantasy NBA section wired to an ESPN league. This is the write-up on stitching the two together: take a night of real box scores and mint each performance as a trading card, where the rarity is decided by how a player did relative to everyone else who played that night. The Card Lab does it for NBA (my fantasy roster) and WNBA (the whole slate), and this is where I drew the line.
The full pitch is a small economy: check your ESPN roster and your weekly matchup, earn currency off however many points your opponent puts up, spend it on packs, and rip weighted pulls of cards generated from every rostered player’s real performance that week. A quiet game mints a plain card; a monster line mints something rare.
That is a lot of moving parts, and most of them need somewhere to store state that this repo doesn’t have. So I built the one piece that is genuinely new and that everything else leans on: the generator that turns performances into rarity-tiered cards. The economy comes later, once there’s a place to keep what people own.
The obvious version assigns rarity by absolute points: 50 is an SIR, 10 is a common. I didn’t do that, because a fantasy week isn’t graded on an absolute scale. A 30-point night is a monster in a slow week and unremarkable in a shootout. So the engine scores each player by percentile within that week’s pool: top of the pack earns an SIR, the next slice a rare, the middle an uncommon, and the rest a common.
sport-player-period, so regenerating the same week is idempotent — it produces the exact same cards.Keeping the thresholds as named constants means tuning “how rare is an SIR” is one edit and a test change, not a hunt through branching logic.
The generator (generateCards) knows nothing about ESPN. It takes a list of PlayerPerformance objects and returns cards, which makes it trivial to test against synthetic pools and keeps the sport a pluggable dimension. Everything ESPN-specific lives in adapters that each validate the raw payload with a Zod schema and drop anything malformed rather than throwing, so a half-empty response degrades to fewer cards instead of a broken page.
There are two: one flattens the fantasy league roster into season totals, and one parses a night’s box scores into per-game lines. The box-score parser is the important one — it’s what makes the cards nightly.
Currency, pack inventory, owned cards, and pull history are all per-user state that has to persist. In this app that lives in a separate service, not in the front end, so doing it properly is its own change with its own schema. I’d rather ship the generator, prove the pipeline from a real roster to a real card, and build the economy on top of something that already works. The one piece of the economy I did bake in early is the pull-weighting contract — each rarity carries a weight that strictly decreases as it gets rarer — so when the rip arrives, the odds are already defined and tested.
Update — Aug 22, 2026
Season totals were the wrong unit. A card should be a single night: “36 points, this date, versus that team.” So the default is a slate — the most recent night rostered players played — from ESPN’s public scoreboard and box scores. Points sit at a different column per sport (NBA last, WNBA second), so the code finds the column by name and never hardcodes it. Discovery scans date-range windows back from today, so the off-season isn’t a dead end: in August the NBA view lands on last June’s Finals.
WNBA turned out to have a real ESPN fantasy game (wfba), so it scopes to my roster when the league is public (or my cookies are set), and otherwise falls back to the whole night’s slate. NFL is weekly and its metric is fantasy points, which the public box score doesn’t carry — so NFL reads the league itself per week, with a picker across all 18. One engine, one page, a sport toggle.
Update — Aug 22, 2026
Relative rarity is the base, but not every 20-point night is equal. A card now bumps up a tier when the player’s real team won, another when it was a playoff game, and hardest of all for a fantasy playoff or finals run — so a quiet Finals night that ended in a win can still mint an SIR. Each shows as a badge, alongside the fantasy team that rosters the player. Boosts never lift a zero or negative outing, and they stack, capped at SIR.
The win and playoff signals come straight from the box score I already fetch; the fantasy playoff/finals signal is wired through the engine and tested, with sourcing it from the league’s matchup view the next step. Card art is still the ESPN headshot; the photo from that exact game is a harder problem for later.
Update — Aug 23, 2026
Generating cards was always the means; the point was collecting them. So there’s an economy now: a coin wallet with a daily claim, a “rip a pack” button that draws five rarity-weighted cards from whatever slate I’m looking at, and a collection page that keeps what I’ve pulled with duplicates grouped. The weighted draw lives in the same engine as everything else (drawPack over RARITY_META.pullWeight), so an SIR really is the rare pull.
The bit I was careful about: the rip is drawn on the server, not the client. The browser only says which view it’s looking at; the BFF regenerates that slate, runs the draw, and hands the result to the API to debit coins and record — so nobody can hand-pick their own pulls. Persistence lives in the separate API (a wallet table and a pulls table, scoped per user), which is its own change; the front end ships behind it and lights up once that’s deployed.
Update — Aug 24, 2026
The first rip UI was a small inline list of what you pulled — correct, but flat. Opening a pack should be a moment. So a rip now fills the screen: a darkened overlay where you spin a wheel of packs, pick one, drag the tab down to tear it open, and the cards flip out one at a time with their art and rarity frames — the Pokémon Pocket feel.
The part that took the care was making the theatre optional. It’s a real modal dialog — labelled, Escape to close, focus moved into it — and every step has a click or keyboard path, because a drag-only interaction locks out anyone not using a mouse. Reduced-motion skips the spin and tear entirely and goes straight to the reveal; the cards are already yours by then, so nothing is lost by fast-forwarding. Pack art draws from the in-band accent palette, so the design guard stays happy.
Update — Aug 24, 2026
Once the cards looked good I noticed a night could mint four SIRs, which makes gold mean nothing. The cause was the boosts: on a playoff night every rostered player picks up a Playoffs boost, the winners a Won on top, and a whole roster stacked its way into gold. So a nightly slate is scarce on purpose now — SIR is capped at two (usually one, sometimes none), rares are held to a slice of the slate, and a boost can lift a card as far as rare but no further. SIR is something a top-of-the-night line earns on its own, not something the boosts hand out. The banding is untouched everywhere else; the caps and that boost ceiling are opt-in, and only the nightly path asks for them.
Two smaller things from the same look. A game that tips in the evening reads as the next day in UTC, so an Aug 24 slate was labelled Aug 25. ESPN hands over no venue timezone, only a UTC stamp — but every US game’s local day is its Pacific day (the westmost US zone, and nothing tips early enough to fall behind it), so the slate dates by Pacific and a game in any US zone lands on the right day. And the cards in a row didn’t line up: the cursor-glow shell wrapped each one in an inner div with no height, which broke the fill so a card with fewer chips came out shorter. I patched it with CSS and thought it was done — it worked in dev and silently failed in the production build, which is its own lesson about verifying the artifact and not the dev server. The honest fix was to stop nesting inside that wrapper: the card’s own element is the grid cell’s filler now and carries the frosted glass itself, so a row always matches. The trade is the cursor glow, which lived in that wrapper; the backdrops, tilt and animated points stay.
Update — Aug 24, 2026
The cards read well but sat flat, so I gave each one a life of its own. Every card now has a backdrop nobody else has: a rarity-tinted glass surface with a glow that follows the cursor, and behind it a drifting GradientMesh and/or a seeded BlobBackground — the ESPN headshots are transparent cutouts, so the colour shows around the player. Which treatment and which palette a card gets is hashed from the card itself, so it’s unique but stable: the same card draws the same backdrop every render, server and client alike, which also keeps hydration quiet.
The points count up as a big jersey-style number over the photo (AnimatedNumber), the boosts became Chips and the owned-count a Badge, and on hover the whole card tilts in 3D to face the pointer. That last one is a new TiltCard — the rotating cousin of the MagneticButton I already had, motion values and a spring so a pointer move never re-renders the card. The thing I was careful about is that none of it is load-bearing: the tilt, the glow, the mesh drift and the count-up all stop under reduced motion, the backdrops are aria-hidden, and the copy sits over a scrim so it keeps its contrast. It’s one shared FantasyCard, so the Card Lab, the collection and the pack reveal all came alive at once.
Update — Aug 24, 2026
Three small asks that added up to a real cleanup. Rarity was a little pill in the corner — easy to miss, and it leaned on colour. Now every card wears a bold band with the tier spelled out (SIR, Rare…), coloured to match, with a border and glow that grow for the rarer pulls, so a good card announces itself. The points moved up front, and the collection shows them too.
The bigger win was collapsing three near-identical card renders — the Card Lab grid, the collection, the pack reveal — into one FantasyCard, so rarity reads the same everywhere and there’s one place to change it. And every page that shows cards now shares the same controls: rarity filter chips and a points/rarity/name sort, the sort itself a tiny pure function that’s trivial to test.
Update — Aug 24, 2026
I’d wired the fantasy playoff/finals boost through the engine but left the basketball side unsourced, because the obvious source doesn’t hold up. I wanted “this night was a fantasy playoff game” per week, but the league’s schedule view doesn’t map a matchup period to real game dates, and rankFinal comes back 0 — so there’s no dependable champion or per-week playoff signal to read.
What is dependable is the final seeding: each team’s playoffSeed against the league’s playoffTeamCount. So the boost keys off the honest thing the data actually knows — did your fantasy team make the playoffs — derived once from the roster fetch I already make, and applied to every card that team rosters. I kept it to a single tier, distinct from the NFL per-week result, so a season-long attribute nudges rarity without flooding the pool with SIRs. It’s the reliable half of the idea, shipped, instead of the whole idea faked.