Back to dashboard Tennis Twin

Tennis Twin โ€” Build Log & Technical Overview

Prepared for the USTA Innovation Challenge review team.

Tennis Twin is an AI-assisted player-development platform built around one idea: one data spine for the whole player journey โ€” playground to podium. It finds each player's closest competitive "twins," benchmarks them against the players one level above, surfaces rising talent, maps court-access equity against the mission of 35 million players by 2035, and โ€” at the elite end โ€” turns US Open Hawk-Eye tracking into shot-level intelligence. All from the USTA datasets provided for the challenge.

The product is organized as four connected pillars:

  1. Find your twin โ€” explainable similarity matching.
  2. See your gap โ€” peer-benchmarked path to the next level.
  3. Spot rising talent โ€” a junior talent radar plus "hidden gems" (fast risers in the most court-constrained states).
  4. Pro Shot IQ โ€” serve maps, court coverage, pressure performance and next-gen stats from Hawk-Eye, alongside a full rally replay.

The narrative and business framing live on the in-app Vision page; this document walks through every step I took, from first opening the raw data to a working prototype, including the decisions (and honest trade-offs) I made along the way.


1. Starting point

I began with the original Tennis Twin proposal: a "player twin" for every athlete, similarity models, progression prediction, personalized development roadmaps, talent identification, and an LLM narrative layer.

Before writing any code, I did a ground-truth assessment of the five datasets USTA provided, because a concept is only as good as the data behind it.


2. Data assessment (what I actually have)

All five files are CSV and share a common person_key join key.

DatasetRowsUnique peopleNature
Junior standing rankings7,368,53788,694 juniorsWeekly ranking snapshots, all of 2025
League / tournament matches6,849,077464,144 playersMatch-level, all 2025
NTRP / ITF / WTN ratings1,045,0741,045,071Single snapshot per person
Person (masked)1,054,7471,054,747Demographics: birth year, gender, section/district/state
Facilities & courts47,756โ€”Geo + court surfaces + amenities

Key findings that shaped the build

  1. Ratings are a single snapshot per person, not a time series. 1,045,074 rows for 1,045,071 unique people. ~71% (744,955) have no NTRP level at all. There is no rating history โ€” I cannot watch a player go 3.5 โ†’ 4.0 over time.
  2. WTN has far better coverage than NTRP. 800,376 people have a WTN singles level vs ~300,000 with NTRP. WTN is also continuous (1โ€“40, lower = stronger) and ships with a confidence score and last-played date. I made WTN the primary rating and NTRP a secondary label.
  3. Match history is 2025 only. One season, 464k players. Rich intra-season signal, but no multi-year arc.
  4. Junior rankings are weekly but only within 2025 (~50 snapshots). Great for intra-year momentum; not a 12-year-old growing into a college recruit.
  5. There is no "success" label โ€” nothing indicates who reached college, pro, or even who advanced a level.
  6. No Hawk-Eye or US Open match tracking is present in the five core files. (USTA later provided a separate single-match Hawk-Eye tracking dataset, which I built out as the Match Lab module โ€” see ยง8.)

NTRP distribution (ratings file)

NTRPPlayers
(none)744,955
3.597,804
3.085,623
4.060,174
2.529,404
4.521,452
5.04,639
5.5+~1,000

3. The pivot decision

The original proposal leans on a longitudinal spine: "learn from players who followed the path to success," "predict future rating growth," "juniors who ultimately reached college/pro." That framing needs multi-year per-player trajectories and an outcome label. The data has neither.

Rather than fabricate forecasts a technical judge could catch, I pivoted from time-travel to peer comparison โ€” keeping the Tennis Twin name, dashboard, and spirit, but grounding every insight in defensible, cross-sectional data:

Proposal (as written)What I built (honest, data-backed)
Predict future rating growthGap-to-next-level: how you differ from the population one level above you
Twins who "followed the path to success"Twins by current ability/profile (cross-sectional similarity)
Multi-year junior โ†’ pro trajectoriesWithin-2025 momentum / talent radar (fastest risers, top-ranked juniors)
LLM coaching narrativeRule-based insight engine behind a clean LLM-swappable seam

