Gorilla Draft: Fantasy Leagues That Draft Teams, Not Players
A live fantasy platform where managers draft whole clubs across ten team sports, built around a published scoring card that prices a baseball win and an NFL win onto the same 1000 point season, and shipped from one React bundle to both the website and an iOS build.
Gorilla Draft is a fantasy league where managers draft clubs rather than athletes. You take the Yankees, not Aaron Judge. Wins turn into points, and ten team sports are scored on the same sheet: EPL, MLB, MLS, NBA, college football, college basketball, NFL, NHL, the Champions League and the WNBA.
That one design choice creates the problem the whole system is built around. A baseball team plays 162 games and a college football team plays 12. If a win is a win, whoever drafts baseball wins the league before anything interesting happens. Most of the engineering here is the answer to that.
Two things belong up front. The site is live and private, so the link above reaches a signup wall rather than a product tour. And the code was written with Claude Code, with me directing it: I set the specs, argued the scoring design, decided what got measured, and rejected the versions that were wrong. The design documents and the code comments are where that argument is recorded, and they are the part of this project I would point a reader at first.
A win in one sport has to be worth a season in another
The rule the platform settled on is one published constant per sport. Each sport declares what one unit of credit is worth, and that number is set so the season the sport’s best team is projected to have comes out at 1000 points.
Written down it is X = 1000 / D, where D is the projected season of the best team in that sport. College football’s best team is expected to win about 11.4 games, so a college football win pays 87.72. Baseball’s best team is expected to win about 97.7, so a baseball win pays 10.24. The prices differ by 16x and the seasons they add up to do not differ at all.

Left, what one win pays. Right, what that sport’s benchmark season pays. The column of identical products is the whole point of the card.
One thing has to be said before anything else about it. This rule is built, tested and documented, and it is not what live leagues are credited on today. The engine reads the published card only when a league’s stored scoring envelope carries a rateCard block, and nothing in the production code writes that block yet. Leagues are still credited by the older per-round rule the card was designed to replace. The two agree on the only number either of them promises, that a full title run is worth 500 points in every sport, and they differ on how that 500 splits across the rounds. The scoring page prints the new split while the engine credits the old one. The code names that gap in a comment headed “which ladder is actually credited today, said plainly, because the answer is not yet one ladder” rather than hiding it.
With that said, three things about the rule matter more than the arithmetic.
It is published, not computed at runtime. The rates live in data/scoring_rates.json and are reached through a single module that is the only place the engine may learn what a win is worth. A rate is a decision a person can read and diff, not a number that appears somewhere in a query.
It is an expectation, and beating it pays more. 1000 is what the best team is expected to earn, not a cap. An NFL team that wins 14 games banks 1176. The overshoot is deliberate, because pricing every sport against its own expectation lets every sport beat its own expectation by a similar margin.
The obvious simpler rule was measured and rejected. Setting a win at 1000 / games played needs no projection and is easier to explain, so it was tested rather than argued about. Over three real seasons its cross-sport deviation was 0.382 against 0.260 for the card. The reason is a fact about sport rather than about arithmetic: the share of a season a title-calibre team wins is not the same everywhere. Under that rule the best college basketball team banks 1329 and the best baseball team banks 771 for the same achievement, which is winning their sport.
Joining in July, and still playing for 1000
The harder version of the same problem is time. Leagues get created in the middle of a season, and a manager who joins in July with two months of baseball left should not be competing for a fraction of the prize an opening-day league is playing for.
The mechanism is a per-league rescale called rho. It is set from the credit the league’s own window is expected to hold, so a league that owns a short tail of the season has its regular-season points multiplied until that window is worth the full 1000 again. On 2026-07-28, with 62 of 221 days of MLB regular season left, rho came out at 2.84 and a baseball win went from 10.24 points to 29.08.

The rescale applies to the regular season and nothing else.
rho is resolved per league and per competition and frozen into that league’s scoring envelope, so a league that has already drafted is never repriced by a later change. The constraint that makes the whole thing hold is that it multiplies the regular season and never the postseason ladder. A champion banks 500 whatever date the league was created on, because the bracket is a fact about the competition and the window is a fact about the league.
That separation caused a real bug worth keeping on the page. The standings priced a win at the league’s own rescaled rate while the recap screen priced the same win at the catalog base rate with no rho at all, so on a sport in mid season the recap understated every result by the whole factor. It was found by auditing the two paths against each other rather than by anyone noticing a wrong number.
The scoring module that computes all of this reads no clock and writes to no table. The current day is passed in as an argument, which is what makes “what will this league be worth next week” a test that moves a date rather than a test that waits a week.
One bundle, two products
The website and the iOS build are the same React build. The app is a real bundle that talks to gorilladraft.com rather than a browser pointed at a website, which is the distinction App Store review guideline 4.2 turns on.

