What this assignment is about

Pre-snap motion is when an offensive player moves after the offense has set, before the ball is snapped. Offenses use it to make the defense show whether it is in man or zone coverage, to create mismatches, and to get a receiver moving with a head start. For this assignment I used the 2025 NFL Big Data Bowl data, which covers weeks 1 to 9 of the 2022 season, to measure how much each team uses motion and how well its offense does when it does.

How I decided whether a player was in motion

The tracking data gives every player’s x and y position 10 times a second. Since the x direction runs down the field, I looked at y, which is side to side across the field, because most motion is a player running sideways behind the line of scrimmage. I only looked at the time between the line_set event and the snap, because before the line is set players are still walking up from the huddle, and that is not motion.

The tracking tags are very sensitive and pick up tiny movements of a hundredth of a yard, so I needed a cutoff. For each offensive player I took the furthest he moved side to side between line set and the snap (his highest y minus his lowest y). If that is more than the cutoff, I count him as in motion. To choose the cutoff, I tried several values and compared each one with PFF’s own motionSinceLineset flag from the player_play file, then kept the cutoff that agreed with PFF the most often.

My metric

A play counts as a motion play if at least one offensive player was in motion. For each team I calculated:

I rank teams by their EPA on motion plays, because using motion a lot does not help unless the plays work. The best motion teams are the ones that use it often and are also successful when they do.

Load the packages and the team colors

This chunk loads the packages I need and the hex color for every NFL team, which I use to color the teams in the plots and the table.

source("https://raw.githubusercontent.com/ptallon/SportsAnalytics_Fall2026/refs/heads/main/SharedCode.R")

load_packages(c("data.table", "dplyr", "ggplot2", "ggrepel", "kableExtra", "plotly"))

# hex colors for every NFL team (got these from the nflverse team colors list)
# the Saints gold was too light to read so I used a darker gold for NO
team_colors <- c(
  ARI = "#97233F", ATL = "#A71930", BAL = "#241773", BUF = "#00338D",
  CAR = "#0085CA", CHI = "#0B162A", CIN = "#FB4F14", CLE = "#FF3C00",
  DAL = "#002244", DEN = "#002244", DET = "#0076B6", GB  = "#203731",
  HOU = "#03202F", IND = "#002C5F", JAX = "#006778", KC  = "#E31837",
  LA  = "#003594", LAC = "#007BC7", LV  = "#000000", OAK = "#000000",
  MIA = "#008E97", MIN = "#4F2683", NE  = "#002244", NO  = "#9F8958",
  NYG = "#0B2265", NYJ = "#003F2D", PHI = "#004C54", PIT = "#000000",
  SEA = "#002244", SF  = "#AA0000", TB  = "#A71930", TEN = "#4495D2",
  WAS = "#5A1414"
)

Step 1. Read in the play files

Here I read in the games, plays, players and player_play files. The plays file has the EPA for each play, and player_play has PFF’s motion flag that I use to check my cutoff. I also take out plays that would not be fair to count, like kneel downs, spikes and plays wiped out by a penalty.

games       <- fread("games.csv")
plays       <- fread("plays.csv")
players     <- fread("players.csv")
player_play <- fread("player_play.csv",
                     select = c("gameId", "playId", "nflId", "motionSinceLineset"))

# only keep normal plays (no kneels, spikes, or plays erased by a penalty)
clean_plays <- plays %>%
  filter(playNullifiedByPenalty != "Y",
         !(as.character(qbKneel) %in% c("1", "TRUE")),
         !(as.character(qbSpike) %in% c("1", "TRUE"))) %>%
  select(gameId, playId, possessionTeam, defensiveTeam, down, yardsToGo,
         isDropback, expectedPointsAdded, yardsGained) %>%
  data.frame()

cat("Plays in the data:", nrow(plays), "  Plays I kept:", nrow(clean_plays), "\n")
## Plays in the data: 16124   Plays I kept: 15936

Step 2. Measure how far each player moved between line set and the snap

The tracking files are really big (about 1 GB each), so I read them one week at a time and only keep the columns I need. For each play I find the frame where the line was set and the frame of the snap, then for each offensive player I work out how far he moved side to side in between. I also save the full tracking for one play from week 1 so I can animate it later.

weeks <- 1:9   # all 9 weeks. use fewer (like 1:3) if your computer is slow

player_moves <- NULL
anim_df      <- NULL

