Every week, I host a Magic: The Gathering draft night for a few friends. We play with a cube: a hand-picked collection of cards that we deal into packs, take turns choosing cards from, and use to build decks for the evening. As the person who got everyone into the hobby, I feel a certain... custodial responsibility. The draft experience must be perfect. This has led to a borderline obsessive post-draft ritual: spending hours on Scryfall, a searchable card database, hunting for new cards, and agonizing over which underperforming cards should get the axe.

The process was slow and prone to my own biases. I decided the answer was to throw an unreasonable amount of code at the problem.

What started as a simple utility script turned into weeks of performance tuning, one long argument with myself about architecture, and eventually a web application I'm genuinely proud of. The project is now live at mtg.scottliu.com/cube/recommend.

Phase 1: Putting My Preferences on the Payroll

The goal was a ranking weighted toward what I want for this particular cube, not an objective verdict on every Magic card. Three things went into it:

  1. Pick Rate (The Hype Factor): How often is this card actually picked by the global cube community?
  2. Word Count (The Simplicity Factor): Can my friends read this card without needing a PhD in rules lawyering?
  3. Price (The "Can I Remortgage My House?" Factor): Can I actually afford it?

My first instinct for getting pick-rate data was a web scraper. A look at my browser's network tab revealed a cleaner route: the JSON API the website was already using. Going straight to it meant reading structured data instead of parsing HTML that could be redesigned out from under me.

Of course, no new project is complete without a ceremonial bug. The Scryfall bulk data downloader immediately crashed with a ZeroDivisionError: the server had declined to tell me the file size, and my progress bar divided by it anyway. A quick patch later, I had a working, if clunky, Python script that could spit out a sorted list of cards to my terminal.

Phase 2: The "Black Lotus" Incident

A wall of text in a terminal is functional, but the first set of recommendations revealed a hilarious flaw. The script, with cold, robotic logic, proudly suggested I add the Power 9 to my cube. Its reasoning? They had a perfect affordability score.

Why? For cards like Black Lotus, which are traded in back alleys for small fortunes, the price field in my data was NULL. My script, bless its heart, interpreted this as "free." I had apparently found the world's most optimistic personal shopper.

NULL means the source hasn't supplied a price, not that a card is free or necessarily unaffordable. I filtered those cards out of the recommendations: without a price, I couldn't check them against my budget. That can exclude an affordable card with missing data too, but I'd rather look it up myself than have the app invent a bargain.

I put a Flask interface in front of the script and loaded the 155MB JSON file into SQLite during a bootstrap step, rather than parsing it on every request.

Phase 3: The Great Architectural Debate

The interactive web UI was working, but there was a problem. The "Adds" page, which had to filter over 30,000 cards against my long list of banned keywords, took over a second to load. For an engineer, a 1-second load time isn't a feature; it's a bug that needs to be hunted.

I wrestled with it for the better part of an afternoon.

The Just-in-Time Engine Calculate scores when a request arrives. Any adjustment to the scoring weights shows up in the next result, which is exactly what I want while tuning. The cost is doing that calculation again on the next request, even if nothing has changed.

The Pre-Computed Universe Calculate the score for every card during bootstrap, store it in SQLite, and let requests sort the stored scores (ORDER BY add_score DESC). That removes scoring from the request path. The downside? Every adjustment to the weights needs another bootstrap before those stored scores are useful again.

I wanted the first while tinkering and the second while actually using the thing. Unfortunately, I am rarely finished tinkering.

Phase 4: The Hybrid Engine

Why not have both? The app could decide which path to use by checking whether the scoring settings had changed:

  1. Formula Hashing: score_calculator.py generates a hash of its scoring knobs - the weights and thresholds I adjust.
  2. Pre-computation: The bootstrap script calculates the scores and stores that hash alongside them in the database.
  3. The Check: The app compares the current settings' hash with the stored one.
    • If they match, it uses the stored scores.
    • If they don't match, it prints a warning and switches to "Live Tuning Mode," filtering eligible cards and recalculating their scores with the current settings before sorting them.

That fingerprint covers the knobs, not arbitrary edits to the scoring code. Changing the calculation itself still needs a rebuild of the stored scores.

The precomputed "Adds" query got one further shortcut: a SQL subquery that applies the fast filters first - price, color, mana cost - and takes the top 500 cards by stored score. Only then does it apply the slower text filters to that shortlist. Query time dropped from 1,000 ms to 280 ms. That's the query, not the whole page load.

There is a trade-off in that cutoff. The page asks for 40 recommendations. If at least 40 of the shortlisted cards survive the text filters, cards below the cutoff cannot outrank them. If fewer survive, the query returns a shorter list, even if eligible cards exist below the top 500. Live Tuning Mode does not use that shortcut; it recalculates scores across the eligible pool.

It required a full rewrite of the database schema and the analysis logic. Worth it. Normal requests use the stored scores and the shortlist; my local copy lets me adjust the weights and see the result without rebuilding the database after every change.

The Final Product

A weekend hack to improve my draft nights turned into a real web application, which is roughly the ratio of effort to necessity I aim for in a hobby.

The part I'd actually take to work with me is the Black Lotus bug. "I don't know the price" and "the price is zero" are very different answers, and my code had cheerfully given them the same score. I go looking for that shape of mistake everywhere now.

And if you're in my playgroup... be ready. The optimization never stops.