Three screens from the iOS build. Everything visible is served from the same bundle that serves the website.
The draft is server-owned. The clock, the turn order and the autopicker live on the server, so a manager who closes a laptop mid-draft does not stall the room. Live updates travel over server-sent events rather than websockets, because the traffic is entirely server to client and plain HTTP survives a deploy with a reconnect the browser handles by itself. That choice cost one piece of work: the browser’s EventSource API cannot set a request header, so authenticating a stream needed a short-lived ticket rather than the bearer token every other route uses.
Underneath it is deliberately small. The whole server imports four third-party packages, and it stores everything in SQLite through the driver now built into Node itself. It runs single-node behind a Cloudflare tunnel, and the realtime layer is in-process memory, so nothing here is a horizontal-scale story.
The stylesheet that is not allowed to touch the website
The part I would show a reviewer first is how the app gets restyled.
There is an iOS-only stylesheet of about 262KB across twelve files, roughly half of it commentary, which adds around 43KB of CSS to the shipped app bundle. Nothing imports it. A normal import would put those bytes in the web bundle, change the emitted CSS file and change its hash, and the standing rule for the workstream is that vite build keeps emitting the website it emitted before, byte for byte. So a Vite plugin appends the import to the entry file during the build, and that plugin is in the plugin array only when the build mode is iOS. In any other build the stylesheet is not in the module graph at all.
It wins the cascade without a single !important. The website’s own styles sit in @layer base and @layer components, and unlayered CSS beats layered CSS regardless of specificity, so a plain selector in the iOS sheet overrides the same selector on the website.
It is not purely a stylesheet, and the honest version matters. Shared source files carry iOS-only branches, and the tab bar, the More sheet and the league navigation are iOS-only React components behind the same build constant. The losing branch folds away at compile time, so the claim is about emitted bytes rather than about untouched source. The scope rule is that the palette and the mascot stay identical to the website, so every colour comes from a custom property the site already defines rather than a hex written in the app sheet, and nothing may hide a route or make a control unreachable. The layer does carry a dozen display: none rules, all of them on duplicated chrome rather than on a feature.
There is a test file whose whole job is keeping the app build from damaging the website, and its reason for existing is an AdSense tag. Inside an application that tag breaks AdSense’s own program policy, and enforcement there is at the account level, so a bad app build puts the live website’s revenue at risk. It also turns the App Privacy answer into tracking and makes an App Tracking Transparency prompt mandatory under review guideline 5.1.2(i). One script tag, three separate ways to lose. Every assertion that the app must not contain something has a sibling assertion that the website must still contain it, because the cheapest wrong fix is to delete the thing from both.
The wider byte-for-byte check is a manifest script rather than a test. It works, and it caught regressions while the work was live, but it is run by hand and its recorded baseline is older than several intentional website changes, so it does not currently pass. There is no CI anywhere in this project. The suite and the copy linter are run by hand.
Built for a platform the machine could not run
The native iOS project is not in the repository and cannot be. Adding it runs CocoaPods, which is macOS only, and the tool refuses to run at all when the directory already exists. What is committed instead is an overlay of hand-written native files and a bootstrap script that copies them over the project the tool generates.
All of it was written on a machine with no Xcode, no simulator and no device. The bootstrap validates what it can without any of those: every property list key asserted, every image measured against its required pixel size, and any icon carrying an alpha channel rejected because App Store Connect rejects it too. The overlay’s own README says where that ends. Nothing in it has been compiled, and Interface Builder gets the final word.
What I am not claiming
The test suite is 142 files and 2,070 tests, of which 2,057 pass. Two fail and three are cancelled. Several tests reach real third-party feeds, so the suite is not hermetic and the failures move with the calendar. A large share of it asserts on source text rather than runtime behaviour, which is a deliberate technique for catching the correct-but-uncalled-function failure mode, and it means the file count is not 142 files of pure behavioural coverage.
The scoring rule is only as good as the preseason projection it reads. If the surface is wrong about a sport’s best team by 15 percent, every team in that sport is mispriced by 15 percent for the season and nothing notices. That is structural, since the rule has to be publishable before the season starts. The projections themselves are a labelled hybrid rather than pure market prices, and several drafted competitions have no market coverage at all.
Every data source is an undocumented public endpoint rather than a licensed feed. The code is honest about it, with disk caching, staleness flags and a rule that refuses to write anything when a source returns a stale season, but it can break without notice.
The ladder has a case I would not enjoy explaining to a customer. A WNBA team that loses its first playoff series banks 62.5 points and a college basketball team that wins two tournament games banks 31.25, because the WNBA championship field is 8 and the college field is 68. It falls directly out of the two decisions the ladder is built on.
Nobody who wrote the legal documents is a lawyer, no external security audit or penetration test has happened, and every operational number in the code comments comes from a production database that is not in the repository. The design document these figures come from labels each one as measured, published, or assumption, and names the file it came off. That convention is the part of this project I expect to keep using.