for (wk in weeks) {

  d <- fread(paste0("tracking_week_", wk, ".csv"),
             select = c("gameId", "playId", "nflId", "displayName", "frameId",
                        "frameType", "club", "x", "y", "event"))

  moves <- d %>%
    # only the frames before and at the snap, and no football
    filter(frameType != "AFTER_SNAP", club != "football") %>%

    # find the line set frame and the snap frame for each play
    group_by(gameId, playId) %>%
    mutate(set_frame  = suppressWarnings(min(frameId[event == "line_set"], na.rm = TRUE)),
           snap_frame = suppressWarnings(min(frameId[frameType == "SNAP"]))) %>%
    ungroup() %>%

    # keep only the frames between line set and the snap
    filter(is.finite(set_frame), is.finite(snap_frame),
           frameId >= set_frame, frameId <= snap_frame) %>%

    # how far each player moved side to side (biggest y minus smallest y)
    group_by(gameId, playId, nflId, displayName, club, set_frame, snap_frame) %>%
    summarise(lateral_move = max(y) - min(y), .groups = "drop") %>%

    # only keep the offense
    inner_join(clean_plays %>% select(gameId, playId, possessionTeam),
               by = c("gameId", "playId")) %>%
    filter(club == possessionTeam) %>%
    data.frame()

  # save one play from the first week to animate: the player who moved the
  # furthest side to side (under 20 yards so it is a normal looking motion)
  if (wk == weeks[1]) {
    pick <- moves %>%
      filter(lateral_move < 20) %>%
      arrange(-lateral_move) %>%
      head(1)

    anim_df <- d %>%
      filter(gameId == pick$gameId, playId == pick$playId,
             frameId <= pick$snap_frame + 20) %>%
      data.frame()
    anim_pick <- pick
  }

  player_moves <- rbind(player_moves, moves)

  rm(d, moves)
  invisible(gc())
  cat("Week", wk, "done\n")
}

cat("Offensive player-plays measured:", nrow(player_moves), "\n")
## Week 1 done
## Week 2 done
## Week 3 done
## Week 4 done
## Week 5 done
## Week 6 done
## Week 7 done
## Week 8 done
## Week 9 done
## Offensive player-plays measured: 173602

Step 3. Pick the motion cutoff

Next I tried cutoffs from half a yard up to 5 yards. For each one I checked how often my “in motion” call agreed with PFF’s motionSinceLineset flag for the same player on the same play.

check <- player_moves %>%
  inner_join(player_play, by = c("gameId", "playId", "nflId")) %>%
  filter(!is.na(motionSinceLineset)) %>%
  mutate(pff_motion = as.logical(motionSinceLineset))

cutoffs <- c(0.5, 1, 1.5, 2, 2.5, 3, 4, 5)

cutoff_table <- data.frame(
  cutoff    = cutoffs,
  # percent of player-plays where my call matches PFF's call
  agreement = sapply(cutoffs, function(c) round(100 * mean((check$lateral_move >= c) == check$pff_motion), 1)),
  # percent of player-plays my rule calls motion
  my_motion = sapply(cutoffs, function(c) round(100 * mean(check$lateral_move >= c), 1))
)

# use the cutoff that matches PFF the most
min_motion <- cutoff_table$cutoff[which.max(cutoff_table$agreement)]

cutoff_table %>%
  kable(format = "html",
        col.names = c("Cutoff (yards)", "Agrees with PFF (%)", "Players called in motion (%)"),
        caption = "How well each cutoff matches PFF's motion flag",
        align = c("c", "c", "c")) %>%
  kable_styling(bootstrap_options = c("striped", "hover", "condensed"),
                full_width = FALSE, position = "center") %>%
  row_spec(which(cutoff_table$cutoff == min_motion), bold = TRUE, background = "#E8F5E9")
How well each cutoff matches PFF’s motion flag
Cutoff (yards) Agrees with PFF (%) Players called in motion (%)
0.5 97.5 8.2
1.0 98.8 6.5
1.5 98.9 6.1
2.0 98.7 5.8
2.5 98.5 5.5
3.0 98.3 5.3
4.0 98.0 4.9
5.0 97.4 4.3

The best match was a cutoff of 1.5 yards, which agreed with PFF on 98.9 percent of player-plays, so that is the cutoff I use for the rest of the assignment. PFF also counts some motion that is not side to side, like a running back moving toward the line, which is probably why the agreement is not 100 percent.

