Methodology
Every number on this page is read live from the same exported JSON each feature's own view is built from — nothing here is hand-typed separately from the pipeline that produced it. Full narrative decision log: `docs/decisions.md` in the repo.
Data through 2026-04-12 (2015-16 through 2025-26 regular seasons).
Architecture
All heavy compute — fetching, feature engineering, model training — runs offline on the author's own machine. The deployed site is precomputed static files plus one thin Cloudflare Worker that only handles /api/ask. Data never goes into git; `make deploy` copies the latest exports into the static assets folder and runs `wrangler deploy` directly from the author's machine.
Data sources and season coverage
Every table is fetched from stats.nba.com via the `nba_api` Python library, throttled to about one request per second and fully cached so a run can be interrupted and resumed. Box scores, season totals, and shot charts cover 2015-16 through 2025-26. Play-by-play (used for game flow) covers the three most recent completed seasons: 2023-24 through 2025-26.
- 10 games (0.08% of 13,199) are missing from the canonical games table: for these specific games, `LeagueGameLog` reports both teams as playing away, so there's no reliable way to recover which team was actually home. Excluded rather than guessed.
- Play-by-play events are occasionally logged out of true chronological order (duplicate or retroactively-corrected rows, ~1.25% of all events) — detected and dropped by a monotonicity check in the game flow parser rather than trusted at face value.
Features, models, and evals
One section per view: what it models, which approach shipped and why, and the real evaluation numbers behind it.
Player style map
Every player-season (2015-16 onward) placed on a 2D map by playing style — shot diet, role, rebounding and defense, self-creation, ball handling, shooting mode, touch location, and play types — via PCA for the underlying vector space, a Gaussian mixture for archetypes, and UMAP purely for 2D layout. Nearest-neighbor comps run as a brute-force cosine search entirely in the browser.
Loading eval data…
Limitation: the 30% hand-checked comp sanity rate reads low in isolation — it's measuring exact agreement with one author's subjective sense of a “good comp,” a much stricter bar than the map being generally sensible (most near-misses, like Curry↔Lillard or Giannis↔LeBron, are defensible archetype neighbors, just not the single comp the author had in mind).
Ask the box score
Plain-English questions are translated to a single validated SQL query by Cloudflare Workers AI (llama-3.3-70b-instruct, with 8B/3B fallbacks if the primary model is ever retired), then executed against real Parquet tables entirely in the browser via DuckDB-WASM. The same SQL guard (allowlisted tables, single SELECT/WITH statement, no file or network access, forced row limit) runs on both the Worker before caching a query and the browser before executing it.
Not yet measured: golden-set execution accuracy (`evals/ask/golden.jsonl`, ~60 questions across easy/medium/hard) and Worker CPU time per request. Both are flagged open in `docs/decisions.md` rather than reported with a made-up number — the model choice above is based on manual spot checks, not the full eval, and may change once that eval exists.
Shot quality court
An expected field-goal probability (xFG) model — what an average player would shoot from a given location, situation, and shot type — trained on every field goal attempt since 2015-16. A LightGBM classifier was compared against a logistic-regression baseline with distance/angle splines; the model isn't shipped directly, only a precomputed lookup grid the browser reads at 1-foot resolution per action family.
Limitation: no defender-distance data exists in public shot logs, so the model can't separate a contested layup from a wide-open one — layupis the one action family with a materially wider calibration gap than the rest.
Game flow
Win probability through every game since 2023-24, from play-by-play events plus a FiveThirtyEight-style Elo rating (K=20, +75 home-court points, 30% regression toward the mean each season) computed from the full 2015-16 onward history. No betting-line data anywhere in the pipeline.
Limitation: Elo is built from final scores only — no injuries, rest, or in-season roster changes. Possession is derived from event sequences, not exposed directly by the API, so it can occasionally misfire on unusual sequences (technical fouls, replay reviews).
How this stays free
The whole site runs on Cloudflare's free plan. These are the budgets that shaped nearly every low-compute design choice above — precomputed grids instead of shipping models, caching before calling an LLM, static assets over server rendering.
| Item | Budget |
|---|---|
| Landing page JavaScript | Under 150 KB gzipped |
| Style map data file | Under 2 MB gzipped |
| Shot court: one player shard | Under 300 KB gzipped; xFG grid under 100 KB |
| Game flow: one monthly bundle | Under 1 MB gzipped |
| Worker CPU per request | Under 5 ms |
| Workers AI | Under 10,000 neurons/day, including evals |
| KV writes | Under 500/day |
| Nightly refresh | Under 15 minutes |
Versions
- Ask the box score schema version: 1
- Data through: 2026-04-12
- Shot quality model: loading…, validated on …
- Game flow model: loading…, validated on …
Legal and brand hygiene
Noncommercial portfolio project, not affiliated with or endorsed by the NBA. Data sourced from stats.nba.com. No team or league logos are used.