Back to projects

Software · In progress

Wheelhouse

A fantasy football game where a wheel deals you the players and you only choose which stat to take from each. Built on a live ingestion pipeline that replays any week of the last five seasons.

Year
2026
Role
Solo build
Stack
Java 21, Spring Boot 4, JPA, H2, Jackson 3, Maven, ESPN API, Sleeper API

What it does

You do not pick your players. A wheel picks a team, then one of that team’s players, and hands him to you. The only thing you choose is which single stat to take from him, and each stat can be used once per position. Fourteen picks later you have a quarterback, a running back and two receivers assembled out of fourteen different men, and the week resolves at once against what actually happened.

The whole draft is blind. You see every player’s projection and his last six games, and you never see this week’s result until the roster is full. That is enforced on the server rather than hidden by the page: while a roster is being built the API response contains no actual values, no actual points, and no total key at all. A blind draft that ships the answers in the same payload is theatre.

The part I would defend in an interview

A raw score mostly measures the wheel. Somebody dealt Lamar Jackson beats somebody dealt a backup every time, and telling them so is not feedback.

So a finished roster reports something else: hold the exact players you were dealt, and compute the best possible assignment of stats to them. Your score over that ceiling is your decisions with the luck divided out, because the ceiling moves with your draw. Each position is a perfect matching over at most four elements, so brute forcing twenty-four permutations is cheaper than reaching for an algorithm.

The number it reports is the ceiling. The arrangement that reaches it is deliberately withheld, because a week can be replayed and printing the answer on the way out would replace the second run with a copying exercise.

How the data works

Two feeds that agree on nothing.

ESPN publishes box scores but no forecasts. Sleeper publishes forecasts but keys its players differently, and its espn_id field is null for every player whose rookie year is 2021 or later, which is precisely the players filling modern box scores. So the crosswalk is learned from the box scores themselves: every athlete who plays arrives with an id, a name and a team, which is enough to match on a normalised name. It only has to resolve players who actually played, which is exactly the set that can have scored anything.

Ingestion polls and diffs rather than streaming events. ESPN serves cumulative totals, so the unit is a snapshot and the deltas are derived by comparing consecutive readings. That is the better shape for reliability: a missed poll is caught up by the next one, a duplicate poll produces an empty diff, and idempotency falls out of the data shape instead of being something the code has to enforce.

The same pipeline pointed at a date is what makes the archive work. A 2021 box score has the same shape as a live one, so replaying any of ninety past weeks needed no new parsing at all. An archived week builds its own player pool out of that week’s box scores, because Sleeper’s roster is the league as it exists today and is useless for 2021 — and you should only be able to draft someone who was on a field that Sunday.

Two bugs worth writing down

Every player who ever changed clubs silently projected zero. Archived players are minted per week carrying that week’s team, but the catalog stored them under the athlete id with putIfAbsent, so the first week to register a player owned him forever. Justin Fields, read from a 2023 box score, stayed a Bear through his 2025 Jets games. Projections join archived players on name plus team, so the wrong team meant a missed join and a zero. It only surfaces after playing several weeks across different seasons, it presents as a cosmetic zero rather than a crash, and the fix is scoping the synthetic id to the contest. Eleven of fourteen picks resolved before, fourteen of fourteen after.

A display name was effectively a password. Every pick endpoint accepts an entry id on its own, which is fine for a link you send a friend and only fine while the id stays secret. It was not secret by two separate routes: a lookup by display name returned the ids belonging to it, and the leaderboard carried one on every row. Names are printed on the leaderboard. Reading one off the board was enough to fetch a stranger’s in-progress roster and spend their respins, which I confirmed by doing it before fixing it.

Scoring, and why it is not fitted

Every multiplier is standard PPR: a touchdown is six, a hundred yards is ten, a reception is one. Fitted weights balanced the choice far better and produced numbers nobody could sanity check — receiving touchdowns came out at 31.5 — and a score you cannot read is worse than one that is imperfectly balanced.

That rule cost three parts. Completions, carries and targets are all real and all predictive, and no fantasy league pays for any of them, so their weights had to be invented rather than inherited. They were removed rather than defended.

Where it is

Playable locally and not yet deployed. The game, the ingestion pipeline, the archive replayer and the scoring are done; hosting, accounts and a public URL are not.

There is a separate pricing workbench that stays out of the public repository, because it is analysis rather than part of the game: a Gaussian copula Monte Carlo that prices an assembled roster as a correlated parlay. Its one useful result so far is that projections clear their own number about half the time in aggregate and nothing like it per stat, which is the difference between a coin-flip product and a mispriced one.