Step 4. Use dplyr to build the team motion metric

Now I turn the player numbers into play numbers and then team numbers. A play is a motion play if any offensive player moved more than the cutoff. Then I group by team and summarise each team’s motion rate, how far its motion players moved, and its EPA with and without motion.

# one row per play
motion_plays <- player_moves %>%
  mutate(in_motion = lateral_move >= min_motion) %>%
  group_by(gameId, playId) %>%
  summarise(motion_players = sum(in_motion),
            motion_yards   = sum(lateral_move[in_motion]),
            .groups = "drop") %>%
  mutate(motion_play = motion_players > 0) %>%
  inner_join(clean_plays, by = c("gameId", "playId")) %>%
  data.frame()

# one row per team
teams <- motion_plays %>%
  group_by(possessionTeam) %>%
  summarise(
    plays            = n(),
    motion_plays     = sum(motion_play),
    motion_rate      = round(100 * mean(motion_play), 1),
    avg_motion_yards = round(mean(motion_yards[motion_play]), 1),
    epa_motion       = round(mean(expectedPointsAdded[motion_play],  na.rm = TRUE), 3),
    epa_no_motion    = round(mean(expectedPointsAdded[!motion_play], na.rm = TRUE), 3),
    .groups = "drop"
  ) %>%
  mutate(motion_boost = round(epa_motion - epa_no_motion, 3)) %>%
  arrange(-epa_motion) %>%                # best EPA on motion plays first
  data.frame()

cat("Teams:", nrow(teams), "  Plays:", nrow(motion_plays),
    "  Motion plays:", sum(motion_plays$motion_play), "\n")
print(head(teams, 10))
## Teams: 32   Plays: 15782   Motion plays: 8769 
##    possessionTeam plays motion_plays motion_rate avg_motion_yards epa_motion
## 1              KC   520          376        72.3              9.5      0.239
## 2             PHI   495          204        41.2             10.2      0.179
## 3             BUF   459          266        58.0             10.3      0.126
## 4             JAX   533          297        55.7             10.2      0.086
## 5             SEA   449          223        49.7             10.7      0.074
## 6             CLE   500          315        63.0             13.7      0.071
## 7             NYG   462          261        56.5             11.4      0.050
## 8             MIA   510          403        79.0             10.0      0.042
## 9             BAL   507          343        67.7              8.4      0.038
## 10            MIN   473          259        54.8             10.8      0.015
##    epa_no_motion motion_boost
## 1          0.006        0.233
## 2          0.093        0.086
## 3          0.021        0.105
## 4          0.021        0.065
## 5          0.068        0.006
## 6          0.004        0.067
## 7         -0.059        0.109
## 8          0.103       -0.061
## 9          0.035        0.003
## 10        -0.111        0.126

Across the league, 55.6 percent of plays used motion. Plays with motion averaged -0.029 EPA and plays without motion averaged -0.03 EPA.

Step 5. What motion looks like

This animation shows one play from week 1. The gold dot is the player my rule picked up as being in motion, the other offensive players are in their team color, and the defense is grey. Press play to watch him move across the formation before the snap.

off_team <- anim_pick$possessionTeam
def_team <- clean_plays$defensiveTeam[clean_plays$gameId == anim_pick$gameId &
                                      clean_plays$playId == anim_pick$playId]

# where the ball is at the snap is the line of scrimmage
los <- anim_df$x[anim_df$club == "football" & anim_df$frameId == anim_pick$snap_frame][1]

anim <- anim_df %>%
  mutate(
    group = case_when(club == "football"       ~ "Football",
                      nflId == anim_pick$nflId ~ "Motion player",
                      club == off_team         ~ paste(off_team, "offense"),
                      TRUE                     ~ paste(def_team, "defense")),
    group = factor(group, levels = c(paste(off_team, "offense"), "Motion player",
                                     paste(def_team, "defense"), "Football")),
    hover = ifelse(club == "football", "Football", displayName),
    # an id for each dot so plotly knows which dot is which from frame to frame
    dot_id = ifelse(club == "football", "football", as.character(nflId))
  )

# colors for each group: offense in team color, motion player gold, defense grey, ball brown
group_colors <- setNames(
  c(unname(team_colors[off_team]), "#F2A900", "#9E9E9E", "#8B4513"),
  levels(anim$group)
)

