CardCore
Back to Blog

Building CardCore - April 5, 2026 - Five Games, One Platform, and Why We Built a Safety Net First

April 5, 20266 min readBy Dan Vohasek
building-in-publicstar-wars-unlimitedmagic-the-gatheringlorcanasorceryriftboundinfrastructuremulti-game

We broke something. Then we fixed everything.

I'm going to be honest about something that happened today. We had a data loss incident. A migration went wrong, and customer collection data got deleted. We had backups - hourly local, daily to Backblaze B2 - so we recovered. But "we recovered" is not the same as "it didn't happen."

That incident changed my priorities for the entire day. I had planned to spend today building browse pages for new card games. Instead, I spent the first half of the day building infrastructure that makes sure we never accidentally destroy production data from a staging environment again.

And then I built the game pages anyway. Both halves of the day mattered.

The real problem: staging was writing to production

Here's what was actually dangerous about our setup before today. Our Docker staging environment shared the production Customer_DB. Not a copy. Not a snapshot. The actual production database.

That means every time someone tested adding a card to their collection in staging, it modified their real collection. Every deck save, every profile edit, every trade action - all hitting production customer data. The store-level stuff (inventory, orders, credit) was already isolated, but customer-owned data like collections and decks? Shared.

This is not an uncommon pattern in early-stage startups. You start with one database because customers span stores - that's by design. A customer's collection travels with them. But the moment you have a staging environment that can write to that shared database, you have a ticking bomb.

Today we defused it.

Nonprod_Customer_DB: environment-scoped customer data

The fix sounds simple, but the architecture decision behind it matters.

Customer data transcends stores within an environment, but not across environments.

Production stores share Customer_DB. Nonprod stores share Nonprod_Customer_DB. These are separate databases with the same schema, and nonprod is a one-way snapshot from production that gets refreshed on demand.

We added a fail-fast guardrail in the application startup code. If the app detects it's running in staging mode but pointing at the production customer database, it crashes immediately. Not a warning. Not a log line you might miss. A hard crash with an actionable error message. After losing data once, "are you sure?" is not strong enough. The answer is "no, you can't."

Every database pool now logs its target at startup when running in staging. You see exactly which databases you're connected to before the first request hits.

The admin layout that didn't exist

While auditing the staging environment, we found that the /admin route had no server-side protection. The admin rewards page checked your session on the client side and hoped the API would reject unauthorized users. That's backwards.

We added a proper server-side admin layout that checks your auth session and staff role before any admin page HTML is sent to the browser. If you're not authorized, you never see the page. The API-level checks stay as defense in depth.

We also found a bug where the admin rewards page had a hardcoded storeId = 1. In production, that's fine - store 1 is the production store. In staging, where the store ID is 0, the middleware correctly blocked it and the page appeared broken. Fixed it to read from the environment variable.

Five games. One afternoon.

With the safety infrastructure in place, I turned to the actual goal: making CardCore a multi-game platform.

We already had Star Wars: Unlimited (the flagship) and Magic: The Gathering (in preview). Today we added three more:

Disney Lorcana - 5,107 cards across 17 sets. Three variants: Normal, Holofoil, Cold Foil. Ten rarity levels from Common up to Enchanted. Every card has pricing from TCGPlayer and images from their CDN.

Sorcery: Contested Realm - 3,164 cards across 7 sets. Two variants: Normal and Foil. Sorcery uses its own rarity names - Ordinary, Exceptional, Elite, Unique - which is exactly the kind of game-specific detail that made me glad we kept each game as an isolated vertical instead of trying to build one generic system too early.

Riftbound - 1,363 cards across 8 sets. Two variants: Normal and Foil. The newest game in our catalog, with data going back to October 2025.

Each game has its own browse page, card detail page, search API, filter API, and preview banner. Each has its own database, its own connection pool, its own feature flags. The code for each game touches zero files from any other game.

The pattern that made it fast

The reason we could ship three new game verticals in one afternoon is that the second one established the pattern.

MTG was the original reference implementation. It has a more complex schema with a separate card_attributes table and Scryfall metadata. Lorcana was simpler - just a cards table with pricing data, no enrichment layer. Once Lorcana worked, Sorcery and Riftbound were mechanical.

The only real surprise was a type casting issue. Sorcery and Riftbound use bigint for product IDs and numeric for prices, while Lorcana uses integer and real. PostgreSQL's node driver returns bigint as strings and numeric as strings. The formatPrice function called .toFixed(2) on what it expected to be a number, and got a string. The fix was SQL-level type casting: productid::int, marketprice::float.

That's the kind of bug you only find by actually running the code against real data. Which is exactly why we built the nonprod validation lane first.

Every game was validated in nonprod before production

This is the part I'm most proud of. Every single game page was built, tested, and validated in the Docker staging environment before any code was pushed to production. The workflow:

  1. Build the game vertical locally
  2. Copy to the Docker staging submodule
  3. Rebuild the container
  4. Verify in the browser on the staging environment
  5. Fix any issues (type casting, empty-name product filtering, rarity sort order)
  6. Only then: commit, push, deploy to prod
  7. Smoke test on prod

No cowboy deploys. No "it works on my machine." The nonprod lane we built in the morning earned its keep in the afternoon.

What's next

All five games are accessible on production right now by direct URL. They're not in the navigation or on the homepage yet - that's a feature flag flip away when we're ready for the full launch. The preview banners make it clear these are coming-soon areas.

The April 18th PQ and SQ events are two weeks away. Store owners attending those events will be able to see that CardCore isn't just a Star Wars platform. It's a trading card game platform that already supports five games with daily pricing, searchable catalogs, and card detail pages.

The next step is wiring up the homepage game selector and navigation links so these games are discoverable, not just accessible. And then: collections, deck building, and trading for non-SWU games. The database schema already supports it - the game column is there. The infrastructure is there. The data is there.

Five games. Five databases. One platform. Zero production data lost (from this point forward).