CardCore
Back to Blog

Building CardCore - April 9, 2026 - Wait, There's More (Part 2 of a Day That Refused to End)

April 9, 202610 min readBy Dan Vohasek
building-in-publicstar-wars-unlimitedtheory-boarddeck-buildertradinginfrastructurecollection

I already wrote today's blog post. Then I didn't stop working.

If you haven't read Part 1, go do that first. It covers theory boards going from zero to a full collaborative tier list system, 9 trade platform bugs getting crushed in a single QA session, a Box EV overhaul, TCGPlayer Sprint 3, and a pile of mobile fixes. It ends with me saying I was going to close the laptop.

I did not close the laptop.

Instead, I looked at the theory board, thought "polling sync isn't good enough," and then spent the next eight hours rebuilding the entire sync layer, shipping a leader/base picker, adding search filters, squashing 9 more bugs, building an automated QA testing pipeline, deprecating a feature nobody used, fixing a ghost row data corruption bug, and adding a Name/Text search toggle to the deck editor. Thirty-one more commits. The git log for today alone runs from 12:02 AM to 9:34 PM - a 22-hour session with a lunch break somewhere in the middle that I genuinely don't remember taking.

This is Part 2. Because apparently one blog post per day isn't enough when you refuse to stop shipping.

Polling is dead - long live WebSockets

The morning's theory board used polling-based sync. It worked. Every few seconds, the client would check the server for updates, merge them into local state, and move on. Functional. Adequate. Completely unacceptable once you've seen what real-time feels like.

So we ripped it out and replaced it with Hocuspocus + Yjs (COREAPP-1102) - a proper WebSocket-based collaboration framework. The difference is night and day. With polling, you'd drag a card to S-tier and your collaborator would see it teleport there 3-5 seconds later. With Yjs, the movement is instant. You see their cursor move. You see the card lift. It feels like you're both touching the same board.

Getting there was three commits of pure infrastructure pain. The Hocuspocus server needed its own TypeScript build pipeline. The database pool was initializing before dotenv loaded the connection string, so every card query returned a 404 because the query was running against... nothing. The onRequest handler needed custom auth to validate session tokens over WebSocket - you can't just slap a middleware on a WebSocket upgrade the way you can on an HTTP route.

Then came the architectural decision that made the whole thing work: hybrid sync. Cards still sync through REST polling. Presence data and activity events flow through Yjs. Most teams go all-in on one approach. We went hybrid because the data demanded it.

Here's why: Yjs uses CRDTs (Conflict-free Replicated Data Types) for merge resolution. CRDTs are brilliant for simple values - cursors, typing indicators, boolean flags. They're a nightmare for ordered arrays of complex objects with server-authoritative positioning. When two people drag the same card to different tiers simultaneously, a CRDT will happily merge both operations and produce a state where the card exists in two tiers at once. That's not a merge - that's a corruption.

So cards stay on REST with server-authoritative ordering. The server is the single source of truth for card placements. Presence and activity get the WebSocket treatment because sub-100ms latency matters for "I can see you working" and it doesn't matter if a cursor position is briefly inconsistent.

Different problems. Different solutions. Ship it.

The 9-bug theory board audit

After the WebSocket upgrade stabilized, I sat down and used the theory board like an actual player would. Created a board. Added a leader. Tried to flip it. Nothing happened.

The flip button was invisible. Not broken - invisible. It was rendering behind the card image because the z-index was wrong. You could technically click it if you knew exactly where it was, which nobody would, because it was invisible. Fix number one.

Then I noticed the legality indicator was permanently red. Every board, every leader, every combination - red "Legality Issues" badge. Even perfectly legal decks. The condition was checking issues.length > 0 but the issues array was being initialized with an empty string, which JavaScript helpfully counts as truthy. Fix number two.

Then the search panel was 12vw wide but the stats panel was 15vw, creating a visual seam down the side of the board. Fix three. The leader/base picker didn't exist for blank boards - you had to know to click a specific invisible region of the header. Fix four. Card images in search were tiny. Fix five. Quantity display wasn't updating. Fix six. The "Set Base" slot showed nothing when only a leader was selected. Fix seven. Aspect filter pills weren't in the search panel. Fix eight. Cost, trait, and keyword filter dropdowns were missing entirely. Fix nine.

Nine bugs. One commit. Sometimes the fastest way to ship is to refuse to commit until the whole list is empty.

Ghost rows in your collection

This one was nasty (COREAPP-1110). When you added a card to your collection from the card detail page, the code was storing variantLabel instead of subTypeName. Both describe the card variant. They're different strings.

The result: ghost rows. You add a Hyperspace Sabine Wren. The row gets created with variant identifier "Hyperspace" (the label) instead of "SWU Hyperspace" (the subtype name). Your collection count goes up by one. But when the collection view tries to match that row back to the card database, it's looking for "SWU Hyperspace" and your row says "Hyperspace." No match. The card appears in your count but not in your grid. A phantom.