# white yard lines every 5 yards and a blue line of scrimmage
field_lines <- lapply(seq(10, 110, by = 5), function(v)
  list(type = "line", x0 = v, x1 = v, y0 = 0, y1 = 53.3,
       line = list(color = "white", width = 1), layer = "below"))
field_lines[[length(field_lines) + 1]] <-
  list(type = "line", x0 = los, x1 = los, y0 = 0, y1 = 53.3,
       line = list(color = "#1E88E5", width = 3), layer = "below")

p_anim <- plot_ly(anim, x = ~x, y = ~y, frame = ~frameId, ids = ~dot_id,
                  color = ~group, colors = group_colors,
                  text = ~hover, hoverinfo = "text",
                  type = "scatter", mode = "markers",
                  marker = list(size = 12, line = list(color = "white", width = 1))) %>%
  layout(
    title = list(text = paste0(anim_pick$displayName, " (", off_team, ") moves ",
                               round(anim_pick$lateral_move, 1),
                               " yards across the field before the snap<br>",
                               "<sup>Line set at frame ", anim_pick$set_frame,
                               ", snap at frame ", anim_pick$snap_frame,
                               ". Blue line = line of scrimmage</sup>")),
    shapes = field_lines,
    plot_bgcolor = "#4C8C4A",
    xaxis = list(range = c(max(0, los - 25), min(120, los + 25)), title = "",
                 showgrid = FALSE, zeroline = FALSE, showticklabels = FALSE),
    yaxis = list(range = c(0, 53.3), title = "", showgrid = FALSE,
                 zeroline = FALSE, showticklabels = FALSE),
    legend = list(orientation = "h", y = -0.05),
    margin = list(t = 80)
  ) %>%
  animation_opts(frame = 100, transition = 0, redraw = FALSE) %>%
  animation_slider(currentvalue = list(prefix = "Frame "))

p_anim

Step 6. How much each team uses motion

This bar chart sorts the teams by their motion rate, so you can see which offenses move players before the snap the most. The dashed line is the league average.

league_rate <- round(100 * mean(motion_plays$motion_play), 1)

g_bar <- ggplot(teams, aes(x = reorder(possessionTeam, motion_rate), y = motion_rate,
                           fill = possessionTeam)) +
  geom_col(width = 0.75) +
  geom_text(aes(label = paste0(motion_rate, "%")), hjust = -0.15, size = 3.3, colour = "grey25") +
  geom_hline(yintercept = league_rate, linetype = "dashed", colour = "grey40") +
  scale_fill_manual(values = team_colors, na.value = "grey50", guide = "none") +
  scale_y_continuous(expand = expansion(mult = c(0, 0.12))) +
  coord_flip() +
  labs(
    title    = "How Often Each Offense Uses Pre-Snap Motion (2022, Weeks 1-9)",
    subtitle = paste0("A play counts as motion if an offensive player moved at least ", min_motion,
                      " yards side to side after the line was set. Dashed line = league average."),
    x = NULL,
    y = "Plays with motion (%)"
  ) +
  theme_minimal() +
  theme(
    plot.title         = element_text(face = "bold"),
    plot.subtitle      = element_text(colour = "grey35"),
    panel.grid.major.y = element_blank(),
    panel.grid.minor   = element_blank()
  )

g_bar

Step 7. Using motion vs. being good with it

This is the main plot. Each team’s motion rate is on the x axis and its EPA on motion plays is on the y axis. Teams in the green area did better than average on motion plays and teams in the red area did worse. The teams in the top right are the ones I think use motion best, because they use it a lot and it works for them.

avg_x <- mean(teams$motion_rate)
avg_y <- mean(teams$epa_motion)

zone_alpha <- 0.08                      # how see-through the green and red are

