This assignment uses the 2025 NFL Big Data Bowl data, which covers weeks 1–9 of the 2022 NFL season with player tracking from the moment the offense lines up, to create a metric for pre-snap motion and rank all 32 teams on it.
Motion has become one of the most popular tools in the NFL, and some offenses put a player in motion on most snaps. But moving a lot is not the same as moving well. A team can use motion constantly and get little from it, while another uses it sparingly and gains yards almost every time.
My metric is Motion Efficiency: the yards an offense gains for every yard of pre-snap motion it runs.
\[\text{Motion Efficiency} = \frac{\sum \text{yards gained on motion plays}}{\sum \text{offensive motion yards (line set to snap)}}\]
It rewards teams whose motion produces yards, not teams that simply move the most.
source("https://raw.githubusercontent.com/ptallon/SportsAnalytics_Fall2026/main/SharedCode.R")
load_packages(c("data.table", "dplyr", "tidyr", "ggplot2", "scales", "kableExtra",
"plotly", "ggrepel", "gganimate", "gifski", "hms", "stringr", "lubridate"))
# --- 2025 NFL Big Data Bowl files (2022 season, weeks 1-9) ---
games <- fread(file.path(data_dir, "games.csv"))
plays <- fread(file.path(data_dir, "plays.csv"))
players <- fread(file.path(data_dir, "players.csv"))
player_play <- fread(file.path(data_dir, "player_play.csv"))
The data has 136 games, 16,124 plays and 354,727 player-play rows. The tracking data is split into nine weekly files of about 7 million rows each, so I process one week at a time, keep only the columns I need, and stack the per-play results.
A player counts as in motion when he is on offense
and the NFL’s player_play file flags him with any of
inMotionAtBallSnap, motionSinceLineset or
shiftSinceLineset.
His motion distance is the sum of dis
(yards moved since the previous tenth of a second) from the frame after
line_set up to and including ball_snap.
Tracking tags register movements as small as 0.01 yards, so a flagged
player only counts if he moves at least 1 yard after
the line sets. I re-run everything at 2 and 3 yards to check that the
rankings hold up.
# offensive players flagged by the NFL as moving after the line set
offense_flags <- player_play |>
inner_join(plays |> select(gameId, playId, possessionTeam), by = c("gameId", "playId")) |>
filter(teamAbbr == possessionTeam) |>
mutate(motion_flag = inMotionAtBallSnap %in% TRUE | motionSinceLineset %in% TRUE |
shiftSinceLineset %in% TRUE) |>
select(gameId, playId, nflId, motion_flag) |>
as.data.table()
# motion yards per offensive player, one week at a time
motion_one_week <- function(week) {
trk <- fread(file.path(data_dir, paste0("tracking_week_", week, ".csv")),
select = c("gameId", "playId", "nflId", "frameId", "dis", "event"))
# the line_set and ball_snap frames for every play
snaps <- trk[event == "ball_snap", .(ball_snap = min(frameId)), by = .(gameId, playId)]
lsets <- trk[event == "line_set", .(line_set = max(frameId)), by = .(gameId, playId)]
snapped <- snaps[, .(gameId, playId, week = week)] # every play with a snap
ev <- merge(snaps, lsets, by = c("gameId", "playId"))
ev <- ev[line_set < ball_snap]
# drop the football, keep the frames between line_set and the snap
trk <- trk[!is.na(nflId)]
trk <- ev[trk, on = .(gameId, playId), nomatch = 0]
trk <- trk[frameId > line_set & frameId <= ball_snap]
# only offensive players the NFL flagged as moving
trk <- offense_flags[motion_flag == TRUE][trk, on = .(gameId, playId, nflId), nomatch = 0]
per_player <- trk[, .(motion_yds = sum(dis, na.rm = TRUE)), by = .(gameId, playId, nflId)]
rm(trk); gc()
list(per_player = per_player, snapped = snapped)
}
weeks <- lapply(1:9, motion_one_week)
mover <- rbindlist(lapply(weeks, `[[`, "per_player"))
snapped <- rbindlist(lapply(weeks, `[[`, "snapped"))
rm(weeks); gc()
## used (Mb) gc trigger (Mb) limit (Mb) max used (Mb)
## Ncells 1880554 100.5 2865694 153.1 NA 2865694 153.1
## Vcells 16048973 122.5 56208792 428.9 16384 107524135 820.4
Across the nine weeks, 10,565 player-plays were flagged as motion. Here’s how far those players actually moved:
summary(mover$motion_yds)
## Min. 1st Qu. Median Mean 3rd Qu. Max.
## 0.010 4.950 8.760 9.516 12.870 112.620
cat("Share of flagged players who moved less than 1 yard (noise):",
percent(mean(mover$motion_yds < 1), 0.1))
## Share of flagged players who moved less than 1 yard (noise): 3.9%
I keep every scrimmage play that has a snap in the tracking data and drop plays wiped out by a penalty, kneel-downs and spikes, since those don’t reflect the offense’s choices. Each play gets its outcome (yards gained, expected points added, and yards after the catch for completed passes) and its total motion yards.
yac <- player_play[, .(yac = sum(yardageGainedAfterTheCatch, na.rm = TRUE)), by = .(gameId, playId)]
play_df <- plays |>
inner_join(snapped, by = c("gameId", "playId")) |>
filter(playNullifiedByPenalty != "Y", !(qbKneel %in% 1), !(qbSpike %in% TRUE)) |>
left_join(yac, by = c("gameId", "playId")) |>
transmute(gameId, playId, week, team = possessionTeam,
yardsGained, expectedPointsAdded, passResult,
pass_play = isDropback %in% TRUE,
yac = ifelse(passResult == "C", yac, NA_real_)) |>
as.data.table()
# total motion yards per play at a given noise cutoff
add_motion <- function(df, cutoff) {
m <- mover[motion_yds >= cutoff, .(motion_yds = sum(motion_yds), n_movers = .N), by = .(gameId, playId)]
out <- m[df, on = .(gameId, playId)]
out[is.na(motion_yds), `:=`(motion_yds = 0, n_movers = 0L)]
out[, motion_play := motion_yds > 0]
out
}
play_1yd <- add_motion(play_df, 1)
cat(format(nrow(play_1yd), big.mark = ","), "plays,",
format(sum(play_1yd$motion_play), big.mark = ","), "with motion (",
percent(mean(play_1yd$motion_play), 0.1), ")\n")
## 15,874 plays, 8,617 with motion ( 54.3% )
For each team, using only its motion plays, I divide total yards gained by total motion yards. Dividing totals rather than averaging per-play ratios means a play with half a yard of motion and a 40-yard gain can’t dominate the result. I also calculate:
team_metric <- function(df, min_plays = 50) {
df[, .(
plays = .N,
motion_plays = sum(motion_play),
motion_share = mean(motion_play),
motion_yds_play = mean(motion_yds[motion_play]),
motion_eff = sum(yardsGained[motion_play]) / sum(motion_yds[motion_play]),
yac_eff = sum(yac[motion_play & passResult == "C"], na.rm = TRUE) /
sum(motion_yds[motion_play & passResult == "C"]),
ypp_motion = mean(yardsGained[motion_play]),
ypp_no_motion = mean(yardsGained[!motion_play]),
epa_motion = mean(expectedPointsAdded[motion_play], na.rm = TRUE),
epa_no_motion = mean(expectedPointsAdded[!motion_play], na.rm = TRUE)
), by = team][
, `:=`(gap = ypp_motion - ypp_no_motion, epa_gap = epa_motion - epa_no_motion)][
motion_plays >= min_plays][
order(-motion_eff)][
, rank := .I][]
}
teams <- team_metric(play_1yd)
league_eff <- with(play_1yd[motion_play == TRUE], sum(yardsGained) / sum(motion_yds))
cat(nrow(teams), "teams ranked. League Motion Efficiency:", round(league_eff, 2), "yards per motion yard\n")
## 32 teams ranked. League Motion Efficiency: 0.47 yards per motion yard
ggplot(teams, aes(x = reorder(team, motion_eff), y = motion_eff, fill = motion_eff > league_eff)) +
geom_col() +
geom_hline(yintercept = league_eff, linetype = "dashed") +
annotate("text", x = 2, y = league_eff, label = "league", hjust = -0.1, vjust = -0.5, size = 3.5) +
coord_flip() +
scale_fill_manual(values = c(`TRUE` = "#1b7837", `FALSE` = "#b2182b"), guide = "none") +
labs(x = NULL, y = "Yards gained per yard of pre-snap motion",
title = "Motion Efficiency, 2022 weeks 1–9",
subtitle = "Green = above the league rate. Motion counts from line set to snap (1-yard cutoff).") +
theme_minimal(base_size = 13)
teams |>
transmute(Rank = rank, Team = team,
`Motion plays` = motion_plays,
`% plays w/ motion` = percent(motion_share, 1),
`Motion yds/play` = round(motion_yds_play, 1),
`Motion Efficiency` = round(motion_eff, 2),
`YAC Efficiency` = round(yac_eff, 2),
`Yds/play motion` = round(ypp_motion, 1),
`Yds/play no motion` = round(ypp_no_motion, 1),
Gap = round(gap, 1)) |>
kbl(align = "c", caption = "All teams ranked by Motion Efficiency (yards gained ÷ motion yards)") |>
kable_styling(bootstrap_options = c("striped", "hover", "condensed"), full_width = FALSE) |>
row_spec(1:5, bold = TRUE, background = "#e6f2e6") |>
scroll_box(height = "500px")
| Rank | Team | Motion plays | % plays w/ motion | Motion yds/play | Motion Efficiency | YAC Efficiency | Yds/play motion | Yds/play no motion | Gap |
|---|---|---|---|---|---|---|---|---|---|
| 1 | BUF | 229 | 50% | 11.0 | 0.62 | 0.39 | 6.8 | 6.3 | 0.5 |
| 2 | MIA | 394 | 77% | 11.0 | 0.61 | 0.41 | 6.7 | 5.5 | 1.2 |
| 3 | BAL | 343 | 68% | 10.0 | 0.60 | 0.42 | 6.0 | 5.1 | 0.9 |
| 4 | KC | 358 | 69% | 11.1 | 0.58 | 0.58 | 6.4 | 6.2 | 0.2 |
| 5 | JAX | 295 | 55% | 10.8 | 0.58 | 0.51 | 6.2 | 5.7 | 0.6 |
| 6 | DET | 310 | 65% | 11.9 | 0.56 | 0.52 | 6.7 | 4.6 | 2.1 |
| 7 | LAC | 214 | 43% | 10.1 | 0.53 | 0.55 | 5.4 | 5.5 | -0.2 |
| 8 | DEN | 199 | 41% | 8.7 | 0.52 | 0.77 | 4.6 | 5.4 | -0.8 |
| 9 | SEA | 222 | 49% | 11.7 | 0.52 | 0.42 | 6.0 | 6.3 | -0.3 |
| 10 | CAR | 236 | 52% | 11.1 | 0.51 | 0.69 | 5.6 | 5.4 | 0.3 |
| 11 | NE | 258 | 50% | 11.3 | 0.51 | 0.55 | 5.7 | 5.5 | 0.2 |
| 12 | PHI | 200 | 40% | 11.6 | 0.49 | 0.51 | 5.7 | 6.3 | -0.6 |
| 13 | IND | 238 | 42% | 10.4 | 0.48 | 0.57 | 5.0 | 4.6 | 0.4 |
| 14 | CHI | 317 | 63% | 10.4 | 0.48 | 0.41 | 5.0 | 5.5 | -0.6 |
| 15 | SF | 309 | 68% | 12.2 | 0.46 | 0.56 | 5.6 | 7.4 | -1.8 |
| 16 | LA | 277 | 60% | 11.0 | 0.45 | 0.64 | 5.0 | 4.8 | 0.2 |
| 17 | LV | 245 | 53% | 12.2 | 0.45 | 0.38 | 5.5 | 5.6 | -0.1 |
| 18 | GB | 300 | 55% | 11.5 | 0.45 | 0.53 | 5.2 | 5.8 | -0.6 |
| 19 | TEN | 251 | 62% | 12.6 | 0.45 | 0.36 | 5.6 | 4.4 | 1.2 |
| 20 | TB | 297 | 54% | 11.5 | 0.45 | 0.39 | 5.1 | 5.2 | 0.0 |
| 21 | DAL | 232 | 52% | 11.6 | 0.44 | 0.41 | 5.1 | 6.1 | -0.9 |
| 22 | NYJ | 373 | 69% | 11.2 | 0.44 | 0.45 | 4.9 | 6.6 | -1.7 |
| 23 | WAS | 294 | 52% | 11.5 | 0.43 | 0.47 | 5.0 | 5.3 | -0.3 |
| 24 | MIN | 253 | 53% | 12.2 | 0.43 | 0.41 | 5.3 | 5.3 | 0.0 |
| 25 | NO | 194 | 37% | 11.7 | 0.43 | 0.38 | 5.0 | 6.5 | -1.4 |
| 26 | NYG | 257 | 56% | 12.8 | 0.42 | 0.37 | 5.4 | 4.6 | 0.8 |
| 27 | PIT | 212 | 45% | 11.7 | 0.40 | 0.38 | 4.7 | 4.7 | 0.0 |
| 28 | ATL | 356 | 70% | 14.2 | 0.38 | 0.39 | 5.3 | 6.4 | -1.0 |
| 29 | CLE | 314 | 62% | 14.8 | 0.37 | 0.34 | 5.5 | 6.0 | -0.5 |
| 30 | CIN | 212 | 39% | 11.8 | 0.37 | 0.56 | 4.4 | 6.5 | -2.1 |
| 31 | ARI | 227 | 41% | 12.2 | 0.36 | 0.43 | 4.5 | 5.3 | -0.9 |
| 32 | HOU | 201 | 47% | 14.7 | 0.33 | 0.34 | 4.9 | 5.4 | -0.5 |
How much a team moves (x-axis) against how well its motion works (y-axis). The dashed lines are the league medians, which split the teams into four groups. Hover over a point for details.
med_x <- median(teams$motion_share); med_y <- median(teams$motion_eff)
q <- ggplot(teams, aes(motion_share, motion_eff,
text = paste0(team, "<br>Motion on ", percent(motion_share, 1), " of plays",
"<br>Efficiency: ", round(motion_eff, 2),
"<br>Gap vs. no motion: ", round(gap, 1), " yds"))) +
geom_vline(xintercept = med_x, linetype = "dashed", colour = "grey50") +
geom_hline(yintercept = med_y, linetype = "dashed", colour = "grey50") +
geom_point(aes(colour = gap), size = 4) +
geom_text(aes(label = team), vjust = -1.1, size = 3.2) +
annotate("text", x = max(teams$motion_share), y = max(teams$motion_eff), hjust = 1,
label = "High volume, efficient", fontface = "bold", colour = "#1b7837") +
annotate("text", x = max(teams$motion_share), y = min(teams$motion_eff), hjust = 1,
label = "High volume, wasteful", fontface = "bold", colour = "#b2182b") +
annotate("text", x = min(teams$motion_share), y = max(teams$motion_eff), hjust = 0,
label = "Low volume, efficient", fontface = "bold", colour = "#2166ac") +
annotate("text", x = min(teams$motion_share), y = min(teams$motion_eff), hjust = 0,
label = "Low on both", fontface = "bold", colour = "grey40") +
scale_x_continuous(labels = percent) +
scale_colour_gradient2(low = "#b2182b", mid = "grey80", high = "#1b7837", midpoint = 0,
name = "Yds/play gap\n(motion − none)") +
labs(x = "Share of plays with motion", y = "Motion Efficiency (yards per motion yard)",
title = "How much teams move vs. how well it works") +
theme_minimal(base_size = 13)
ggplotly(q, tooltip = "text")
teams |>
select(team, ypp_motion, ypp_no_motion, gap) |>
pivot_longer(c(ypp_motion, ypp_no_motion), names_to = "type", values_to = "ypp") |>
mutate(type = ifelse(type == "ypp_motion", "With motion", "Without motion")) |>
ggplot(aes(x = ypp, y = reorder(team, gap))) +
geom_line(aes(group = team), colour = "grey70", linewidth = 1) +
geom_point(aes(colour = type), size = 3) +
scale_colour_manual(values = c("With motion" = "#1b7837", "Without motion" = "grey40")) +
labs(x = "Yards per play", y = NULL, colour = NULL,
title = "Yards per play with vs. without motion",
subtitle = "Sorted by the gap: teams at the top gain the most extra yards when they use motion") +
theme_minimal(base_size = 13) + theme(legend.position = "top")
Across the league, plays with motion averaged -0.12 yards per play relative to plays without it (+0.004 EPA per play), and 13 of 32 teams gained more with motion than without.
The same rankings at 1-, 2- and 3-yard noise cutoffs. If the cutoff mattered a lot, the lines would cross everywhere.
sens <- rbindlist(lapply(c(1, 2, 3), function(k) {
team_metric(add_motion(play_df, k))[, .(team, rank, motion_eff, cutoff = paste0(k, "-yard cutoff"))]
}))
sens_wide <- dcast(sens, team ~ cutoff, value.var = "rank")
rho_12 <- cor(sens_wide$`1-yard cutoff`, sens_wide$`2-yard cutoff`, method = "spearman", use = "complete.obs")
rho_13 <- cor(sens_wide$`1-yard cutoff`, sens_wide$`3-yard cutoff`, method = "spearman", use = "complete.obs")
ggplot(sens, aes(x = cutoff, y = rank, group = team)) +
geom_line(colour = "grey70") +
geom_point(size = 2) +
geom_text(data = sens[cutoff == "1-yard cutoff"], aes(label = team), hjust = 1.4, size = 3) +
geom_text(data = sens[cutoff == "3-yard cutoff"], aes(label = team), hjust = -0.4, size = 3) +
scale_y_reverse(breaks = c(1, 8, 16, 24, 32)) +
labs(x = NULL, y = "Rank",
title = "Rankings at three motion cutoffs",
subtitle = sprintf("Spearman rank correlation with the 1-yard rankings: %.2f (2 yards), %.2f (3 yards)",
rho_12, rho_13)) +
theme_minimal(base_size = 13)
ggplot(teams, aes(motion_eff, yac_eff, label = team)) +
geom_smooth(method = "lm", se = FALSE, colour = "grey60", linetype = "dashed") +
geom_point(colour = "#2166ac", size = 3) +
ggrepel::geom_text_repel(size = 3.2) +
labs(x = "Motion Efficiency (all plays)", y = "YAC per motion yard (completed passes)",
title = "Do the same teams get yards after the catch from their motion?",
subtitle = sprintf("Correlation r = %.2f", cor(teams$motion_eff, teams$yac_eff, use = "complete.obs"))) +
theme_minimal(base_size = 13)
One play from the top-ranked team, BUF, animated with the class helper function. Players in motion at the snap are highlighted in black with white numbers.
The play: (9:33) (Shotgun) J.Allen pass deep right to S.Diggs for 53 yards, TOUCHDOWN. (53 yards on 20.2 yards of pre-snap motion).
# each player's path from line set to 4 seconds after the snap;
# players in motion are highlighted, dots mark where everyone stood at the snap
source("https://raw.githubusercontent.com/mlfurman3/gg_field/main/gg_field.R")
path_df <- game_df |>
filter(team != "football") |>
mutate(phase = ifelse(frameId <= f_snap, "Before snap", "After snap"),
in_motion = inMotionAtBallSnap %in% TRUE | nflId %in% movers_ex)
snap_df <- path_df |> filter(frameId == f_snap)
ggplot() +
gg_field(yardmin = max(-5, min(path_df$x) - 5), yardmax = min(125, max(path_df$x) + 5)) +
geom_path(data = path_df |> filter(!in_motion), aes(x, y, group = nflId, colour = team), alpha = 0.5) +
geom_path(data = path_df |> filter(in_motion, phase == "Before snap"), aes(x, y, group = nflId),
colour = "yellow", linewidth = 1.6) +
geom_path(data = path_df |> filter(in_motion, phase == "After snap"), aes(x, y, group = nflId),
colour = "black", linewidth = 1, linetype = "dashed") +
geom_point(data = snap_df, aes(x, y, fill = team), shape = 21, size = 5, colour = "black") +
geom_text(data = snap_df, aes(x, y, label = jerseyNumber), size = 2.6, colour = "white") +
scale_colour_manual(values = c(home = "dodgerblue1", away = "firebrick1"), guide = "none") +
scale_fill_manual(values = c(home = "dodgerblue1", away = "firebrick1"), guide = "none") +
labs(title = paste0(teams$team[1], ": pre-snap motion (yellow) and the route after the snap (dashed)")) +
theme(panel.background = element_rect(fill = "forestgreen", colour = "forestgreen"),
panel.grid = element_blank())
The same play animated (press Play, or drag the slider through the frames). Players in motion are yellow; the two teams are blue and red. Hover over a player for his jersey number.
# Animated with plotly (the frame aesthetic), which renders in the browser instead of
# building a GIF. The class helper visualize_single_play() builds the same view as a GIF:
# visualize_single_play(game_df, highlight_players_in_motion = TRUE)
anim_df <- game_df |>
mutate(in_motion = nflId %in% movers_ex | inMotionAtBallSnap %in% TRUE,
who = case_when(team == "football" ~ "football",
in_motion ~ "in motion",
TRUE ~ as.character(team)),
label = ifelse(team == "football", "ball", paste0("#", jerseyNumber)),
phase = ifelse(frameId <= f_snap, "before snap", "after snap"))
x_lo <- max(0, min(anim_df$x) - 5); x_hi <- min(120, max(anim_df$x) + 5)
yard_lines <- seq(ceiling(x_lo / 5) * 5, floor(x_hi / 5) * 5, by = 5)
field_lines <- lapply(yard_lines, function(v) list(type = "line", x0 = v, x1 = v, y0 = 0, y1 = 160/3,
line = list(color = "white", width = 1), layer = "below"))
pal <- c(home = "dodgerblue", away = "firebrick", `in motion` = "yellow", football = "saddlebrown")
tryCatch(
plot_ly(anim_df, x = ~x, y = ~y, frame = ~frameId, color = ~who, colors = pal,
text = ~paste(label, "-", phase), hoverinfo = "text",
type = "scatter", mode = "markers",
marker = list(size = 11, line = list(color = "black", width = 1))) |>
layout(shapes = field_lines, plot_bgcolor = "forestgreen",
title = paste0(teams$team[1], " motion play: line set to 4 seconds after the snap"),
xaxis = list(range = c(x_lo, x_hi), showgrid = FALSE, zeroline = FALSE, title = ""),
yaxis = list(range = c(0, 160/3), showgrid = FALSE, zeroline = FALSE, title = "",
scaleanchor = "x")) |>
animation_opts(frame = 100, transition = 0, redraw = FALSE) |>
animation_slider(currentvalue = list(prefix = "Frame ")),
error = function(e) cat("The animation could not be built on this machine; the static view above shows the same play.")
)
Motion costs something: it uses play clock, risks false starts, and tips the defense off as well as the offense. Motion Efficiency tells a coordinator whether that cost is paying off.
teams |>
transmute(Rank = rank, Team = team, `Motion plays` = motion_plays,
`Motion Efficiency` = round(motion_eff, 2),
`EPA/play motion` = round(epa_motion, 3),
`EPA/play no motion` = round(epa_no_motion, 3),
`EPA gap` = round(epa_gap, 3)) |>
kbl(align = "c") |>
kable_styling(bootstrap_options = c("striped", "condensed"), full_width = FALSE) |>
scroll_box(height = "450px")
| Rank | Team | Motion plays | Motion Efficiency | EPA/play motion | EPA/play no motion | EPA gap |
|---|---|---|---|---|---|---|
| 1 | BUF | 229 | 0.62 | 0.095 | 0.065 | 0.030 |
| 2 | MIA | 394 | 0.61 | 0.082 | -0.053 | 0.135 |
| 3 | BAL | 343 | 0.60 | 0.055 | -0.005 | 0.060 |
| 4 | KC | 358 | 0.58 | 0.263 | -0.038 | 0.301 |
| 5 | JAX | 295 | 0.58 | 0.062 | 0.055 | 0.007 |
| 6 | DET | 310 | 0.56 | 0.015 | -0.185 | 0.200 |
| 7 | LAC | 214 | 0.53 | 0.014 | -0.030 | 0.043 |
| 8 | DEN | 199 | 0.52 | -0.158 | -0.132 | -0.026 |
| 9 | SEA | 222 | 0.52 | 0.049 | 0.069 | -0.020 |
| 10 | CAR | 236 | 0.51 | -0.082 | -0.169 | 0.087 |
| 11 | NE | 258 | 0.51 | -0.078 | -0.068 | -0.010 |
| 12 | PHI | 200 | 0.49 | 0.121 | 0.137 | -0.016 |
| 13 | IND | 238 | 0.48 | -0.233 | -0.145 | -0.088 |
| 14 | CHI | 317 | 0.48 | -0.052 | -0.053 | 0.001 |
| 15 | SF | 309 | 0.46 | -0.067 | 0.142 | -0.210 |
| 16 | LA | 277 | 0.45 | -0.129 | -0.126 | -0.003 |
| 17 | LV | 245 | 0.45 | 0.041 | -0.011 | 0.052 |
| 18 | GB | 300 | 0.45 | -0.149 | -0.071 | -0.078 |
| 19 | TEN | 251 | 0.45 | 0.007 | -0.161 | 0.169 |
| 20 | TB | 297 | 0.45 | -0.076 | -0.055 | -0.021 |
| 21 | DAL | 232 | 0.44 | -0.070 | 0.028 | -0.098 |
| 22 | NYJ | 373 | 0.44 | -0.159 | 0.050 | -0.208 |
| 23 | WAS | 294 | 0.43 | -0.144 | -0.108 | -0.036 |
| 24 | MIN | 253 | 0.43 | -0.009 | -0.086 | 0.077 |
| 25 | NO | 194 | 0.43 | -0.008 | -0.036 | 0.028 |
| 26 | NYG | 257 | 0.42 | 0.045 | -0.078 | 0.123 |
| 27 | PIT | 212 | 0.40 | -0.098 | -0.198 | 0.101 |
| 28 | ATL | 356 | 0.38 | -0.022 | 0.123 | -0.145 |
| 29 | CLE | 314 | 0.37 | 0.073 | 0.004 | 0.069 |
| 30 | CIN | 212 | 0.37 | -0.158 | 0.174 | -0.333 |
| 31 | ARI | 227 | 0.36 | -0.169 | 0.051 | -0.221 |
| 32 | HOU | 201 | 0.33 | -0.147 | -0.144 | -0.003 |
The most efficient motion offense in weeks 1–9 of 2022 was BUF, gaining 0.62 yards per yard of pre-snap motion, followed by MIA (0.61) and BAL (0.6). The league rate was 0.47. The least efficient was HOU (0.33).
Moving more doesn’t mean moving better: how often a team uses motion has little to do with how efficient its motion is (r = 0.29 between share of plays with motion and Motion Efficiency).
On average, motion plays gained 0.12 fewer yards per play than non-motion plays, so motion alone isn’t a yardage boost. 13 of 32 teams gained more yards per play with motion than without.
The rankings are not an artifact of the cutoff. The rankings correlate at 1 (2-yard) and 0.99 (3-yard) with the 1-yard rankings.
Limitations and next steps. Weeks 1–9 of one season is a small sample for the low-motion teams, even with the 50-play minimum. Yards gained depends on far more than motion (play call, blocking, the quarterback), so this metric measures what motion plays produced, not what motion caused. Motion also gets more “credit” on long plays where it played little role. Next steps would be adjusting for down, distance and field position, splitting by motion type (jet, orbit, shift), and measuring how far defenders move in response, which would show which teams’ motion actually disrupts the defense.
I used generative AI (Claude, by Anthropic) as a coding and debugging
I chose the definition of Motion Efficiency and the 1-yard cutoff in my plan, made the analysis decisions, ran and checked the code, and reviewed all results and conclusions.
Data
Code
load_packages(),
load_data_for_one_week(),
visualize_single_play()): https://github.com/ptallon/SportsAnalytics_Fall2026gg_field for drawing NFL fields in
ggplot2: https://github.com/mlfurman3/gg_fieldClass recordings