This is the kind of bug that makes players say "something feels off about my collection" without being able to explain what. They're not wrong. They're just looking at corrupted data that looks almost right. Fixed, shipped, and a reminder that in a platform with 600+ unique card variants, getting the identifier wrong by one field creates invisible chaos.

Staff gets a pull list, traders lose the binder

Two operational changes that are boring to describe and transformative to use.

The consolidated pull list (COREAPP-1109) aggregates all pending orders into a single sorted card list. Instead of opening order 1, writing down the cards, opening order 2, writing down more cards, walking the case 15 times - you get one list. "3x Sabine Wren, 2x Darth Vader, 1x Ghost." One trip through the case. All orders staged. At a busy store doing 20 orders a day, this saves 30 minutes minimum.

The trade binder deprecation (COREAPP-1108) removed an early feature that let players curate a subset of their collection for trading. In practice, nobody curated. They just traded from their full collection and got confused when cards they owned didn't show up in the trade picker (because they hadn't added them to the binder first). The binder was adding a mandatory curation step to every trade that provided zero value. The card picker now pulls directly from your collection's tradeable cards. The code is cleaner. The UX is simpler. The feature nobody used is gone.

Teaching the robots to test

The last act of this marathon wasn't code. It was infrastructure.

We set up automated visual QA testing - Claude's Chrome browser extension connected to store.cardcore.gg, configured with Plane MCP for direct ticket management. The idea: point an AI at the site, give it a list of bugs with reproduction steps, and let it screenshot, click, resize to mobile viewports, and verify fixes systematically.

The punchline? It didn't work. The extension couldn't access our site. "Blocked by your organization's policy." Cue 30 minutes of debugging. We checked the CSP headers. Added unsafe-eval to script-src. Deployed. Still blocked. Checked Chrome enterprise policies. Clean. Tested other sites - Google, GitHub, all fine. Just our domain, specifically blocked.

Turns out the problem was three characters in a text field. The Anthropic org admin settings had cardcore.gg on a domain blocklist. Why? No idea. Probably fat-fingered during initial setup. Removed it, reopened Chrome, and suddenly the extension could screenshot, navigate, read DOM elements, and interact with every page on the site.

We reverted the CSP change (it was never the problem), and now we have a fully operational QA pipeline. Ten open bugs are queued for automated verification tomorrow. The robots will test. The humans will verify. The tickets will close themselves.

The deck editor gets smarter

One more feature that slipped in at 9:33 PM because apparently we weren't done. The deck editor now has a Name/Text search toggle. Previously, search only matched card names. If you wanted to find every card with "When Played" in its text, or every card with "Restore," or every card that references "Experience token" - you couldn't. You had to know the card names.

Now you flip the toggle to "Text" and search the full card text. Want every card that gives Shield tokens? Search "Shield token." Want every Restore card? Search "Restore." It's the kind of feature that seems obvious in retrospect and makes you wonder how anyone built decks without it.

We also shipped variant dedup for text search (searching "hyperspace" no longer returns 400 duplicate variants), a search result limit cap that stays consistent across sort orders, and base card landscape orientation so the Colossus base actually displays horizontally instead of looking like someone turned it sideways.

By the numbers - the real ones this time

Forget the 48-hour numbers from Part 1. Here's just today:

  • 31 commits from 12:02 AM to 9:34 PM
  • 38 Plane tickets touched (24 created, 14 updated)
  • 22-hour session with questionable life choices
  • Full WebSocket infrastructure - Hocuspocus + Yjs from scratch
  • 9 theory board bugs found and fixed in one audit
  • 1 data corruption bug (collection ghost rows) caught and patched
  • 1 deprecated feature removed (trade binder)
  • 1 automated QA pipeline operational
  • 1 CSP change deployed and reverted in the same afternoon (the real bug was 3 characters in a settings field)
  • 0 times I closed the laptop when I said I would

Combined with yesterday: 84 commits across 2 days, 38+ tickets, and a platform that's meaningfully different than it was 48 hours ago.

What's actually next (for real this time)

Tomorrow morning, the QA robots chew through 10 open deck builder bugs. The April 12th booth event needs the kiosk queue fix (COREAPP-923). The deck browse overhaul is on deck. And somewhere in there, I need to eat a meal that isn't coffee.

But right now - right now I'm looking at a theory board with real-time cursors, a trading system with 9 fewer bugs, a collection that doesn't create ghost rows, a staff pull list that saves 30 minutes a day, and a QA pipeline that can test the entire site without a human touching a mouse.

Not bad for a Wednesday.

31 commits. 22 hours. One blog post that spawned a sequel. If you're still reading this, you're as obsessed as I am - and that's exactly the kind of player CardCore is built for.