Pre-snap motion is when an offensive player moves after the offense has set and before the snap. Offenses use it to make the defense show its coverage, to create mismatches and to get blockers or receivers moving before the play starts. This project uses player tracking data from the 2025 NFL Big Data Bowl to answer one main question:
Which NFL offenses get the most out of pre-snap motion?
The analysis builds toward that answer in three steps:
Here is what pre-snap motion looks like in the tracking data. The player with the yellow ring and trail is the motion player:
gif_path <- normalizePath("motion_play.gif", mustWork = FALSE)
if (file.exists(gif_path)) {
knitr::include_graphics(gif_path)
} else {
cat("Animation file motion_play.gif not found - run the animation code in the Appendix first.")
}
(The code that builds this animation is in the Appendix at the end.)
library(tidyverse)
library(data.table)
library(scales)
library(knitr)
library(kableExtra)
games <- fread("games.csv")
plays <- fread("plays.csv")
players <- fread("players.csv")
player_play <- fread("player_play.csv")
The tracking chips are very sensitive and record movements as small
as 0.01 yards, so a player standing still still shows “movement.” For
every offensive skill player (WR, TE, RB, FB) on every play, I measured
how far he moved laterally (in y) between the moment
the offense was set (line_set) and the snap:
lat_range = max(y) - min(y) is the
full width of the movement. I use this instead of adding up every
frame-to-frame change, because summing roughly 100 frames of 0.01-yard
jitter would make players who stood still look like they moved.The tracking files are about 1 GB each, so this step processes one
week at a time and saves the results to motion_players.rds.
If that file already exists, the slow loop is skipped.
offense_keys <- plays[, .(gameId, playId, possessionTeam)]
skill_ids <- players[position %in% c("WR", "TE", "RB", "FB"), nflId]
process_week <- function(w) {
trk <- fread(paste0("tracking_week_", w, ".csv"),
select = c("gameId", "playId", "nflId", "frameId",
"frameType", "club", "event", "x", "y"))
trk <- trk[frameType %in% c("BEFORE_SNAP", "SNAP")]
ls <- trk[event == "line_set", .(lineSetFrame = min(frameId)), by = .(gameId, playId)]
sn <- trk[frameType == "SNAP", .(snapFrame = min(frameId)), by = .(gameId, playId)]
trk <- trk[nflId %in% skill_ids]
trk <- merge(trk, offense_keys, by = c("gameId", "playId"))
trk <- trk[club == possessionTeam]
trk <- merge(trk, ls, by = c("gameId", "playId"))
trk <- merge(trk, sn, by = c("gameId", "playId"))
trk <- trk[frameId >= lineSetFrame & frameId <= snapFrame]
setorder(trk, gameId, playId, nflId, frameId)
out <- trk[, .(
y_lineset = y[1],
y_snap = y[.N],
lat_range = max(y) - min(y),
lat_path = sum(abs(diff(y))),
x_range = max(x) - min(x),
n_frames = .N
), by = .(gameId, playId, nflId, possessionTeam)]
rm(trk); gc()
out
}
if (!file.exists("motion_players.rds")) {
motion_players <- rbindlist(lapply(1:9, process_week))
saveRDS(motion_players, "motion_players.rds")
}
motion_players <- readRDS("motion_players.rds")
pcts <- c(.25, .5, .75, .9, .95, .99)
data.table(Percentile = percent(pcts),
`Lateral Movement (yards)` = round(quantile(motion_players$lat_range, pcts), 2)) %>%
kable(align = "c",
caption = "Distribution of skill players' lateral movement between line set and snap") %>%
kable_styling(bootstrap_options = c("striped", "hover", "condensed"),
full_width = FALSE) %>%
row_spec(0, bold = TRUE, color = "white", background = "#2c3e50") %>%
row_spec(which(pcts >= .9), bold = TRUE, background = "#d6eaf8")
| Percentile | Lateral Movement (yards) |
|---|---|
| 25% | 0.06 |
| 50% | 0.11 |
| 75% | 0.24 |
| 90% | 4.33 |
| 95% | 9.70 |
| 99% | 17.71 |
75% of players move less than about a quarter of a yard, which is just tag noise and shuffling. Then there is a jump: by the 90th percentile, players are moving 4+ yards. That gap is where the motion cutoff should go.
ggplot(motion_players, aes(x = lat_range)) +
geom_histogram(binwidth = 0.25, fill = "steelblue", color = "white") +
geom_vline(xintercept = 2, linetype = "dashed", color = "red") +
scale_y_log10() +
coord_cartesian(xlim = c(0, 20)) +
labs(title = "Lateral movement of skill players between line set and snap",
subtitle = "Red line = 2-yard motion cutoff",
x = "Lateral movement (yards, max y - min y)",
y = "Number of player-plays (log scale)") +
theme_minimal()
To check the cutoff, I compared my definition against the NFL’s own
motion flags (motionSinceLineset /
inMotionAtBallSnap) at several thresholds:
pp_flags <- player_play[, .(gameId, playId, nflId, motionSinceLineset, inMotionAtBallSnap)]
check <- merge(motion_players, pp_flags, by = c("gameId", "playId", "nflId"))
check[, nfl_motion := motionSinceLineset %in% c(TRUE, "TRUE") |
inMotionAtBallSnap %in% c(TRUE, "TRUE")]
thresholds <- c(0.5, 1, 1.5, 2, 2.5, 3, 4, 5)
agree_tbl <- rbindlist(lapply(thresholds, function(t) {
check[, .(Threshold = t,
Flagged = sum(lat_range >= t),
Agreement = round(mean((lat_range >= t) == nfl_motion), 3),
Precision = round(mean(nfl_motion[lat_range >= t]), 3),
Recall = round(mean(lat_range[nfl_motion] >= t), 3))]
}))
agree_tbl %>%
kable(caption = "Agreement between my lateral-movement cutoff and the NFL motion flag") %>%
kable_styling(bootstrap_options = c("striped", "condensed"), full_width = FALSE)
| Threshold | Flagged | Agreement | Precision | Recall |
|---|---|---|---|---|
| 0.5 | 12747 | 0.928 | 0.578 | 0.956 |
| 1.0 | 10763 | 0.948 | 0.668 | 0.933 |
| 1.5 | 10249 | 0.951 | 0.687 | 0.913 |
| 2.0 | 9835 | 0.950 | 0.692 | 0.882 |
| 2.5 | 9426 | 0.949 | 0.692 | 0.847 |
| 3.0 | 9080 | 0.947 | 0.692 | 0.815 |
| 4.0 | 8280 | 0.942 | 0.690 | 0.741 |
| 5.0 | 7267 | 0.937 | 0.685 | 0.646 |
I chose 2 yards. Agreement with the NFL flag peaks at 1.5–2 yards (about 95%), and precision stops improving at 2 yards. A higher cutoff only misses more real motion: recall falls from 88% to 65% at 5 yards. Most players I flag that the NFL does not are probably shifts. Those are still real lateral movement, so they belong in this metric. A play counts as a motion play if at least one skill player moved 2+ yards laterally.
motion_cutoff <- 2
motion_players[, in_motion := lat_range >= motion_cutoff]
play_motion <- motion_players[, .(
motion = any(in_motion),
n_motion = sum(in_motion),
motion_yards = sum(lat_range[in_motion]),
max_motion_yards = if (any(in_motion)) max(lat_range[in_motion]) else 0
), by = .(gameId, playId, possessionTeam)]
play_motion[, .(Plays = .N), by = .(motion)][
, `Share of Plays` := percent(Plays / sum(Plays), 0.1)][
order(-motion)][
, .(`Play Type` = fifelse(motion, "With motion (2+ yds)", "No motion"),
Plays = comma(Plays), `Share of Plays`)] %>%
kable(align = c("l", "c", "c"),
caption = "How often teams used pre-snap motion") %>%
kable_styling(bootstrap_options = c("striped", "hover", "condensed"),
full_width = FALSE) %>%
row_spec(0, bold = TRUE, color = "white", background = "#2c3e50")
| Play Type | Plays | Share of Plays |
|---|---|---|
| With motion (2+ yds) | 8,481 | 53.1% |
| No motion | 7,482 | 46.9% |
About 53% of plays include motion, which is close to how often NFL teams actually used motion in 2022.
Success depends on the situation, so I used rules based on down, field position and the two-minute drill:
| Situation | 1st down | 2nd down | 3rd down | 4th down |
|---|---|---|---|---|
| Own territory | Gain > 3.49 yds | Gain ≥ half of yards to go | Convert first down | Convert |
| Opponent territory | Gain > 3.49 yds | Gain ≥ half of yards to go | Leave 4th & 1 or less | Convert |
| Two-minute drill, trailing or tied | First down or 10+ yds | same | same | same |
The 3.5 yards on 1st down comes from splitting 10 yards across 3 downs, since teams usually punt on 4th. In opponent territory, getting to 4th & 1 counts as a success on 3rd down because teams are more likely to go for it or kick a field goal there. In the last two minutes of a half, a team that is winning uses the normal rules, but a trailing or tied team needs chunk plays. I removed QB kneels, spikes, plays nullified by penalty, plays with missing values and tracking glitches (any player with more than 35 lateral yards, which is wider than a realistic sideline-to-sideline motion).
plays_clean <- merge(plays, games[, .(gameId, homeTeamAbbr, visitorTeamAbbr)], by = "gameId")
plays_clean <- plays_clean[
playNullifiedByPenalty != "Y" &
!(qbKneel %in% c(1, TRUE, "TRUE")) &
!(qbSpike %in% c(1, TRUE, "TRUE")) &
!is.na(down) & !is.na(yardsToGo) & !is.na(yardsGained) &
!is.na(yardlineNumber) & !is.na(gameClock)]
plays_clean[, `:=`(
yards_to_endzone = fifelse(yardlineNumber == 50, 50,
fifelse(yardlineSide == possessionTeam,
100 - as.numeric(yardlineNumber),
as.numeric(yardlineNumber))),
clock_sec = as.numeric(sub(":.*", "", gameClock)) * 60 +
as.numeric(sub(".*:", "", gameClock)),
off_score = fifelse(possessionTeam == homeTeamAbbr, preSnapHomeScore, preSnapVisitorScore),
def_score = fifelse(possessionTeam == homeTeamAbbr, preSnapVisitorScore, preSnapHomeScore)
)]
plays_clean[, two_min := quarter %in% c(2, 4) & clock_sec <= 120]
plays_clean[, trailing := off_score <= def_score]
plays_clean[, situation := fcase(
two_min & trailing, "Two-minute (trailing/tied)",
yards_to_endzone >= 50, "Own territory",
default = "Opponent territory")]
plays_clean[, success := fcase(
situation == "Two-minute (trailing/tied)", yardsGained >= yardsToGo | yardsGained >= 10,
down == 1, yardsGained > 3.49,
down == 2, yardsGained >= yardsToGo / 2,
down == 3 & situation == "Own territory", yardsGained >= yardsToGo,
down == 3 & situation == "Opponent territory", (yardsToGo - yardsGained) <= 1,
down == 4, yardsGained >= yardsToGo)]
analysis_df <- merge(plays_clean, play_motion, by = c("gameId", "playId", "possessionTeam"))
analysis_df <- analysis_df[max_motion_yards <= 35]
# Safety check: drop any play where field position, situation, success or motion is missing
analysis_df <- analysis_df[!is.na(yards_to_endzone) & !is.na(situation) &
!is.na(success) & !is.na(motion)]
Each play is either successful or unsuccessful, so within each group (motion or no motion) those two percentages add to 100%. The success rate compares the two groups and does not add to 100% across them, because they are two separate groups of plays.
# Confirm nothing is missing before summarizing
stopifnot(!anyNA(analysis_df$success), !anyNA(analysis_df$motion))
overall <- analysis_df[, .(Plays = .N,
Successful = sum(success),
Unsuccessful = sum(!success)), by = .(motion)][order(-motion)]
overall[, `:=`(`Success Rate` = Successful / Plays,
`Unsuccessful %` = Unsuccessful / Plays,
`Share of All Plays` = Plays / sum(Plays))]
overall[, .(`Play Type` = fifelse(motion, "With motion", "No motion"),
Plays = comma(Plays),
Successful = comma(Successful),
Unsuccessful = comma(Unsuccessful),
`Success Rate` = percent(`Success Rate`, 0.1),
`Unsuccessful %` = percent(`Unsuccessful %`, 0.1),
`Share of All Plays` = percent(`Share of All Plays`, 0.1))] %>%
rbind(data.table(`Play Type` = "Total",
Plays = comma(sum(overall$Plays)),
Successful = comma(sum(overall$Successful)),
Unsuccessful = comma(sum(overall$Unsuccessful)),
`Success Rate` = percent(sum(overall$Successful) / sum(overall$Plays), 0.1),
`Unsuccessful %` = percent(sum(overall$Unsuccessful) / sum(overall$Plays), 0.1),
`Share of All Plays` = "100.0%")) %>%
kable(align = c("l", rep("c", 6)),
caption = "League-wide results with and without pre-snap motion") %>%
kable_styling(bootstrap_options = c("striped", "hover", "condensed"), full_width = FALSE) %>%
row_spec(0, bold = TRUE, color = "white", background = "#2c3e50") %>%
row_spec(3, bold = TRUE, background = "#eaecee")
| Play Type | Plays | Successful | Unsuccessful | Success Rate | Unsuccessful % | Share of All Plays |
|---|---|---|---|---|---|---|
| With motion | 8,476 | 4,259 | 4,217 | 50.2% | 49.8% | 53.7% |
| No motion | 7,301 | 3,498 | 3,803 | 47.9% | 52.1% | 46.3% |
| Total | 15,777 | 7,757 | 8,020 | 49.2% | 50.8% | 100.0% |
by_sit <- analysis_df[, .(Plays = .N, Successful = sum(success)),
by = .(situation, motion)][order(situation, -motion)]
by_sit[, `:=`(`Success Rate` = Successful / Plays,
`Share of Situation` = Plays / sum(Plays)), by = situation]
by_sit[, .(Situation = situation,
`Play Type` = fifelse(motion, "With motion", "No motion"),
Plays = comma(Plays),
Successful = comma(Successful),
`Success Rate` = percent(`Success Rate`, 0.1),
`Share of Situation` = percent(`Share of Situation`, 0.1))] %>%
kable(align = c("l", "l", rep("c", 4)),
caption = "Results by situation (Share of Situation adds to 100% within each situation)") %>%
kable_styling(bootstrap_options = c("striped", "hover", "condensed"), full_width = FALSE) %>%
row_spec(0, bold = TRUE, color = "white", background = "#2c3e50") %>%
collapse_rows(columns = 1, valign = "middle")
| Situation | Play Type | Plays | Successful | Success Rate | Share of Situation |
|---|---|---|---|---|---|
| Opponent territory | With motion | 3,334 | 1,681 | 50.4% | 54.2% |
| No motion | 2,818 | 1,408 | 50.0% | 45.8% | |
| Own territory | With motion | 4,724 | 2,444 | 51.7% | 56.3% |
| No motion | 3,666 | 1,828 | 49.9% | 43.7% | |
| Two-minute (trailing/tied) | With motion | 418 | 134 | 32.1% | 33.8% |
| No motion | 817 | 262 | 32.1% | 66.2% |
test_df <- overall[, .(succ = Successful, n = Plays)]
pt <- prop.test(test_df$succ, test_df$n)
rate <- function(s, m) by_sit[situation == s & motion == m, percent(`Success Rate`, 0.1)]
Across the league, plays with motion succeed slightly more often than plays without it (50.2% vs. 47.9%; two-proportion test p = 0.0036). Almost all of the advantage is in a team’s own territory (51.7% vs. 49.9%). In opponent territory the difference is about zero (50.4% vs. 50.0%), and in the two-minute drill motion makes no difference (32.1% vs. 32.1%). Teams also use motion much less late in halves, which makes sense: motion takes time off the clock, and defenses play soft coverage that motion can’t do much to reveal.
The main ranking metric is success rate on motion plays. Next to it is each team’s motion lift: its success rate with motion minus its success rate without. Lift shows whether motion actually helps that offense, rather than the team just being good overall.
team_stats <- analysis_df[, .(
plays = .N,
motion_rate = mean(motion),
succ_motion = mean(success[motion]),
succ_no_motion = mean(success[!motion]),
avg_motion_yards = mean(motion_yards[motion])
), by = .(team = possessionTeam)]
team_stats[, motion_lift := succ_motion - succ_no_motion]
setorder(team_stats, -succ_motion)
team_stats[, rank := .I]
team_stats[, .(Rank = rank, Team = team,
`Motion Rate` = percent(motion_rate, 0.1),
`Success w/ Motion` = percent(succ_motion, 0.1),
`Success w/o Motion` = percent(succ_no_motion, 0.1),
`Motion Lift` = percent(motion_lift, 0.1),
`Avg Lateral Motion Yds` = round(avg_motion_yards, 1))] %>%
kable(caption = "Teams ranked by success rate on plays with pre-snap motion") %>%
kable_styling(bootstrap_options = c("striped", "hover", "condensed"), full_width = FALSE) %>%
scroll_box(height = "450px")
| Rank | Team | Motion Rate | Success w/ Motion | Success w/o Motion | Motion Lift | Avg Lateral Motion Yds |
|---|---|---|---|---|---|---|
| 1 | BUF | 56.0% | 59.9% | 48.5% | 11.4% | 10.4 |
| 2 | BAL | 64.4% | 55.8% | 49.4% | 6.4% | 8.1 |
| 3 | ATL | 69.2% | 54.5% | 52.9% | 1.6% | 12.4 |
| 4 | NE | 50.4% | 53.9% | 43.3% | 10.6% | 10.5 |
| 5 | KC | 70.4% | 53.8% | 48.1% | 5.8% | 9.7 |
| 6 | JAX | 53.5% | 53.7% | 50.8% | 2.9% | 10.5 |
| 7 | PHI | 39.0% | 52.8% | 54.3% | -1.5% | 10.6 |
| 8 | NO | 32.6% | 52.5% | 50.3% | 2.3% | 10.9 |
| 9 | CLE | 61.6% | 51.9% | 49.0% | 3.0% | 13.4 |
| 10 | MIN | 52.4% | 51.6% | 42.2% | 9.4% | 11.0 |
| 11 | MIA | 76.7% | 51.4% | 49.6% | 1.8% | 10.2 |
| 12 | TEN | 62.2% | 51.4% | 39.0% | 12.4% | 11.1 |
| 13 | LA | 59.0% | 51.3% | 46.5% | 4.8% | 10.2 |
| 14 | DET | 65.5% | 51.3% | 50.6% | 0.7% | 10.6 |
| 15 | NYG | 53.0% | 51.0% | 43.8% | 7.2% | 11.7 |
| 16 | LAC | 43.6% | 50.7% | 47.8% | 2.9% | 9.4 |
| 17 | GB | 55.2% | 50.5% | 49.4% | 1.1% | 10.3 |
| 18 | CIN | 38.6% | 50.5% | 56.0% | -5.5% | 11.1 |
| 19 | SEA | 48.6% | 50.5% | 49.4% | 1.1% | 10.7 |
| 20 | LV | 51.4% | 50.0% | 47.1% | 2.9% | 11.8 |
| 21 | SF | 69.4% | 49.2% | 55.9% | -6.7% | 11.1 |
| 22 | PIT | 47.1% | 48.2% | 46.6% | 1.6% | 10.0 |
| 23 | HOU | 47.8% | 48.0% | 43.9% | 4.1% | 12.9 |
| 24 | DAL | 50.5% | 47.1% | 51.2% | -4.1% | 11.0 |
| 25 | WAS | 52.1% | 46.9% | 46.6% | 0.3% | 10.5 |
| 26 | TB | 54.1% | 46.8% | 49.2% | -2.4% | 10.9 |
| 27 | ARI | 39.8% | 46.3% | 49.1% | -2.8% | 11.5 |
| 28 | DEN | 39.6% | 45.5% | 43.6% | 1.9% | 8.3 |
| 29 | CHI | 62.8% | 44.9% | 43.0% | 1.9% | 9.5 |
| 30 | NYJ | 70.5% | 44.5% | 46.2% | -1.6% | 10.4 |
| 31 | IND | 39.9% | 44.3% | 45.2% | -0.9% | 9.7 |
| 32 | CAR | 47.0% | 42.5% | 42.7% | -0.2% | 11.0 |
league_avg <- analysis_df[motion == TRUE, mean(success)]
ggplot(team_stats, aes(x = reorder(team, succ_motion), y = succ_motion,
fill = motion_lift > 0)) +
geom_col() +
geom_hline(yintercept = league_avg, linetype = "dashed") +
geom_text(aes(label = percent(succ_motion, 0.1)), hjust = -0.15, size = 3) +
coord_flip() +
scale_y_continuous(labels = percent, expand = expansion(mult = c(0, 0.1))) +
scale_fill_manual(values = c(`TRUE` = "steelblue", `FALSE` = "firebrick"),
labels = c(`TRUE` = "Better with motion", `FALSE` = "Worse with motion"),
name = NULL) +
labs(title = "Success rate on plays with pre-snap motion",
subtitle = "Dashed line = league average on motion plays",
x = NULL, y = "Success rate") +
theme_minimal() +
theme(legend.position = "bottom")
Buffalo, Baltimore, Atlanta, New England and Kansas City are the best on motion plays. Denver, Chicago, the Jets, Indianapolis and Carolina are the worst.
Success on motion plays partly reflects how good an offense is overall: a strong offense will succeed with or without motion. To isolate what motion itself adds, the next chart ranks teams by motion lift, their success rate with motion minus their success rate without it. A positive lift means the offense does better when it uses motion.
ggplot(team_stats, aes(x = reorder(team, motion_lift), y = motion_lift,
fill = motion_lift > 0)) +
geom_col() +
geom_hline(yintercept = 0, color = "black") +
geom_text(aes(label = percent(motion_lift, 0.1),
hjust = ifelse(motion_lift > 0, -0.15, 1.15)),
size = 3) +
coord_flip() +
scale_y_continuous(labels = percent,
expand = expansion(mult = c(0.15, 0.15))) +
scale_fill_manual(values = c(`TRUE` = "steelblue", `FALSE` = "firebrick"),
labels = c(`TRUE` = "Better with motion", `FALSE` = "Worse with motion"),
name = NULL) +
labs(title = "Motion lift: how much does motion improve each offense?",
subtitle = "Success rate with motion minus success rate without motion",
x = NULL, y = "Motion lift (percentage points)") +
theme_minimal() +
theme(legend.position = "bottom")
lift_top <- team_stats[order(-motion_lift)][1:5, team]
lift_bot <- team_stats[order(motion_lift)][1:5, team]
n_pos <- team_stats[motion_lift > 0, .N]
23 of 32 teams do better with motion than without it. The teams that gain the most from motion are TEN, BUF, NE, MIN, NYG. The teams that gain the least, or do worse with motion, are SF, CIN, DAL, ARI, TB. Comparing the two rankings separates teams that are simply strong offenses from teams whose motion is actually creating an advantage. A team ranked high on both is getting the most out of motion.
top_bottom <- rbind(
team_stats[1:5][, group := "Top 5 motion success"],
team_stats[(.N - 4):.N][, group := "Bottom 5 motion success"])
ggplot(top_bottom, aes(x = reorder(team, -succ_motion), y = avg_motion_yards, fill = group)) +
geom_col() +
geom_text(aes(label = paste0(percent(succ_motion, 1), " success")),
vjust = -0.4, size = 3) +
scale_fill_manual(values = c("Top 5 motion success" = "steelblue",
"Bottom 5 motion success" = "firebrick"),
name = NULL) +
scale_y_continuous(expand = expansion(mult = c(0, 0.12))) +
labs(title = "Do the most successful motion teams move more?",
subtitle = "Average lateral motion yards per motion play",
x = NULL, y = "Avg lateral motion yards") +
theme_minimal() +
theme(legend.position = "bottom")
Hover over any team to see its exact values. Bubble size = how often the team uses motion.
library(plotly)
ct <- cor.test(team_stats$avg_motion_yards, team_stats$succ_motion)
# Trend line
fit <- lm(succ_motion ~ avg_motion_yards, data = team_stats)
line_x <- seq(min(team_stats$avg_motion_yards), max(team_stats$avg_motion_yards), length.out = 50)
line_df <- data.frame(x = line_x,
y = predict(fit, newdata = data.frame(avg_motion_yards = line_x)))
# Alternate label positions (above / below / right / left) so neighboring teams don't overlap
sc <- copy(team_stats)[order(avg_motion_yards)]
sc[, text_pos := rep(c("top center", "bottom center", "middle right", "middle left"),
length.out = .N)]
x_pad <- diff(range(sc$avg_motion_yards)) * 0.08
y_pad <- diff(range(sc$succ_motion)) * 0.12
plot_ly(sc, height = 650) %>%
add_lines(data = line_df, x = ~x, y = ~y,
line = list(color = "grey", dash = "dash", width = 1.5),
hoverinfo = "skip", showlegend = FALSE) %>%
add_trace(data = sc, x = ~avg_motion_yards, y = ~succ_motion,
type = "scatter", mode = "markers+text",
text = ~team, textposition = ~text_pos,
textfont = list(size = 11, color = "#2c3e50"),
customdata = ~motion_rate,
marker = list(size = ~motion_rate * 45, sizemode = "diameter",
color = "steelblue", opacity = 0.7,
line = list(color = "white", width = 1)),
hovertemplate = paste0(
"<b>%{text}</b><br>",
"Avg lateral motion yards (x): %{x:.2f}<br>",
"Success rate with motion (y): %{y:.2%}<br>",
"Motion rate: %{customdata:.1%}",
"<extra></extra>"),
showlegend = FALSE) %>%
layout(title = list(text = "Lateral motion yards vs. success on motion plays"),
xaxis = list(title = "Avg lateral motion yards per motion play",
range = c(min(sc$avg_motion_yards) - x_pad,
max(sc$avg_motion_yards) + x_pad),
zeroline = FALSE),
yaxis = list(title = "Success rate with motion", tickformat = ".0%",
range = c(min(sc$succ_motion) - y_pad,
max(sc$succ_motion) + y_pad),
zeroline = FALSE),
hoverlabel = list(bgcolor = "white", font = list(size = 12)))
No. The correlation between average motion yards and motion success is essentially zero (r = 0.04, p = 0.84). Baltimore ranks 2nd while using some of the least motion yards, and Carolina ranks last while using some of the most. What matters is how motion is used, not how much ground it covers.
The animation at the top shows a successful Buffalo 1st-down play in
its own territory with one player in motion covering 8–15 yards. It is
drawn on a full NFL field with the offense in solid team colors and the
defense in white. The GIF is only rebuilt if
motion_play.gif doesn’t already exist.
library(gganimate)
library(gifski)
motion_play <- analysis_df[possessionTeam == "BUF" & motion == TRUE & success == TRUE &
n_motion == 1 & max_motion_yards >= 8 & max_motion_yards <= 15 &
down == 1 & situation == "Own territory" &
yardsGained >= 5 & yardsGained <= 15][1]
team_cols <- c(
ARI="#97233F", ATL="#A71930", BAL="#241773", BUF="#00338D", CAR="#0085CA",
CHI="#0B162A", CIN="#FB4F14", CLE="#311D00", DAL="#003594", DEN="#FB4F14",
DET="#0076B6", GB="#203731", HOU="#03202F", IND="#002C5F", JAX="#006778",
KC="#E31837", LA="#003594", LAC="#0080C6", LV="#000000", MIA="#008E97",
MIN="#4F2683", NE="#002244", NO="#D3BC8D", NYG="#0B2265", NYJ="#125740",
PHI="#004C54", PIT="#FFB612", SEA="#002244", SF="#AA0000", TB="#D50A0A",
TEN="#0C2340", WAS="#5A1414", football="#7B4A2A")
draw_field <- function(off_team, def_team, dir) {
hash_x <- setdiff(11:109, seq(10, 110, 5))
num_x <- seq(20, 100, 10)
num_lb <- c(10, 20, 30, 40, 50, 40, 30, 20, 10)
left_team <- if (dir == "right") off_team else def_team
right_team <- if (dir == "right") def_team else off_team
list(
annotate("rect", xmin = seq(10, 105, 5), xmax = seq(15, 110, 5),
ymin = 0, ymax = 53.3, fill = rep(c("#3f8a34", "#367a2d"), 10)),
annotate("rect", xmin = 0, xmax = 10, ymin = 0, ymax = 53.3,
fill = team_cols[left_team], alpha = 0.85),
annotate("rect", xmin = 110, xmax = 120, ymin = 0, ymax = 53.3,
fill = team_cols[right_team], alpha = 0.85),
annotate("text", x = 5, y = 26.65, label = left_team, angle = 90,
color = "white", size = 9, fontface = "bold"),
annotate("text", x = 115, y = 26.65, label = right_team, angle = -90,
color = "white", size = 9, fontface = "bold"),
annotate("segment", x = seq(15, 105, 5), xend = seq(15, 105, 5),
y = 0, yend = 53.3, color = "white", linewidth = 0.5),
annotate("segment", x = c(10, 110), xend = c(10, 110),
y = 0, yend = 53.3, color = "white", linewidth = 1.2),
annotate("segment", x = hash_x, xend = hash_x, y = 0.4, yend = 1.1, color = "white"),
annotate("segment", x = hash_x, xend = hash_x, y = 52.2, yend = 52.9, color = "white"),
annotate("segment", x = hash_x, xend = hash_x, y = 22.91, yend = 23.58, color = "white"),
annotate("segment", x = hash_x, xend = hash_x, y = 29.75, yend = 30.42, color = "white"),
annotate("text", x = num_x, y = 11, label = num_lb, color = "white",
size = 7, fontface = "bold"),
annotate("text", x = num_x, y = 42.3, label = num_lb, color = "white",
size = 7, fontface = "bold", angle = 180),
annotate("rect", xmin = 0, xmax = 120, ymin = 0, ymax = 53.3,
fill = NA, color = "white", linewidth = 1.5)
)
}
animate_play <- function(gid, pid, title_txt, x_window = NULL, post_frames = 40) {
wk <- games[gameId == gid, week]
trk <- fread(paste0("tracking_week_", wk, ".csv"))[gameId == gid & playId == pid]
info <- plays[gameId == gid & playId == pid]
off <- info$possessionTeam
def <- info$defensiveTeam
off_col <- unname(team_cols[off])
def_col <- unname(team_cols[def])
off_txt <- if (off %in% c("PIT", "NO")) "black" else "white"
def_txt <- if (def %in% c("PIT", "NO")) "black" else def_col
ls_f <- trk[event == "line_set", min(frameId)]
sn_f <- trk[frameType == "SNAP", min(frameId)]
trk <- trk[frameId >= ls_f - 5 & frameId <= sn_f + post_frames]
los <- trk[club == "football" & frameId == sn_f, x][1]
dir <- trk$playDirection[1]
fd <- if (dir == "right") los + info$yardsToGo else los - info$yardsToGo
mo_id <- motion_players[gameId == gid & playId == pid & in_motion == TRUE, nflId]
off_dt <- trk[club == off]
def_dt <- trk[club == def]
ball_dt <- trk[club == "football"]
mo_dt <- trk[nflId %in% mo_id]
frames <- sort(unique(trk$frameId))
mo_trk <- trk[nflId %in% mo_id, .(nflId, x, y, ord = frameId)]
setorder(mo_trk, nflId, ord)
trail_df <- rbindlist(lapply(frames, function(f) mo_trk[ord <= f][, frameId := f]))
phase_df <- data.table(frameId = frames)[
, phase := fifelse(frameId < sn_f, "PRE-SNAP", "POST-SNAP")]
xlim_use <- if (is.null(x_window)) c(0, 120) else c(los - x_window, los + x_window)
p <- ggplot() +
draw_field(off, def, dir) +
annotate("segment", x = los, xend = los, y = 0, yend = 53.3,
color = "#1e88e5", linewidth = 1.3) +
annotate("segment", x = fd, xend = fd, y = 0, yend = 53.3,
color = "#fdd835", linewidth = 1.3)
if (nrow(trail_df) > 0) {
p <- p + geom_path(data = trail_df, aes(x = x, y = y, group = nflId),
color = "#ffd600", linewidth = 1.2, alpha = 0.8)
}
p <- p +
geom_point(data = def_dt, aes(x = x, y = y),
shape = 21, size = 6, fill = "white", color = def_col, stroke = 2) +
geom_text(data = def_dt, aes(x = x, y = y, label = jerseyNumber),
color = def_txt, size = 2.6, fontface = "bold") +
geom_point(data = off_dt, aes(x = x, y = y),
shape = 21, size = 6, fill = off_col, color = "white", stroke = 0.8) +
geom_text(data = off_dt, aes(x = x, y = y, label = jerseyNumber),
color = off_txt, size = 2.6, fontface = "bold")
if (nrow(mo_dt) > 0) {
p <- p + geom_point(data = mo_dt, aes(x = x, y = y),
shape = 21, size = 8.5, fill = NA,
color = "#ffd600", stroke = 2)
}
p <- p +
geom_point(data = ball_dt, aes(x = x, y = y),
shape = 21, size = 3.5, fill = "#7B4A2A", color = "white") +
geom_label(data = phase_df, aes(x = mean(xlim_use), y = 56.3, label = phase),
fill = "black", color = "white", size = 5, fontface = "bold",
label.size = 0) +
coord_fixed(xlim = xlim_use, ylim = c(-1, 58), expand = FALSE) +
labs(title = title_txt,
subtitle = paste0(off, " offense (solid) vs ", def, " defense (white)",
" | Q", info$quarter, " ", info$gameClock,
" | ", info$down, " & ", info$yardsToGo,
" | Yards gained: ", info$yardsGained,
"\nBlue line = line of scrimmage Yellow line = first down"),
caption = str_wrap(info$playDescription, 120)) +
theme_void() +
theme(plot.background = element_rect(fill = "#24521d", color = NA),
plot.title = element_text(color = "white", face = "bold", size = 16),
plot.subtitle = element_text(color = "white", size = 10),
plot.caption = element_text(color = "white", size = 9, hjust = 0),
plot.margin = margin(10, 10, 10, 10)) +
transition_manual(frameId)
animate(p, nframes = length(frames), fps = 10,
width = 1200, height = 680, renderer = gifski_renderer())
}
if (!file.exists("motion_play.gif")) {
anim_motion <- animate_play(motion_play$gameId, motion_play$playId,
"BUF pre-snap motion (yellow ring + trail = motion player)")
anim_save("motion_play.gif", anim_motion)
}