g <- ggplot(teams, aes(x = motion_rate, y = epa_motion)) +

  # green box for above average teams, red box for below average
  annotate("rect", xmin = -Inf, xmax = Inf, ymin = avg_y, ymax = Inf,
           fill = "#2E7D32", alpha = zone_alpha) +
  annotate("rect", xmin = -Inf, xmax = Inf, ymin = -Inf, ymax = avg_y,
           fill = "#C62828", alpha = zone_alpha) +

  # dashed lines at the averages
  geom_hline(yintercept = avg_y, linetype = "dashed", colour = "grey60") +
  geom_vline(xintercept = avg_x, linetype = "dashed", colour = "grey60") +

  # a dot for each team in its team color. bigger dot = more motion plays
  geom_point(aes(size = motion_plays, fill = possessionTeam),
             shape = 21, colour = "white", stroke = 0.7, alpha = 0.9) +
  scale_fill_manual(values = team_colors, na.value = "grey50", guide = "none") +

  # team names next to the dots in their team color
  geom_text_repel(aes(label = possessionTeam, colour = possessionTeam),
                  size = 3.6, fontface = "bold",
                  box.padding = 0.4, min.segment.length = 0,
                  segment.colour = "grey60", seed = 1) +
  scale_colour_manual(values = team_colors, na.value = "grey20", guide = "none") +

  # little labels in the corners so you know what they mean
  annotate("text", x = Inf, y = Inf, label = "Uses motion a lot + it works",
           hjust = 1.05, vjust = 1.8, size = 3.3, colour = "grey40",
           fontface = "italic") +
  annotate("text", x = -Inf, y = -Inf, label = "Uses motion less + it works less",
           hjust = -0.05, vjust = -1, size = 3.3, colour = "grey40",
           fontface = "italic") +

  scale_size_continuous(range = c(3, 9), name = "Motion plays") +
  guides(size = guide_legend(override.aes = list(fill = "grey50"))) +

  labs(
    title    = "Pre-Snap Motion: How Often vs. How Well (2022, Weeks 1-9)",
    subtitle = paste0("Dashed lines show the league average. ",
                      "Green = better than average EPA on motion plays, red = worse."),
    x = "Plays with Motion (%)",
    y = "EPA per Play with Motion"
  ) +
  theme_minimal() +
  theme(
    plot.title       = element_text(face = "bold"),
    plot.subtitle    = element_text(colour = "grey35"),
    panel.grid.minor = element_blank()
  )

# show the plot
g

# teams that were above average in BOTH motion rate and EPA on motion plays (top right)
best_both <- teams %>%
  filter(motion_rate > avg_x, epa_motion > avg_y) %>%
  arrange(-epa_motion)

There were 9 teams that used motion more than average and also did better than average on their motion plays: KC, BUF, CLE, NYG, MIA, BAL, DET, TEN, ATL. By my definition these are the best motion offenses in the data.

Step 8. Team rankings

This table ranks every team by its EPA on motion plays. The motion boost column shows how much better (or worse) the team did on motion plays compared to its plays without motion. A positive boost is green and a negative boost is red.

# get the team color for each team (grey if I don't have the team)
name_colors <- unname(team_colors[teams$possessionTeam])
name_colors[is.na(name_colors)] <- "#333333"

# green for a positive motion boost, red for a negative one
boost_colors <- ifelse(teams$motion_boost > 0, "#2E7D32", "#C62828")

teams %>%
  select(possessionTeam, plays, motion_plays, motion_rate, avg_motion_yards,
         epa_motion, epa_no_motion, motion_boost) %>%
  mutate(
    Number = row_number(),
    .before = 1
  ) %>%
  kable(
    format = "html",
    col.names = c(
      "Rank",
      "Team",
      "Plays",
      "Motion Plays",
      "Motion Rate (%)",
      "Avg Motion (yards)",
      "EPA with Motion",
      "EPA without Motion",
      "Motion Boost"
    ),
    caption = "Teams Ranked by EPA per Play with Pre-Snap Motion (2022, 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"
  ) %>%
  # make the team names bold and in their team color
  column_spec(2, bold = TRUE, color = name_colors) %>%
  # color the motion boost green or red
  column_spec(9, bold = TRUE, color = boost_colors)
