Prices were already in the Scryfall bulk download, so this adds no dependency and no new fetch: the work is deciding what a card's price means, given that the index keeps one row per card and a card has many printings. It is the median of the ordinary printings. The kept printing underprices about a third of the legal pool because it is chosen for art and identity and is usually a cheap reprint; the maximum is worse, since premium and bundled listings put a $870 Sol Ring in the data. The median lands where a player recognises the number and ignores the outliers. It is a market value, not a build cost. Two filter decisions are deliberate and easy to undo by accident: Reserved List printings are included, because that is where Commander's banned Power 9 live and excluding them priced Ancestral Recall and Time Walk at nothing, while memorabilia is excluded because a Collectors' Edition box is not a single card. Banned cards fall back to their banned printings. deckValue counts unpriced cards instead of treating them as free, so a total is always labelled with what it left out. Also implements scripts/verify-cards.mjs. package.json and the index skill both documented `pnpm cards:verify` but the script did not exist; it now runs in deploy and fails on a card priced at exactly zero, which is the failure mode the missing-price guard exists to prevent. Co-Authored-By: Claude Code <noreply@anthropic.com>
MAGIK
A Magic: The Gathering Commander (EDH) deck builder. Pick a Commander, build the 100-card list, and get legality checked live as you type — against real Scryfall data, with no account and no server-side state.
Live at https://magic.marcospaulo.dev.br
What it does
- Deck builder — search Commander-legal cards, add them, watch the validator.
- Legality engine — 100 cards, one Commander or a legal Partner pair, colour identity contained in the Commander, singleton except basic lands, no banned cards.
- Share links — a deck is encoded into its own URL. There is no save button and no account; a deck is a link.
- Browse / Analyze — browse the legal pool, and break a list down by mana curve, colour identity and type line.
- Batch import — a folder of
.txtdecklists, several lists pasted together, or the JSON backup Settings exports. Unresolved names are reported by name rather than dropped. - Play — a board for the phone lying face-up during a game. One card per player holding only what is used mid-turn: a name, a life total, a minus and a plus in opposite corners, and the counters on that player. All four seats fit one screen with no scrolling. Commander damage, the ±5 and ±10 steps, adding a counter, renaming and turn order are one tap away in a sheet, because they are used between turns rather than during them. The state survives a reload, the screen is kept awake, and it runs full screen.
- Card art — hot-linked from Scryfall's CDN.
- Market prices — a USD value per card and a total for the deck, so a list can be costed before it is built.
- Usable on a phone — one column in the order a player reads it, touch targets sized for a thumb, modals as bottom sheets, and a card detail that opens rather than being hidden.
Architecture
Three pieces, and the boundaries between them are the design.
Scryfall bulk download (build time, host only)
default_cards.json ──► scripts/build-cards.mjs ──► data/cards.db
│
│ baked into image
▼
┌──────────────────────── one container, one process ────────────────────────┐
│ server/index.mjs — node:http │
│ ├── /api/* read-only queries over data/cards.db (node:sqlite) │
│ └── /* static files from dist/, SPA shell fallback │
│ dist/ React 19 SPA, built by Vite │
└──────────────────────────────────────────────────────────────────────────┘
│
K3s: ClusterIP → Traefik
│
Caddy on Oracle VPS, over Tailscale
The decisions behind it
One process, one container, no ORM. node:http and node:sqlite are
built into Node 26. There is no Express, no Prisma, no native module to compile
— the API image needs no build toolchain at all.
The card database is a build artifact, not a live dependency. Commander
filtering is a one-off question about a static dataset, so it is answered once,
ahead of time, into SQLite. The runtime never talks to Scryfall: no rate limits,
no CORS, no upstream outage. The trade is that the index goes stale when Wizards
publishes a Banned & Restricted List change; pnpm cards:build rebuilds it and
that is the whole refresh story. See .claude/skills/commander-card-index.
No user state, therefore no PVC. Decks live in the browser and travel in the
share URL. The service holds nothing, so a new release replaces the old one
outright with nothing to migrate or restore. That is also why replicas: 1 is
correct rather than a scaling compromise.
Rules come from data, not from a hardcoded list. Ban status and per-card colour identity are read from Scryfall, so the Commander Committee changing the ban list is a data refresh, not a code change.
A card's price is a median, and the reason is not obvious. The index keeps one row per card, but a card has many printings at very different prices, so the price has to be aggregated. Neither obvious choice works: the printing the row keeps is chosen for identity and art, so it is usually a cheap reprint and underprices about a third of the legal pool; and the maximum is far worse, since premium and bundled listings put a $870 Sol Ring in the data. The median of the ordinary printings lands where a player recognises the number — Forest $0.29, Sol Ring $2.17, Mystical Tutor $19.07 — and is unmoved by those outliers.
It is a market value, not a build cost: the cheapest printing is well below
it, and the index does not answer that question. Cards with no open-market
price (Un-cards) and sealed product (Collectors' Edition boxes) are stored as
null and rendered as "no price", never as $0.00. pnpm cards:verify fails
the build if a card is priced at exactly zero.
API
All endpoints are read-only GET.
| Endpoint | Purpose |
|---|---|
/api/health |
liveness + card count |
/api/search |
filter the legal pool (name, color, type, cmc, commander, …) |
/api/commanders |
search restricted to Commander-eligible cards |
/api/cards?id=… |
batch fetch, repeated param, max 400 |
/api/cards/:uuid |
single card |
/api/lookup?name=… |
resolve by name, repeated param |
Layout
server/ node:http API + static serving; db.mjs is the card-index reader
src/lib/ commander.js is the rules engine; deckStore.js owns decks;
table.js is the game-in-progress reducer
src/components/ card browser, detail modal, commander picker, validator, import
src/routes/ Decks, DeckBuilder, Browse, Analyze, Table, Settings
scripts/ build-cards.mjs, deploy.sh
deploy/k8s/ Namespace + Deployment + Service
templates/ project scaffolding
.claude/skills/ project-specific agent skills (see below)
commander.js, deckStore.js and table.js are plain JavaScript on purpose —
node --test exercises them directly, and the UI imports the same file. There
is no second copy of the rules, and the life total on the table is asserted by
the same reducer that draws it.
Running it
pnpm install
pnpm cards:build # downloads Scryfall bulk, builds data/cards.db (~1 min)
pnpm dev # vite on :5173, api on :8099
pnpm test # 71 tests: rules engine, deck/store handling, import, table, prices
pnpm typecheck
pnpm cards:verify # card-index invariants, including a $0.00 price check
pnpm build
pnpm api # serve the built SPA + API on one port
Deploy to the homelab cluster with scripts/deploy.sh (see
.claude/skills/homelab-web-deploy).
Project skills
.claude/skills/ holds the project-specific knowledge that would otherwise live
only in a chat log. They are the reusable part of building this thing:
| Skill | Owns |
|---|---|
commander-card-index |
building, verifying and repairing data/cards.db; picking up B&R List changes |
commander-deck-rules |
the legality engine in commander.js and deck/share handling in deckStore.js |
homelab-web-deploy |
the Dockerfile / manifest / deploy-script pattern for a stateless web service on the K3s homelab |
Status and history
PROGRESS.md is the working log: the stack decision with its rationale, the
design direction, the task list, and the deploy history. Read it if you want the
why rather than the what.