NBA Player Tracking

An NBA game, reconstructed from telemetry.

Ten players and a ball, sampled 25 times per second. Every dot on the court below is a row read from Arc.

Press play, seek anywhere in the game, select a player. The Arc query panel shows the SQL that produced what you are looking at and how long Arc took to run it.

-- querying Arc for the timeline index...
What you just watched

One game. 939,454 observations.

Ten players and one ball, each producing a position 25 times per second, for the whole game. A sport becomes a time-series workload the moment you want to ask questions of it: where was this player at this instant, how fast were they moving, who was closest to them, what did the ball do during that shot.

Observations in Arc
939,454
rows in nba_tracking.tracking for this game
Frames
85,450
after removing 147,589 duplicate moments from 233,039 in the source file
Sampling rate
25 Hz
one frame every 40 ms
Tracked time
56.9 min
146.4 min of wall clock, 99 contiguous segments
Players observed
22
plus the ball
Play-by-play events
493
units the source is organized around
Time range
2015-12-25
22:20:35 to 00:47:01 UTC
Imported
2026-09-22
by demos/nba-tracking/ingest.py

How the time axis works

SportVU stamps every frame with the real capture time, so that becomes Arc's time column unchanged. Period, game clock and shot clock are stored alongside it. The feed contains no frames during timeouts and halftime, so the scrubber runs over tracked time: the contiguous segments Arc finds with a window function, concatenated. Nothing is resampled; the stored observations are the source's.

Why the file had duplicates

The source is organized around play-by-play events, and adjacent events repeat each other's frames. The importer keys frames on (period, capture time), verifies that repeated keys carry identical positions, and keeps the first occurrence. The counts above are what that pass produced.

The query behind the timeline
WITH frames AS (
  SELECT DISTINCT time, period
  FROM tracking
  WHERE game_id = '0021500438'
),
boundaries AS (
  SELECT time, period,
         CASE WHEN LAG(time) OVER (ORDER BY time) IS NULL
                OR time - LAG(time) OVER (ORDER BY time) > INTERVAL 1 SECOND
                OR period <> LAG(period) OVER (ORDER BY time)
              THEN 1 ELSE 0 END AS boundary
  FROM frames
),
runs AS (
  SELECT time, period, SUM(boundary) OVER (ORDER BY time) AS segment
  FROM boundaries
)
SELECT segment, period,
       epoch_ms(min(time)) AS start_ms,
       epoch_ms(max(time)) AS end_ms,
       count(*) AS frames
FROM runs
GROUP BY segment, period
ORDER BY start_ms

Tracking data: NBA SportVU optical tracking (STATS LLC) for the 2015-16 season, as published in the NBA-Player-Movements repository. Game 0021500438, Cleveland Cavaliers at Golden State Warriors, 25 December 2015. The dataset is used here for a non-commercial technical demonstration; the raw files are not redistributed by Basekick Labs. NBA, the NBA logo and team names are trademarks of their owners. This demo is not affiliated with or endorsed by the NBA.

The game was imported once by a one-shot importer that fetches the source file, removes the duplicate frames shared between adjacent play-by-play events, and writes one row per entity per frame into Arc. Every figure on this page is read back from that import.

Arc is an open, SQL-native time-series database for high-volume telemetry. A basketball court is a friendly stand-in for a factory floor, a fleet or a grid: many sources, high sample rates, questions about specific entities at specific instants.