Teams Ranked by EPA per Play with Pre-Snap Motion (2022, Weeks 1-9)
Rank Team Plays Motion Plays Motion Rate (%) Avg Motion (yards) EPA with Motion EPA without Motion Motion Boost
1 KC 520 376 72.3 9.5 0.239 0.006 0.233
2 PHI 495 204 41.2 10.2 0.179 0.093 0.086
3 BUF 459 266 58.0 10.3 0.126 0.021 0.105
4 JAX 533 297 55.7 10.2 0.086 0.021 0.065
5 SEA 449 223 49.7 10.7 0.074 0.068 0.006
6 CLE 500 315 63.0 13.7 0.071 0.004 0.067
7 NYG 462 261 56.5 11.4 0.050 -0.059 0.109
8 MIA 510 403 79.0 10.0 0.042 0.103 -0.061
9 BAL 507 343 67.7 8.4 0.038 0.035 0.003
10 MIN 473 259 54.8 10.8 0.015 -0.111 0.126
11 LV 464 250 53.9 11.6 -0.002 0.037 -0.039
12 LAC 489 218 44.6 9.2 -0.009 -0.021 0.012
13 DET 471 321 68.2 10.7 -0.017 -0.117 0.100
14 NO 543 200 36.8 11.1 -0.021 -0.027 0.006
15 TEN 408 258 63.2 11.4 -0.025 -0.174 0.149
16 ATL 496 360 72.6 12.2 -0.030 0.158 -0.188
17 CHI 500 326 65.2 9.3 -0.054 -0.047 -0.007
18 NE 512 261 51.0 10.5 -0.060 -0.075 0.015
19 CIN 529 209 39.5 10.9 -0.086 0.114 -0.200
20 DAL 438 232 53.0 10.6 -0.090 0.057 -0.147
21 CAR 451 218 48.3 10.8 -0.092 -0.119 0.027
22 SF 445 312 70.1 11.1 -0.093 0.274 -0.367
23 TB 549 303 55.2 10.8 -0.097 -0.039 -0.058
24 LA 456 275 60.3 10.1 -0.108 -0.163 0.055
25 HOU 427 208 48.7 13.5 -0.113 -0.173 0.060
26 PIT 467 225 48.2 10.0 -0.124 -0.166 0.042
27 NYJ 530 378 71.3 10.5 -0.145 -0.005 -0.140
28 ARI 548 229 41.8 11.2 -0.146 0.027 -0.173
29 GB 538 307 57.1 10.1 -0.147 -0.103 -0.044
30 DEN 482 201 41.7 8.1 -0.154 -0.129 -0.025
31 WAS 560 297 53.0 10.4 -0.163 -0.084 -0.079
32 IND 571 234 41.0 9.5 -0.224 -0.155 -0.069

Does motion help more on runs or passes?

An offensive coordinator would also want to know when motion helps the most, so I split the plays into runs and passes and compared the EPA with and without motion.

motion_plays %>%
  mutate(play_type = ifelse(isDropback %in% c(TRUE, "TRUE", 1), "Pass", "Run")) %>%
  group_by(play_type) %>%
  summarise(
    plays         = n(),
    motion_rate   = round(100 * mean(motion_play), 1),
    epa_motion    = round(mean(expectedPointsAdded[motion_play],  na.rm = TRUE), 3),
    epa_no_motion = round(mean(expectedPointsAdded[!motion_play], na.rm = TRUE), 3),
    .groups = "drop"
  ) %>%
  mutate(motion_boost = round(epa_motion - epa_no_motion, 3)) %>%
  kable(
    format = "html",
    col.names = c("Play Type", "Plays", "Motion Rate (%)", "EPA with Motion",
                  "EPA without Motion", "Motion Boost"),
    caption = "Motion on Runs vs. Passes",
    align = c("l", "c", "c", "c", "c", "c")
  ) %>%
  kable_styling(bootstrap_options = c("striped", "hover", "condensed"),
                full_width = FALSE, position = "center")
Motion on Runs vs. Passes
Play Type Plays Motion Rate (%) EPA with Motion EPA without Motion Motion Boost
Pass 9618 51.0 -0.008 -0.014 0.006
Run 6164 62.6 -0.055 -0.061 0.006

What I found and how an offensive coordinator could use it

The best team by my metric was KC, who averaged 0.239 EPA per play on plays with motion and used motion on 72.3 percent of its plays. The team that used motion the most was MIA at 79 percent, and the team that used it the least was NO at 36.8 percent.

An offensive coordinator could use this in a few ways. The motion boost shows whether motion is actually helping their offense or if they are just doing it out of habit, so a team with a negative boost might need to change the type of motion they run. Comparing runs and passes shows where motion pays off the most, which can help decide when to call it. They could also look at an upcoming opponent’s defense and see how well other teams did against it with motion.

My metric has some limits. EPA depends on a lot more than motion, like the play call, the players and the defense, so a good EPA on motion plays does not prove that motion caused it. My rule also only catches side to side motion, so it can miss a running back or tight end who moves toward the line, and nine weeks is not a huge sample for each team.