Introduction

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:

  1. What counts as motion? An objective cutoff for when a player is really in motion.
  2. Does motion work? Whether plays with motion are more successful than plays without it.
  3. Who benefits most? Teams ranked by how well they do on motion plays, and by how much motion improves them.

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.)

Data & Packages

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")

Step 1: Measuring Lateral Movement

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")
Distribution of skill players’ lateral movement between line set and snap
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.

Step 2: Choosing the Motion Cutoff

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)
Agreement between my lateral-movement cutoff and the NFL motion flag
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")
How often teams used pre-snap motion
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.

Step 3: Defining a “Successful” Play

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)]

Step 4: Does Motion Work?

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")
League-wide results with and without pre-snap motion
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")
Results by situation (Share of Situation adds to 100% within each situation)
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.

Step 5: Ranking the Teams

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")
Teams ranked by success rate on plays with pre-snap motion
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.

Who gains the most from motion?

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.

Do the best motion teams simply move more?

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.

How Offensive Coordinators Can Use This

  • Use motion early in drives, on your own side of the field. That is where the success gain shows up. Motion forces the defense to show man or zone coverage before the snap, which matters most when the whole playbook is available.
  • Don’t count on motion in the two-minute drill. It costs clock and gives no measurable advantage when trailing late.
  • More motion is not better motion. Long, cross-formation motions didn’t make teams more successful. Coordinators should judge motion by its purpose (a reveal, a mismatch, a running start) and not by distance.
  • Use motion lift to evaluate your own team and scout opponents. A team with negative lift may be using motion as a tell or running it without a clear purpose. A defense facing a high-lift team (like the top 5 here) should practice communicating and adjusting to motion.

Limitations

  • “Success” is my own definition. Other reasonable rules (or EPA) could change the rankings.
  • Each team has only about 250 motion plays in 9 weeks, so differences of a few percentage points between teams could be noise.
  • This shows association, not cause: teams may choose to use motion on plays that were already likely to work.
  • The 2-yard cutoff counts shifts as well as true motion, because both are lateral pre-snap movement.

Appendix: Animation Code

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)
}