Assignment Description

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.


Step 1. Load the CSV files

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

Step 2. Measure pre-snap lateral motion (Week 1 test)

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.

Step 3. Choose a cutoff for motion

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
Comparing motion cutoffs with the NFL’s motion flags (Week 1)
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)

Step 4. Apply the cutoff to all 9 weeks and build team metrics

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.

Step 5. Visualize what pre-snap motion looks like

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

Step 6. Rank the teams by how often they use motion

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 pre-snap motion rankings (2022 season, Weeks 1–9)
# 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

Step 7. Compare motion usage with motion effectiveness

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:

  • Quadrant 4 (motion pays off): Kansas City stands out, using motion on 73% of plays with the best EPA on those plays. Miami used motion the most of any team (79%) and still had above-median results.
  • Quadrant 3 (lots of motion, little payoff): the Jets and 49ers both used motion on over 70% of plays but had negative EPA. San Francisco actually did much better on plays without motion.
  • Quadrant 2 (good results, little motion): Philadelphia was very effective while using motion on only 42% of plays, likely because its offense was built around Jalen Hurts’s running.
  • Quadrant 1 (doesn’t believe in motion): Indianapolis and Denver rarely used motion and struggled when they did.

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.

Conclusion

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.