The project
Wargaming has been a passion of mine for a few years now, especially 40k. When 11th edition came out the official app stopped supporting 10th, so I built my own list builder to keep playing it. dilectus.app covers every army in both 10th and 11th edition. You pick a faction and a detachment, add units and choose their wargear, attach Leaders to their bodyguards and put units in Transports, and the list is checked against the rules as you go. Lists save in the browser and sync across devices once you sign in. There's also a battle mode for the table, which tracks wounds and Command Points round by round, and two players can link their battles with a 4-digit code. All the game data comes from the BSData community project and Wahapedia, checked for updates every day. The one rule for the whole thing was that it had to run on free tiers, and that ended up shaping a ton of the design.
How it fits together
- The rules engine evaluates BSData's own data model directly, the constraints, modifiers and conditions that BattleScribe and New Recruit use. Every list-building rule is data, so there's no Orks code or Knights code anywhere, and adding an army means importing its catalogue. The same engine runs in the browser for instant feedback and on the server behind the API.
- A daily sync on GitHub Actions checks each source's latest version with one small request and only downloads and rebuilds when something has moved. A full build needs about 2 GB of memory, more than a Vercel function gets, so it runs on an Actions runner with 7 GB to spare.
- Every build goes through a rule gate first. Anything in the raw data the engine doesn't support blocks the import, because otherwise an unknown condition would be read as false and lists would be judged wrong without anyone noticing. A failed sync leaves the current data live and opens a GitHub issue.
- Neon's free tier is 0.5 GB, so the database keeps only the active import for each edition. Each import records exactly what it was built from (the BSData commit, Wahapedia's export date, a hash of my own rule fixes), so any older one can be rebuilt if it's ever needed.
- Lists are local-first in IndexedDB and work signed out. Signing in syncs them to the account, last change wins per list, with Better Auth storing users in the same Postgres to avoid a hosted auth provider's per-user pricing.
- Battle mode reads the list live and runs each unit's stats through the rules engine, so a Leader's buffs or an Enhancement show up on the card they affect. Two players each keep their own battle, and a match record links them for the shared round and victory points.
The trade-offs that mattered
Peer to peer instead of WebSockets
Vercel functions are stateless and can't hold a connection open. Vercel's WebSockets beta ties each connection to one instance, so it would still need a pub/sub service on top, and Ably, Pusher and Supabase Realtime all meant another vendor and another account. The devices in a battle are usually at the same table anyway. They connect directly over WebRTC data channels, and the API only introduces them. Cloudflare's TURN relay covers phones on mobile data, with the first 1,000 GB a month free.
Only going live with a second device
The first version polled whenever a battle was open, around 720 requests an hour per battle, and it kept the database awake. Most people use one device at a time. Now a list only joins a live room once another of the account's devices has it open, so a player on one device makes no background requests at all.
BSData as the main data source
I started on Wahapedia's CSV export and hand-wrote the list-building rules for Orks. A day in I threw that away. BSData already encodes units, wargear, points and every list-building rule as structured data, it's what the other tools in this space run on, and hand-written rules were never going to stretch to every army.
Keeping CI inside 2,000 minutes
A private repo gets 2,000 Actions minutes a month, shared with the daily sync. Unit tests run on every PR, but the Playwright suite only runs when a PR has an e2e label. It loads a small committed set of game data (Orks and Thousand Sons) where it used to run the full import, which had been taking about 3 minutes of every run.
What I took from it
Running on free tiers was the goal from the start, and it made more of the design decisions than anything else did. Neon's 0.5 GB is why only one import per edition is kept, with every import recording its source versions so an older one can be rebuilt. The memory limit on Vercel functions is why the data sync runs on a GitHub Actions runner. Stateless functions are why battles go peer to peer over WebRTC, and the 2,000 Actions minutes are why the browser tests are opt-in and run on a small set of fixture data. The result is an app covering every army in two editions, synced daily, with accounts and live two-player battles, that costs nothing to host.