Find what’s slowing your GTM.
Most GTM problems aren’t one big failure. They’re one or two weak spots quietly dragging down the rest.
The free GTM Audit scores your go-to-market across four dimensions, then sequences a 90-day roadmap for your stage.
Drop your URL. Get your score in about 10 minutes.
I’ve assembled a game archive of player tracking data from the 2025-26 NBA season covering over 600 games, including player locations, ball locations and height, and the context needed to start making those coordinates useful.
The combined ZIP below includes normalized tracking files you can read in Python, the original feed responses, and reports describing what was available for each game.
Coverage varies by game. This is a collection of the public feed exposed by NBA’s Game Pulse feature. It does not guarantee tracking for every game, every possession, or all ten players and the ball at every timestamp4.
What this looks like

Phoenix at Philadelphia, game 0022500613. The 5 Hz source positions are linearly interpolated for 40 Hz playback. Teal represents Philadelphia, purple Phoenix, and the lower panel plots ball height in feet.
Tracking data lets you ask questions a box score cannot answer:
Where is the nearest opponent when a player catches the ball?
Where are the other nine players when a shot goes up?
The animation makes those relationships easier to follow. For analysis, start with the original samples and check lineups, event timing, and coverage.
Where the data comes from
The NBA App’s Game Pulse experience includes a live visualization of the court. That visualization is backed by timestamped player and ball positions delivered to the client.
I traced the feeds behind that experience and built a collector to retrieve their retained histories. The collector starts with the NBA schedule, selects completed games, checks for published tracking, and downloads the available data2.
What’s in the download?
Each game with normalized coordinates has a tracking.jsonl.gz file: gzip-compressed JSON Lines, with one timestamped record per line. You can process it a row at a time without loading an entire game into memory.
The normalized records include:
Field | Meaning |
|---|---|
| NBA game ID, preserved as a string with leading zeros |
| Source timestamp in Unix milliseconds |
| Player IDs, team IDs, positions, and lineup membership |
| Observed players that could not be assigned to a side |
| Ball x/y/z when observed; otherwise |
| Available clock context |
| Missing-player information, lineup availability, and other quality flags |
Normalized coordinates are in feet. The origin is midcourt1: x runs along the court’s length, y across its width, and z represents height. The raw feed uses inches. Normalization divides coordinates by 12 without changing the axes.
Alongside the tracking files, you’ll find:
metadata.jsonandcoverage.json: rosters, coordinate definitions, counts, gaps, and coverage notes for each game.raw/: the original positions, companion, and statistics event histories, also saved as compressed JSON Lines.manifest.json: the collection results, including unavailable programs, empty published histories, and requests that need retrying.catalog.sqlite3: a queryable index of game IDs, dates, statuses, frame counts, and directories.
This uses a documented JSONL schema. It does not contain full player skeletons or body-joint coordinate sequences, but some games also have shot-specific, pose-related features in the raw companion feed, described below.
Example 1: Read a game without extracting the ZIP
You do not need to unpack all 4.8 GB to start working. Python’s standard library can open a compressed tracking file directly inside the ZIP.
Put the archive in your working directory and run:
import gzip
import json
import zipfile
archive_path = "NBA-2025-26-All-Games.zip"
game_id = "0022500613"
with zipfile.ZipFile(archive_path) as archive:
games = [n for n in archive.namelist()
if n.endswith("/tracking.jsonl.gz")]
print("Games with normalized tracking files:", len(games))
member = next(n for n in games
if n.endswith(f"/{game_id}/tracking.jsonl.gz"))
with archive.open(member) as compressed:
with gzip.open(compressed, "rt", encoding="utf-8") as rows:
frame = next(
(r for r in map(json.loads, rows)
if r["quality"]["all_ten_and_ball_observed"]
and r["period"] in (1, 2, 3, 4)),
None,
)
if frame is None:
raise ValueError("No frame met the selected quality filter.")
print(frame["timestamp_utc"], frame["period"], frame["ball"])This selects a frame with a known five-player lineup for each team, all ten expected players observed, and a ball observation. It is a useful filter for geometry examples, not a substitute for validating the full possession.
Example 2: Find a player’s nearest opponent

Using the frame from the first example, we can calculate the distance from one home player to the nearest on-court opponent:
from math import hypot
home = [p for p in frame["home_players"] if p["on_court"] is True]
away = [p for p in frame["away_players"] if p["on_court"] is True]
player = home[0]
def distance_ft(a, b):
return hypot(a["xyz"][0] - b["xyz"][0],
a["xyz"][1] - b["xyz"][1])
opponent = min(away, key=lambda p: distance_ft(player, p))
print("Player:", player["player_id"])
print("Nearest opponent:", opponent["player_id"])
print(f"Distance: {distance_ft(player, opponent):.2f} feet")That is a two-dimensional proximity measurement. It does not establish who was guarding whom, whether a shot was contested, or whether a defender could meaningfully affect the play.
But it gives you a building block. With event timing and additional logic, you can begin studying catch spacing, help positioning, and how the floor changes over a possession.
Example 3: Find the shots worth a second look

Here's how I built the chart:
Join
ShotDetails.playIdandplayerIdto the latest correspondingPlayByPlayrecord'sidandpersonId, within each game.Keep Brunson's matched three-point attempts from 22–28 feet with complete values for all five fields.
Rank each field within that sample. For each shot, average the absolute distance of its five percentile ranks from 50. Larger scores move up the queue.
Add the outcome,
pressureScore, and the feed'sexpFieldGoalPctas context. None of those values determines the ranking.
The first card is a made three in Game 2, Q3, 9:21. It has the sample's highest reported hipDegrees and verticalJump values. Another make comes next, a blocked miss is third. The queue surfaces different-looking attempts without treating every difference as a mistake.
That is a useful starting point for film study: open the corresponding clips and investigate what changed. A larger archive could let you compare a player's attempts within shot types or track how the feature distributions change over a season. I was going to integrate the video clips into this example, but nba.com currently does not have their video clip api functionality working as of this blog post.
These are derived summaries from joint-coordinate sequences. Higher percentiles do not mean better mechanics.
The limitations worth understanding
The observed coordinate delivery is generally 5 Hz. Samples are usually about 200 milliseconds apart. Measure the actual timestamp differences for the game and segment you use and do not assume a perfectly regular stream. The source player tracking data samples at 60 Hz and it’s pretty clear this is a processed version of that source data meant specifically for the Game Pulse feature within the NBA app, which is for entertainment purposes not rigorous statistical analysis purposes.
The archive preserves its source samples and omissions. Its normalization does not add temporal player or ball interpolation3. The separate animation example above interpolates between valid adjacent samples while leaving missing entities and longer gaps unfilled. Source interpolation flags are preserved when supplied. Game and shot clocks can be interpolated between source updates while marked running, that is separate from the coordinate rendering example. If clock updates are missing or stop early, later coordinates can inherit stale period or clock labels. Read the coverage report and check source timestamps before making quarter-level or event-aligned claims.
Conclusion
There is room here for students learning Python, researchers testing ideas, and developers building tools that make movement data easier to understand.
If you build something with it, send it my way. I’d love to see what you find!
1 (0,0)
2 This only took me about 2 hours to figure it out. You should be able to figure it out too!
3 It makes no sense to save computable data and unnecessarily bloat the archived size when the computations are trivially easy.
4 If I had to guess, someone turned it on for 2026 and didn’t realize it made available all 2026 games from the 2025-26 season.



