Pre-snap motion is when an offensive player moves after the offense has set but before the ball is snapped, such as a receiver running sideways across the formation. Offenses use motion to confuse defenses, reveal whether the defense is in man or zone coverage, and create better matchups. In this assignment, I use player tracking data from the 2025 NFL Big Data Bowl, which covers weeks 1–9 of the 2022 NFL season, to answer two questions: which NFL teams use pre-snap motion most often, and is using more motion associated with better offensive performance? I measure motion by tracking how far each offensive player moves laterally between the time the offense sets and the snap, using a cutoff to ignore tiny movements picked up by the tracking tags. I then rank all 32 teams by how often they use motion, compare that with how well their motion plays work (measured by expected points added, or EPA), and discuss how offensive coordinators could use these results.
I load four files from the 2025 NFL Big Data Bowl: plays.csv (play details and EPA), players.csv (names and positions), games.csv (game information), and player_play.csv (the NFL’s own motion flags, used to check my cutoff). The nine weekly tracking files record every player’s x/y position 10 times per second. Because they are so large, I load them one week at a time in later steps.
setwd("/Users/rubynguyen/sports analytics/fall sports analytics 2026")
source("https://raw.githubusercontent.com/ptallon/SportsAnalytics_fall2026/refs/heads/main/SharedCode.R")
load_packages(c("data.table", "ggplot2", "kableExtra", "stringr", "dplyr",
"scales", "ggrepel", "gganimate", "gifski", "magick",
"nflreadr", "httr"))
# Download and unzip the 2025 Big Data Bowl data (only the first time)
destination <- file.path(getwd(), "NFLBDB2025")
zip_file <- file.path(getwd(), "NFLBDB2025.zip")
fileURL <- "https://www.dropbox.com/scl/fi/spy2limdm8kqleswa1pqo/nfl-big-data-bowl-2025.zip?rlkey=e4cz61xvmzlv3itdli1oee3dw&raw=1"
if (!dir.exists(destination)) dir.create(destination, recursive = TRUE)
if (!file.exists(zip_file)) GET(url = fileURL, write_disk(zip_file, overwrite = TRUE))
if (length(list.files(destination, pattern = "plays.csv", recursive = TRUE)) == 0) {
unzip(zip_file, exdir = destination, overwrite = TRUE)
}
# Fallback in case SharedCode.R doesn't set `directory`
if (!exists("directory")) directory <- destination
player_play <- fread(paste0(directory, "/player_play.csv"))
plays <- fread(paste0(directory, "/plays.csv"))
games <- fread(paste0(directory, "/games.csv"))
players <- fread(paste0(directory, "/players.csv"))
To measure motion, I looked at each play between the moment the offense was set (line_set) and the snap (ball_snap). For each offensive player, I added up their lateral movement, meaning how much their y-coordinate changed from frame to frame. I left out offensive linemen and the quarterback, since they don’t go in motion. Tracking tags are sensitive enough to pick up tiny shifts, so I only counted frames where a player was moving at least 0.5 yards per second. I tested this on Week 1 first.
SPEED_CUTOFF <- 0.5 # yd/s: frames slower than this are treated as tracking jitter
# Saved after the first run so knitting doesn't redo it every time
if (file.exists("motion_w1.rds")) {
motion_w1 <- readRDS("motion_w1.rds")
} else {
df <- fread(paste0(directory, "/tracking_week_1.csv"),
select = c("gameId", "playId", "nflId", "frameId",
"club", "y", "s", "event"))
# line_set and ball_snap frames for each play
snap_frames <- df |>
filter(event %in% c("line_set", "ball_snap")) |>
distinct(gameId, playId, frameId, event) |>
group_by(gameId, playId) |>
summarise(set_frame = suppressWarnings(max(frameId[event == "line_set"])),
snap_frame = suppressWarnings(min(frameId[event == "ball_snap"])),
.groups = "drop") |>
filter(is.finite(set_frame), is.finite(snap_frame), set_frame < snap_frame)
# Offensive non-linemen between line_set and the snap
motion_w1 <- df |>
filter(!is.na(nflId)) |> # drop the football
inner_join(snap_frames, by = c("gameId", "playId")) |>
filter(frameId >= set_frame, frameId <= snap_frame) |>
left_join(plays |> select(gameId, playId, possessionTeam),
by = c("gameId", "playId")) |>
filter(club == possessionTeam) |> # offense only
left_join(players |> select(nflId, position), by = "nflId") |>
filter(!position %in% c("T", "G", "C", "QB")) |> # skip linemen and QB
arrange(gameId, playId, nflId, frameId) |>
group_by(gameId, playId, nflId, club, position) |>
mutate(dy = abs(y - lag(y))) |> # lateral change per frame
summarise(lateral_yards = sum(dy[s >= SPEED_CUTOFF], na.rm = TRUE),
.groups = "drop")
saveRDS(motion_w1, "motion_w1.rds")
rm(df); gc()
}
## used (Mb) gc trigger (Mb) limit (Mb) max used (Mb)
## Ncells 1346118 71.9 2605635 139.2 NA 2605635 139.2
## Vcells 13552010 103.4 183629352 1401.0 16384 229536644 1751.3
ggplot(motion_w1, aes(lateral_yards)) +
geom_histogram(binwidth = 0.25, fill = "darkgreen") +
coord_cartesian(xlim = c(0, 15)) +
labs(title = "Pre-Snap Lateral Movement of Offensive Players (Week 1)",
x = "Lateral yards between line_set and the snap", y = "Players") +
theme_minimal() +
theme(plot.title = element_text(hjust = 0.5))
The histogram shows that most offensive players barely move between line_set and the snap, so the bars pile up near 0 yards. A long tail stretches out to 10 or more yards: these are the players who are clearly in motion. The goal of the next step is to pick a cutoff that separates the two groups.
To decide where motion starts, I tested six cutoffs from 0.5 to 5 yards and compared each with the NFL’s own motion flags in player_play.csv. A 1-yard cutoff worked best. It agreed with the NFL’s flags for 98.1% of players and still caught 92% of the players the NFL marked as in motion. Lower cutoffs flagged extra players who were only adjusting their stance. Higher cutoffs missed real motion; at 5 yards, only 67% were caught. From here on, a player counts as in motion if they moved at least 1 yard laterally before the snap.
check <- motion_w1 |>
left_join(player_play |> select(gameId, playId, nflId, inMotionAtBallSnap,
motionSinceLineset, shiftSinceLineset),
by = c("gameId", "playId", "nflId")) |>
mutate(nfl_flag = coalesce(inMotionAtBallSnap, FALSE) |
coalesce(motionSinceLineset, FALSE) |
coalesce(shiftSinceLineset, FALSE))
cutoff_table <- bind_rows(lapply(c(0.5, 1, 1.5, 2, 3, 5), function(cut) {
my_flag <- check$lateral_yards >= cut
data.frame(cutoff_yards = cut,
players_flagged = sum(my_flag),
agreement_pct = round(100 * mean(my_flag == check$nfl_flag), 1),
nfl_motion_found = round(100 * mean(my_flag[check$nfl_flag]), 1))
}))
cutoff_table |>
kable(format = "html",
col.names = c("Cutoff (yards)", "Players Flagged",
"Agreement with NFL Flag (%)", "NFL Motion Players Caught (%)"),
caption = "Comparing motion cutoffs with the NFL's motion flags (Week 1)",
align = c("c", "c", "c", "c")) |>
kable_styling(bootstrap_options = c("striped", "hover", "condensed"),
full_width = FALSE, position = "center") |>
row_spec(2, bold = TRUE, background = "#e8f5e9") # highlight the 1-yard cutoff
| Cutoff (yards) | Players Flagged | Agreement with NFL Flag (%) | NFL Motion Players Caught (%) |
|---|---|---|---|
| 0.5 | 1459 | 97.7 | 94.4 |
| 1.0 | 1361 | 98.1 | 92.1 |
| 1.5 | 1312 | 97.9 | 89.6 |
| 2.0 | 1274 | 97.6 | 87.4 |
| 3.0 | 1177 | 96.8 | 81.1 |
| 5.0 | 975 | 94.8 | 67.2 |
MOTION_CUTOFF <- 1.0 # chosen cutoff (yards of lateral movement)
I ran the same steps on all nine weeks. A play counts as a motion play if at least one offensive player moved 1+ yards laterally before the snap. I removed plays canceled by penalties, kneel-downs, and spikes. For each team, I calculated its motion rate (% of plays with motion) and its motion effectiveness (average EPA on motion plays).
get_motion <- function(week) {
trk <- fread(paste0(directory, "/tracking_week_", week, ".csv"),
select = c("gameId", "playId", "nflId", "frameId",
"club", "y", "s", "event"))
snap_frames <- trk |>
filter(event %in% c("line_set", "ball_snap")) |>
distinct(gameId, playId, frameId, event) |>
group_by(gameId, playId) |>
summarise(set_frame = suppressWarnings(max(frameId[event == "line_set"])),
snap_frame = suppressWarnings(min(frameId[event == "ball_snap"])),
.groups = "drop") |>
filter(is.finite(set_frame), is.finite(snap_frame), set_frame < snap_frame)
trk |>
filter(!is.na(nflId)) |>
inner_join(snap_frames, by = c("gameId", "playId")) |>
filter(frameId >= set_frame, frameId <= snap_frame) |>
left_join(plays |> select(gameId, playId, possessionTeam),
by = c("gameId", "playId")) |>
filter(club == possessionTeam) |>
left_join(players |> select(nflId, position), by = "nflId") |>
filter(!position %in% c("T", "G", "C", "QB")) |>
arrange(gameId, playId, nflId, frameId) |>
group_by(gameId, playId, nflId, club, position) |>
mutate(dy = abs(y - lag(y))) |>
summarise(lateral_yards = sum(dy[s >= SPEED_CUTOFF], na.rm = TRUE),
.groups = "drop") |>
mutate(week = week)
}
# Run once and save; after that, just load the saved file
if (file.exists("motion_all.rds")) {
motion_all <- readRDS("motion_all.rds")
} else {
motion_all <- bind_rows(lapply(1:9, function(w) {
out <- get_motion(w)
gc()
out
}))
saveRDS(motion_all, "motion_all.rds")
}
# One row per play: did anyone go in motion?
play_motion <- motion_all |>
group_by(gameId, playId) |>
summarise(n_motion = sum(lateral_yards >= MOTION_CUTOFF),
motion_yards = sum(lateral_yards[lateral_yards >= MOTION_CUTOFF]),
.groups = "drop") |>
mutate(has_motion = n_motion > 0) |>
left_join(plays |> select(gameId, playId, possessionTeam, expectedPointsAdded,
playNullifiedByPenalty, qbKneel, qbSpike),
by = c("gameId", "playId")) |>
filter(playNullifiedByPenalty != "Y",
coalesce(as.numeric(qbKneel), 0) == 0,
!coalesce(as.logical(qbSpike), FALSE))
# One row per team
team_motion <- play_motion |>
group_by(team = possessionTeam) |>
summarise(plays = n(),
motion_rate = mean(has_motion),
epa_motion = mean(expectedPointsAdded[has_motion], na.rm = TRUE),
epa_no_motion = mean(expectedPointsAdded[!has_motion], na.rm = TRUE),
avg_motion_yds = mean(motion_yards[has_motion]),
.groups = "drop") |>
mutate(epa_lift = epa_motion - epa_no_motion)
# Quadrants (split at the league medians)
med_x <- median(team_motion$epa_motion)
med_y <- median(team_motion$motion_rate)
team_motion <- team_motion |>
mutate(quadrant = case_when(
motion_rate < med_y & epa_motion < med_x ~ 1,
motion_rate < med_y & epa_motion >= med_x ~ 2,
motion_rate >= med_y & epa_motion < med_x ~ 3,
motion_rate >= med_y & epa_motion >= med_x ~ 4)) |>
arrange(desc(motion_rate))
quad_cols <- c("1" = "#d73027", "2" = "#4575b4", "3" = "#e08214", "4" = "#1a9850")
quad_names <- c("1" = "1: Doesn't believe in motion",
"2" = "2: Good results, little motion",
"3" = "3: Lots of motion, little payoff",
"4" = "4: Motion pays off")
Across weeks 1–9 there were 15,720 plays, and 56.4% of them had at least one offensive player in motion.
To show what motion looks like in the tracking data, I animated a Kansas City play from its game against Indianapolis. Before the snap, wide receiver Marquez Valdes-Scantling ran 19.9 yards laterally across the formation, shown by the dashed gold line. Patrick Mahomes then threw a short pass to Valdes-Scantling for a 9-yard gain, a good example of motion setting up the play. The blue line is the line of scrimmage and the yellow line is the first-down line.
anim_team <- "KC" # team to pull an example play from
max_lateral <- 20 # skip odd outliers above this many lateral yards
view_yards <- 25 # yards shown on each side of the line of scrimmage
gif_file <- "motion_play.gif"
# Pick a play with exactly one player in a big, clean motion
example_play <- motion_all |>
filter(club == anim_team) |>
group_by(gameId, playId) |>
filter(sum(lateral_yards >= MOTION_CUTOFF) == 1) |>
ungroup() |>
filter(lateral_yards >= MOTION_CUTOFF, lateral_yards <= max_lateral) |>
semi_join(play_motion, by = c("gameId", "playId")) |>
arrange(desc(lateral_yards)) |>
slice(1)
gid <- example_play$gameId
pid <- example_play$playId
motion_id <- example_play$nflId
off_team <- example_play$club
info <- plays |> filter(gameId == gid, playId == pid)
mover <- players |> filter(nflId == motion_id) |> pull(displayName)
# Build the GIF only if it doesn't exist yet (delete the file to rebuild it)
if (!file.exists(gif_file)) {
trk <- fread(paste0(directory, "/tracking_week_", example_play$week, ".csv"),
select = c("gameId", "playId", "nflId", "frameId", "club",
"x", "y", "event", "playDirection"))
play_trk <- trk |> filter(gameId == gid, playId == pid)
rm(trk); gc()
set_frame <- max(play_trk$frameId[play_trk$event == "line_set"], na.rm = TRUE)
snap_frame <- min(play_trk$frameId[play_trk$event == "ball_snap"], na.rm = TRUE)
los <- play_trk |> filter(club == "football", frameId == snap_frame) |> pull(x)
play_dir <- play_trk$playDirection[1]
# ~1 second before line_set through ~3 seconds after the snap
anim_df <- play_trk |>
filter(frameId >= set_frame - 10, frameId <= snap_frame + 30) |>
mutate(role = case_when(club == "football" ~ "Football",
nflId == motion_id ~ "Motion player",
club == off_team ~ "Offense",
TRUE ~ "Defense"))
players_df <- anim_df |> filter(role != "Football")
ball_df <- anim_df |> filter(role == "Football")
mover_df <- anim_df |> filter(role == "Motion player")
# Pre-snap path (no frameId column, so it stays on screen in every frame)
motion_path <- mover_df |>
filter(frameId <= snap_frame) |>
select(px = x, py = y)
phase_df <- anim_df |>
distinct(frameId) |>
mutate(phase = ifelse(frameId < snap_frame, "PRE-SNAP", "BALL SNAPPED"))
x_lim <- los + c(-view_yards, view_yards)
# Yard numbers every 10 yards (goal lines are at x = 10 and x = 110)
yard_labels <- data.frame(x = seq(20, 100, 10)) |>
mutate(label = ifelse(x <= 60, x - 10, 110 - x)) |>
filter(x > x_lim[1] + 2, x < x_lim[2] - 2)
first_down <- if (play_dir == "right") los + info$yardsToGo else los - info$yardsToGo
show_fd <- first_down > x_lim[1] & first_down < x_lim[2]
anim <- ggplot() +
# Field and 5-yard lines
annotate("rect", xmin = x_lim[1], xmax = x_lim[2], ymin = 0, ymax = 53.3,
fill = "#3f8f3f") +
annotate("segment", x = seq(10, 110, 5), xend = seq(10, 110, 5),
y = 0, yend = 53.3, color = "white", alpha = 0.4) +
# Yard numbers
geom_text(data = yard_labels, aes(x = x, y = 5, label = label),
color = "white", fontface = "bold", size = 5, alpha = 0.8) +
geom_text(data = yard_labels, aes(x = x, y = 48.3, label = label),
color = "white", fontface = "bold", size = 5, alpha = 0.8, angle = 180) +
# Line of scrimmage and first-down line
annotate("segment", x = los, xend = los, y = 0, yend = 53.3,
color = "royalblue", linewidth = 1.2) +
annotate("label", x = los, y = 1.5, label = "LOS", size = 3,
fill = "royalblue", color = "white", fontface = "bold") +
{ if (show_fd) list(
annotate("segment", x = first_down, xend = first_down, y = 0, yend = 53.3,
color = "yellow", linewidth = 1.2),
annotate("label", x = first_down, y = 1.5, label = "1st down", size = 3,
fill = "yellow", color = "black", fontface = "bold")) } +
# Motion trail
geom_path(data = motion_path, aes(px, py),
color = "gold", linewidth = 1, linetype = "dashed", alpha = 0.9) +
# Players, motion player's name, football
geom_point(data = players_df, aes(x, y, fill = role),
shape = 21, size = 6, color = "black", stroke = 0.6) +
geom_text(data = mover_df, aes(x, y, label = mover),
nudge_y = 2.2, fontface = "bold", size = 4, color = "white") +
geom_point(data = ball_df, aes(x, y),
fill = "saddlebrown", shape = 21, size = 3, color = "black") +
# Phase label
geom_label(data = phase_df, aes(x = x_lim[1] + 1, y = 51, label = phase),
hjust = 0, fontface = "bold", size = 4.5) +
scale_fill_manual(values = c("Motion player" = "gold",
"Offense" = "red3",
"Defense" = "white"),
name = NULL) +
coord_fixed(xlim = x_lim, ylim = c(0, 53.3), expand = FALSE) +
labs(title = paste0(off_team, " vs ", info$defensiveTeam, ": ", mover,
" in motion (", round(example_play$lateral_yards, 1),
" lateral yards before the snap)"),
subtitle = str_wrap(info$playDescription, 80),
caption = paste0("Dashed gold = pre-snap motion path | Blue = line of scrimmage | ",
"Yellow = first-down line\n",
"Data: 2025 NFL Big Data Bowl (2022 season)")) +
theme_void(base_size = 12) +
theme(plot.title = element_text(face = "bold", size = 13, margin = margin(b = 6)),
plot.subtitle = element_text(size = 10, color = "grey30", margin = margin(b = 10)),
plot.caption = element_text(size = 9, color = "grey40", hjust = 0, margin = margin(t = 8)),
plot.title.position = "plot",
plot.caption.position = "plot",
plot.margin = margin(15, 15, 15, 15),
legend.position = "bottom") +
transition_time(frameId)
gif <- animate(anim,
nframes = n_distinct(anim_df$frameId),
fps = 8,
width = 750,
height = 850,
end_pause = 12,
renderer = gifski_renderer())
anim_save(gif_file, animation = gif)
# Add team logos (skipped quietly if there's no internet connection)
tryCatch({
teams <- nflreadr::load_teams()
off_logo <- image_read(teams$team_logo_espn[teams$team_abbr == off_team]) |>
image_scale("80")
def_logo <- image_read(teams$team_logo_espn[teams$team_abbr == info$defensiveTeam]) |>
image_scale("80")
frames <- image_read(gif_file)
frames <- image_composite(frames, off_logo, gravity = "southeast", offset = "+105+15")
frames <- image_composite(frames, def_logo, gravity = "southeast", offset = "+15+15")
image_write(frames, gif_file)
}, error = function(e) message("Logos skipped: ", conditionMessage(e)))
}
knitr::include_graphics(normalizePath(gif_file))
Here I explain the ranking. (Teams are ranked by the percentage of their plays with at least one player in motion. Bar colors match the quadrants in Step 7. Who is at the top and the bottom?)
ggplot(team_motion, aes(x = reorder(team, motion_rate), y = motion_rate,
fill = factor(quadrant))) +
geom_col() +
geom_text(aes(label = percent(motion_rate, accuracy = 1)),
hjust = -0.1, size = 3) +
geom_hline(yintercept = med_y, linetype = "dashed", color = "grey40") +
coord_flip() +
scale_y_continuous(labels = percent, limits = c(0, 0.9)) +
scale_fill_manual(values = quad_cols, labels = quad_names, name = NULL) +
labs(title = "Team Rankings: Share of Plays with Pre-Snap Motion",
subtitle = "Dashed line = league median | colors = quadrant from Step 7",
x = NULL,
y = "% of plays with at least one player in motion (≥ 1 lateral yard)",
caption = "Data: 2025 NFL Big Data Bowl (2022 season, Weeks 1–9)") +
theme_minimal(base_size = 12) +
theme(plot.title = element_text(hjust = 0.5, face = "bold"),
plot.subtitle = element_text(hjust = 0.5),
legend.position = "bottom") +
guides(fill = guide_legend(nrow = 2))
team_motion |>
mutate(rank = row_number(),
motion_rate = percent(motion_rate, accuracy = 0.1),
avg_motion_yds = round(avg_motion_yds, 1)) |>
select(rank, team, plays, motion_rate, avg_motion_yds,
epa_motion, epa_no_motion, epa_lift, quadrant) |>
kable(format = "html",
col.names = c("#", "Team", "Plays", "Motion Rate", "Avg Motion Yards",
"EPA (Motion)", "EPA (No Motion)", "EPA Lift", "Quadrant"),
caption = "Team pre-snap motion rankings (2022 season, Weeks 1–9)",
digits = 3,
align = c("l", "l", "c", "c", "c", "c", "c", "c", "c")) |>
kable_styling(bootstrap_options = c("striped", "hover", "condensed"),
full_width = FALSE, position = "center")
| # | Team | Plays | Motion Rate | Avg Motion Yards | EPA (Motion) | EPA (No Motion) | EPA Lift | Quadrant |
|---|---|---|---|---|---|---|---|---|
| 1 | MIA | 507 | 79.5% | 10.4 | 0.027 | 0.134 | -0.107 | 4 |
| 2 | KC | 517 | 73.3% | 10.1 | 0.240 | -0.019 | 0.259 | 4 |
| 3 | ATL | 496 | 72.8% | 12.3 | 0.008 | 0.058 | -0.050 | 4 |
| 4 | NYJ | 528 | 71.4% | 10.5 | -0.147 | -0.002 | -0.145 | 3 |
| 5 | SF | 445 | 71.0% | 11.3 | -0.094 | 0.286 | -0.379 | 3 |
| 6 | BAL | 506 | 69.4% | 8.7 | 0.025 | 0.045 | -0.020 | 4 |
| 7 | DET | 471 | 67.1% | 10.8 | 0.030 | -0.209 | 0.240 | 4 |
| 8 | CHI | 497 | 66.2% | 9.6 | -0.053 | -0.056 | 0.003 | 3 |
| 9 | TEN | 405 | 63.7% | 11.4 | -0.009 | -0.177 | 0.168 | 4 |
| 10 | CLE | 500 | 63.2% | 13.4 | 0.068 | 0.008 | 0.061 | 4 |
| 11 | LA | 456 | 61.8% | 10.1 | -0.115 | -0.155 | 0.040 | 3 |
| 12 | BUF | 458 | 59.0% | 10.5 | 0.125 | 0.017 | 0.108 | 4 |
| 13 | GB | 537 | 58.5% | 10.3 | -0.139 | -0.108 | -0.031 | 3 |
| 14 | NYG | 454 | 58.1% | 11.6 | 0.041 | -0.069 | 0.110 | 4 |
| 15 | JAX | 528 | 57.2% | 10.0 | 0.090 | 0.012 | 0.078 | 4 |
| 16 | TB | 549 | 56.6% | 10.7 | -0.099 | -0.034 | -0.065 | 3 |
| 17 | MIN | 472 | 55.3% | 10.9 | 0.009 | -0.109 | 0.119 | 2 |
| 18 | LV | 463 | 55.1% | 11.2 | 0.032 | 0.000 | 0.032 | 2 |
| 19 | DAL | 437 | 53.8% | 10.6 | -0.089 | 0.061 | -0.150 | 1 |
| 20 | WAS | 560 | 53.4% | 10.5 | -0.152 | -0.096 | -0.056 | 1 |
| 21 | NE | 510 | 51.6% | 10.4 | -0.080 | -0.057 | -0.022 | 1 |
| 22 | CAR | 450 | 51.3% | 10.3 | -0.121 | -0.087 | -0.034 | 1 |
| 23 | SEA | 446 | 50.9% | 10.9 | 0.082 | 0.047 | 0.035 | 2 |
| 24 | HOU | 427 | 49.6% | 13.1 | -0.119 | -0.168 | 0.048 | 1 |
| 25 | PIT | 465 | 48.2% | 10.7 | -0.118 | -0.173 | 0.055 | 1 |
| 26 | LAC | 488 | 44.7% | 9.3 | -0.019 | -0.019 | 0.000 | 2 |
| 27 | ARI | 545 | 42.9% | 11.0 | -0.130 | 0.024 | -0.154 | 1 |
| 28 | IND | 569 | 42.9% | 9.3 | -0.239 | -0.136 | -0.103 | 1 |
| 29 | DEN | 482 | 42.7% | 8.0 | -0.155 | -0.129 | -0.026 | 1 |
| 30 | PHI | 495 | 42.2% | 10.3 | 0.197 | 0.079 | 0.118 | 2 |
| 31 | CIN | 529 | 40.1% | 11.0 | -0.094 | 0.122 | -0.216 | 1 |
| 32 | NO | 528 | 34.1% | 10.8 | -0.046 | -0.019 | -0.027 | 2 |
To see whether using motion more often goes with better results, I plotted each team’s motion rate (y-axis) against its motion effectiveness (x-axis, average EPA on motion plays). The dashed lines mark the league medians and split the teams into four groups: teams that use little motion and get little from it (1), teams that get good results with little motion (2), teams that use lots of motion with little payoff (3), and teams whose heavy motion pays off (4). Bigger dots mean the team ran more motion plays, so its average is more reliable. The black dashed line shows the overall trend.
ct <- cor.test(team_motion$motion_rate, team_motion$epa_motion)
# Pad the axes so quadrant labels don't overlap the dots
x_rng <- range(team_motion$epa_motion)
y_rng <- range(team_motion$motion_rate)
xl <- x_rng + c(-1, 1) * diff(x_rng) * 0.08
yl <- y_rng + c(-1, 1) * diff(y_rng) * 0.10
quad_labels <- data.frame(
quadrant = c("1", "2", "3", "4"),
x = c(xl[1], xl[2], xl[1], xl[2]),
y = c(yl[1], yl[1], yl[2], yl[2]),
hjust = c(0, 1, 0, 1),
vjust = c(0, 0, 1, 1),
label = unname(quad_names))
quad_shading <- data.frame(
quadrant = c("1", "2", "3", "4"),
xmin = c(xl[1], med_x, xl[1], med_x), xmax = c(med_x, xl[2], med_x, xl[2]),
ymin = c(yl[1], yl[1], med_y, med_y), ymax = c(med_y, med_y, yl[2], yl[2]))
ggplot(team_motion, aes(x = epa_motion, y = motion_rate)) +
geom_rect(data = quad_shading, inherit.aes = FALSE,
aes(xmin = xmin, xmax = xmax, ymin = ymin, ymax = ymax, fill = quadrant),
alpha = 0.07) +
geom_vline(xintercept = med_x, linetype = "dashed", color = "grey50") +
geom_hline(yintercept = med_y, linetype = "dashed", color = "grey50") +
geom_smooth(method = "lm", se = FALSE, color = "grey20",
linewidth = 0.7, linetype = "longdash") +
geom_point(aes(color = factor(quadrant), size = plays * motion_rate), alpha = 0.9) +
geom_text_repel(aes(label = team), size = 3.2, seed = 1) +
geom_text(data = quad_labels, inherit.aes = FALSE,
aes(x = x, y = y, label = label, hjust = hjust, vjust = vjust, color = quadrant),
fontface = "bold", size = 3.6) +
scale_color_manual(values = quad_cols, guide = "none") +
scale_fill_manual(values = quad_cols, guide = "none") +
scale_size_continuous(range = c(2, 6), name = "Plays with motion") +
scale_x_continuous(labels = label_number(accuracy = 0.01, style_positive = "plus")) +
scale_y_continuous(labels = percent) +
coord_cartesian(xlim = xl, ylim = yl, expand = FALSE) +
labs(title = "Pre-Snap Motion: How Often Teams Use It vs. How Well It Works",
subtitle = sprintf("Dashed lines = league medians | trend line r = %.2f (p = %.3f)",
ct$estimate, ct$p.value),
x = "Motion effectiveness (avg EPA per play with motion)",
y = "% of plays with pre-snap motion",
caption = paste0("Data: 2025 NFL Big Data Bowl (2022 season, Weeks 1–9)\n",
"Motion = ≥ 1 yard lateral movement after line_set")) +
theme_minimal(base_size = 12) +
theme(plot.title = element_text(hjust = 0.5, face = "bold"),
plot.subtitle = element_text(hjust = 0.5),
plot.caption = element_text(hjust = 0, size = 9, color = "grey40"),
plot.caption.position = "plot",
panel.grid.minor = element_blank(),
legend.position = "bottom")
The trend line slopes upward (r = 0.30), so teams that use more motion tend to get slightly better results on their motion plays. However, the relationship is not statistically significant (p = 0.093). With only 32 teams, it could be due to chance. The quadrants show why:
New Orleans used motion the least, so its average comes from the fewest plays and is the least reliable. Overall, how a team uses motion seems to matter more than how often.
Most NFL teams use pre-snap motion often, but some use it much more than others: Miami used it on about 79% of plays, while New Orleans used it on only about 34%. Teams that used motion more tended to do slightly better on those plays, but the link was weak and could be due to chance. Some teams, like Kansas City, used a lot of motion and it worked well. Others, like the Jets and 49ers, used a lot of motion without much benefit. This suggests that how a team uses motion matters more than how often.
For offensive coordinators, this metric shows how their team’s use of motion compares with the rest of the league and whether it’s helping. Teams that use a lot of motion without results should change how they use it, while teams that succeed without much motion might improve by trying more. Coaches can also study successful teams like Kansas City for ideas.
One limitation is that this project only covers nine weeks of one season. Also, good teams may succeed with or without motion, so motion alone doesn’t explain their success.