Framing I use with reviewers: "personalized, peer-benchmarked development guidance," not "predicts your future rating." True longitudinal forecasting is noted as future work that unlocks with historical data.


4. Architecture

The raw CSVs total ~4 GB โ€” far too large to touch live in a browser/Next.js app. So the system has two clean stages:

 dataSourceFiles/*.csv  (~4 GB, git-ignored)
        โ”‚
        โ–ผ   pipeline/run.sh  (DuckDB, offline, ~7 seconds)
 data/derived/*.parquet + tennis.duckdb  (~42 MB, compact feature tables)
        โ”‚
        โ–ผ   Next.js Route Handlers  (@duckdb/node-api, read-only)
 Tennis Twin dashboard  (React / Tailwind)

Tooling: DuckDB 1.5.4 (CLI + @duckdb/node-api 1.5.4), Next.js 16.2.4 (App Router, Turbopack), React 19, Tailwind 4.

I chose DuckDB because it reads the multi-GB CSVs directly, does the heavy feature engineering in SQL in seconds, and the same engine serves the app.


5. The offline pipeline (pipeline/build.sql)

Reads the raw CSVs as views (never copies 4 GB into the DB) and materializes small derived tables. Feature engineering includes:

Outputs (in data/derived/, all git-ignored):

FileContents
players.parquet (~32 MB)1 row per player, full feature set
junior_trends.parquetPer-junior ranking trajectory
level_benchmarks_ntrp/wtn.parquetPeer-group metric distributions
facilities.parquet, facility_access_by_section.parquetCourt/geo data
summary.jsonDashboard headline stats
tennis.duckdbFull queryable DB used by the app

Runtime: ~7 seconds end-to-end. Output size: ~42 MB (from ~4 GB raw).


6. Validation

The pivot is only worth building if the features genuinely separate skill levels. They do. Median WTN and opponent strength track NTRP cleanly (male players):

NTRPPlayersMedian WTNMedian opp WTNMedian win %
3.016,82230.930.147%
3.527,15029.228.550%
4.021,15227.426.750%
4.58,67525.024.356%
5.01,51820.420.360%

Twin matching also passes the eye test: a 39-year-old male at WTN 27.2 with 21 matches (38% wins) matches to 37โ€“40-year-olds at WTN ~27 with similar volume and win rates.

Headline dataset stats (from the app summary)


7. The application

Data-access layer (lib/)

Twin-matching engine

A transparent, weighted k-NN computed in SQL. Distance is a normalized, weighted sum over WTN (highest weight), age, win rate, match volume, tournament mix, and strength of schedule โ€” within the same gender and a soft age window. Every term is human-readable, so matches are explainable (a requirement of the proposal). Distance is mapped to an intuitive 0โ€“100 similarity score.

Gap-to-next-level

Compares a player against the peer group one level above (NTRP + 0.5 where NTRP exists, otherwise the next WTN band) across win rate, matches played, weeks active, opponent strength, clutch win rate, and upset win rate.

Talent radar

Two views over ranked juniors, both normalized to a within-list percentile (0 = bottom of the list, 100 = #1) so lists of very different sizes are comparable: Rising (biggest full-season percentile climb) and Elite (highest season-best percentile). Raw rank and list size are shown for context.

Twin similarity visualization

An SVG scatter plots each twin on WTN (ability) ร— win rate, sized by similarity, with the selected player highlighted โ€” visualizing the twin cluster. Points are clickable to pivot to that twin.

AI insight generation

lib/insights.ts produces a deterministic rule-based narrative. When OPENAI_API_KEY is set (lib/llm.ts, any OpenAI-compatible endpoint), the app generates the narrative with an LLM instead, using a system prompt that enforces the honest peer-benchmark framing (no fabricated forecasts). It falls back to the rule engine on any error, and the UI badges which source produced each insight.

Court access

A section-level view of the facilities dataset (courts, indoor/outdoor, surface availability, % private) supporting the "democratizing the pipeline" theme.

API (App Router Route Handlers)

Dashboard UI

A single responsive dashboard: headline stats, a player picker with filters, a player-detail view (profile card, AI-insight panel, gap comparison chart, clickable twins grid), and the talent radar.


8. Match Lab โ€” US Open Hawk-Eye rally tracking (added dataset)

Partway through the build, USTA provided an additional dataset: Hawk-Eye "Tennis Rally" tracking feeds for a single US Open 2025 men's singles match (anonymized as Player 11 vs Player 31, Arthur Ashe Stadium, best-of-five). This is an entirely different kind of data from the five core CSVs โ€” granular per-rally 3D tracking rather than population-level records โ€” so I built it as a separate "Match Lab" module rather than forcing it into the twin/benchmark engine (the metrics don't align 1:1, and there are no shared player identities).

What's in it

~310 MB of pretty-printed JSON across five synchronized feeds, all in real-world court coordinates (metres), documented in the accompanying feed spec PDF:

FeedFilesContents
rally.summary490 ralliesOutcome, server/receiver, rally length, winner vs forced/unforced error, serve speed & side, distance each player ran, running score, scenario flags (break/set/match point)
rally.events~830+ shotsPer-shot: stroke (FH/BH), shot type, placement, spin (rpm), speed, bounce location, in/out call
rally.samples.ball490~50 Hz ball trajectory: position (x, y, z), velocity, acceleration
rally.samples.people.centroids490~50 Hz player positions, velocity, speed, and role (server/receiver)
rally.motions.ball490Parametric ball-flight arcs (toss โ†’ hit โ†’ bounce) as polynomial curves

Stats derived from it: 490 tracked rallies, avg rally 2.4 shots (max 20), serve speeds 40โ€“138 mph, 31 aces, ending on a match-point ace.

Offline pipeline (pipeline/build_match.mjs)

Same philosophy as the DuckDB pipeline โ€” precompute offline, ship something small:

Because these are static JSON, Match Lab needs no database or server function โ€” the page fetches the index up front and lazy-loads a rally's trajectory on demand.

The feature (/match-lab, linked from the dashboard)

Caveats (honest framing)


9. Data-quality decisions worth flagging

  1. "Tournament share" was dropped as a metric. The provided play history is ~99% league/team play โ€” only 7,388 tournament matches exist across 6.85M rows (Team League 4,113,970; Flex League 53,998; Tournament 7,388). A tournament ratio is ~0 for nearly everyone, so I replaced it with weeks active, which genuinely varies by level. Note: this is fundamentally a league dataset, not a tournament dataset.
  2. Benchmarks use the mean for engagement metrics. Median tournament ratio and upset rate are 0 for most players, so "next-level target" uses the level mean for those, keeping the target meaningful.
  3. WTN over NTRP as the primary rating (see ยง2) โ€” better coverage, continuous, confidence-scored.
  4. Names are anonymized in the source, so the UI shows deterministic handles like "Player 1D7F88" derived from the masked person_key.

10. Known limitations & honest framing


11. How to run

# 1. Build the derived feature tables from the raw CSVs (requires DuckDB CLI)
brew install duckdb          # one-time
./pipeline/run.sh            # ~7 seconds, writes data/derived/

# 2. (Optional) Rebuild the US Open Match Lab data from the raw Hawk-Eye feeds
node pipeline/build_match.mjs   # writes public/match/ (~6 MB, committed)

# 3. Install and start the app
npm install
npm run dev                  # http://localhost:3000

Optional โ€” enable LLM-generated insights: set OPENAI_API_KEY (and optionally OPENAI_BASE_URL / TENNIS_TWIN_LLM_MODEL, default gpt-4o-mini) before starting the app. Without a key, insights use the deterministic rule engine and everything still works.

The raw datasets live in dataSourceFiles/ and are git-ignored (multi-GB); the DuckDB-derived outputs in data/derived/ are also git-ignored and regenerated by the pipeline. The much smaller Match Lab output in public/match/ (~6 MB) is committed, since it is served as static assets and the raw Hawk-Eye feeds are git-ignored.


12. Roadmap

Recently completed: within-list percentile normalization for talent, an LLM-swappable insight seam, the twin similarity scatter, the Match Lab US Open rally replay (ยง8), and โ€” most recently โ€” the pillars that connect the whole pathway:

Next: