The problem
My 12-team fantasy football league drafts in two phases: a sealed-bid auction for the first eight roster spots, then a snake draft for the rest. On a whiteboard that takes 6–8 hours. Every bid has to be collected privately, compared, and announced; ties need re-bids; budgets, position limits and teams that run out of money all need tracking by hand.
Off-the-shelf draft apps don’t support this format, so the goal was software that runs the league’s exact rules and gets the draft down to about 3 hours, without changing how the league plays.
My role
I built it solo: the product spec (every rule and edge case written down first), a pure rules engine, the real-time server and database, and the web app for phones, laptops and a TV big board, plus a commissioner console for pausing, undoing and fixing mistakes during the draft.
Approach
Every manager nominates, bids and picks from their own device. When the bid clock runs out, sealed bids flip on every screen at the same moment, and the app handles ties, budgets, position limits, broke teams and the switch to the snake on its own.
- A pure rules engine. Every rule is a pure function,
reduce(state, action, ctx) → { state, events }. The engine never reads the clock, uses randomness or does I/O; time and a random source are passed in. That makes every rule unit-testable and every draft replayable from its action log. The server, the web app’s greyed-out choices and the settings form all reuse the engine’s own checks, so the rules exist in one place. - One authoritative server. Clients only send intents (“I bid $47”). The server checks who’s asking, runs the intent through the engine under a per-draft lock, saves the result, then broadcasts it. Each intent is stamped with the time it arrived, so a bid sent just before the buzzer counts even if it’s processed a moment later.
- Bids stay secret until the reveal. Bid amounts are removed at the boundary where the server builds what clients receive. Before a reveal, everyone sees only “team X is in”. Afterwards, only the amounts the reveal showed are ever sent. Tests inspect every outgoing message for leaks.
- Nothing is lost on a restart. Every action is saved before it’s broadcast, and clocks are stored as end times and re-armed when the server starts.

Key decisions
- A pure engine, with time and randomness passed in
Every rule can be tested in isolation and a whole draft can be replayed from its log. The cost: more plumbing, since anything that depends on the clock or a coin flip has to go through the engine’s inputs.
- The server decides everything
Phones’ clocks and network delays can’t change an outcome, and nobody can act for another team. The trade-off is that every action needs a round trip, so server latency matters.
- Clients reload the full snapshot after every event
Instead of each client applying changes itself, it asks for a fresh snapshot, so the server stays the single source of truth and clients can’t drift. It sends more data per event, which is fine for 12 teams and a TV.
- Save each action as one SQL statement
Each action’s changes travel as a single JSON parameter applied with data-modifying CTEs: atomic without a separate transaction, and only two database round trips. That keeps a single action at about 60 ms against a remote database.
Results
The rules engine, server and every screen were built in three days, with 350 automated tests: 246 for the engine (including a full scripted 12-team draft), 51 server integration tests (including “restart mid-lot loses nothing”) and 53 for the web app. Two full-length 12-team scripted mock drafts and a manual mock draft with a real NFL player pool all finished cleanly, and the league’s walkthrough came back with no notes.
What’s next: hosting, a real mock draft with the league on their phones, and import and export with MyFantasyLeague. One known gap: when all 12 teams bid at the same instant, the last acknowledgement takes about 0.7 seconds against the remote development database, above the 300 ms target. A database hosted next to the server should close that gap.
Stack & links
- --typescript
- --react
- --node
- --fastify
- --socket.io
- --postgres
