The question

Does sideways motion pay off more than forward motion? Class work added up total pre-snap distance, which mixes sideways and forward movement together. This assignment splits the path into its X and Y parts and measures what each direction is actually worth.

My metric — Directional Motion Payoff

  • Lateral payoff = yards gained per yard of lateral motion (sum of |change in y|)
  • Forward payoff = yards gained per yard of forward motion (sum of |change in x|)

Offense and defense are split, and teams are ranked on lateral payoff.

Setup: load the 2025 Big Data Bowl data

Same setup as class (Oct 8). Change directory to point at your own “NFL Big Data Bowl Data 2025” folder before knitting. This version uses week 1; to use more weeks, extend the weeks vector to 1:9.

source("https://raw.githubusercontent.com/ptallon/SportsAnalytics_Fall2026/refs/heads/main/SharedCode.R")
load_packages(c("data.table", "dplyr", "ggplot2", "stringr", "scales"))

directory <- "NFL Big Data Bowl Data 2025"  # <-- change to your folder
weeks <- 1  # extend to 1:9 to use all nine weeks

df <- rbindlist(lapply(weeks, function(w) {
  fread(file.path(directory, paste0("tracking_week_", w, ".csv")))
}))
player_play <- fread(file.path(directory, "player_play.csv"))
plays       <- fread(file.path(directory, "plays.csv"))
players     <- fread(file.path(directory, "players.csv"))
games       <- fread(file.path(directory, "games.csv"))

Step 1: find the motion players

Motion is defined by the data’s own flags in player_play — no y-cutoff needed. A player counts as a motion player if any of the three flags is true. (The class code used the same flags for df1.)

motion_flags <- player_play |>
  filter(!is.na(inMotionAtBallSnap), !is.na(motionSinceLineset),
         !is.na(shiftSinceLineset)) |>
  filter(inMotionAtBallSnap %in% TRUE |
         motionSinceLineset %in% TRUE |
         shiftSinceLineset %in% TRUE) |>
  select(gameId, playId, nflId, inMotionAtBallSnap,
         motionSinceLineset, shiftSinceLineset) |>
  distinct() |>
  data.frame()

Step 2: split each motion player’s path into X and Y

For every motion player, keep only the frames from line_set to ball_snap, then add up |change in y| (lateral) and |change in x| (forward) frame by frame. This is the new work — the class used the single total distance (dis); here the path is split. Plays without a ball_snap event (false starts, etc.) are dropped first, the same way the class code did it.

One thing the real data forces: the motion flags are offense-only. Checked in the 2025 files (Nov 8) — in week 1 all 1,427 flagged player-plays are offensive players and zero are defenders, so a defense side built from flagged players would be empty. The defense side therefore follows the class code’s own definition (df2): every defender’s movement in the same line_set to ball_snap window, split the same X/Y way. Offense is the flagged motion players; defense is the whole defense reacting to them.

snap_plays <- df |>
  filter(event == "ball_snap") |>
  distinct(gameId, playId)

# Offense: flagged motion players only
off_paths <- df |>
  select(gameId, playId, nflId, frameId, x, y, event, club) |>
  inner_join(snap_plays, by = c("gameId", "playId")) |>
  inner_join(motion_flags, by = c("gameId", "playId", "nflId")) |>
  filter(club != "football") |>
  group_by(gameId, playId, nflId) |>
  filter(frameId >= min(frameId[event == "line_set"], na.rm = TRUE),
         frameId <= min(frameId[event == "ball_snap"], na.rm = TRUE)) |>
  arrange(gameId, playId, nflId, frameId) |>
  summarise(
    lateral_total = sum(abs(diff(y)), na.rm = TRUE),
    forward_total = sum(abs(diff(x)), na.rm = TRUE),
    club = first(club),
    .groups = "drop"
  ) |>
  data.frame()

# Defense: every defender's movement in the same window
# (class df2 definition — the flags are offense-only in the real data)
def_paths <- df |>
  select(gameId, playId, nflId, frameId, x, y, event, club) |>
  inner_join(snap_plays, by = c("gameId", "playId")) |>
  left_join(plays |> select(gameId, playId, possessionTeam),
            by = c("gameId", "playId")) |>
  filter(club != "football", club != possessionTeam) |>
  group_by(gameId, playId, nflId) |>
  filter(frameId >= min(frameId[event == "line_set"], na.rm = TRUE),
         frameId <= min(frameId[event == "ball_snap"], na.rm = TRUE)) |>
  arrange(gameId, playId, nflId, frameId) |>
  summarise(
    lateral_total = sum(abs(diff(y)), na.rm = TRUE),
    forward_total = sum(abs(diff(x)), na.rm = TRUE),
    club = first(club),
    .groups = "drop"
  ) |>
  data.frame()

motion_paths <- rbind(off_paths, def_paths)

Step 3: total each direction per play, offense vs defense

play_totals <- motion_paths |>
  left_join(plays |> select(gameId, playId, possessionTeam,
                            yardsGained, expectedPoints, passLength),
            by = c("gameId", "playId")) |>
  mutate(side = ifelse(club == possessionTeam, "offense", "defense")) |>
  group_by(gameId, playId, side) |>
  summarise(
    lateral_total = sum(lateral_total, na.rm = TRUE),
    forward_total = sum(forward_total, na.rm = TRUE),
    yardsGained = first(yardsGained),
    expectedPoints = first(expectedPoints),
    passLength = first(passLength),
    possessionTeam = first(possessionTeam),
    .groups = "drop"
  ) |>
  data.frame()

# reshape so each play has one row: offense and defense totals side by side
off <- play_totals |> filter(side == "offense") |>
  rename(off_lateral = lateral_total, off_forward = forward_total)
def <- play_totals |> filter(side == "defense") |>
  rename(def_lateral = lateral_total, def_forward = forward_total)
plays_xy <- off |>
  left_join(def |> select(gameId, playId, def_lateral, def_forward),
            by = c("gameId", "playId")) |>
  mutate(
    lateral_payoff = ifelse(off_lateral > 0, yardsGained / off_lateral, NA),
    forward_payoff = ifelse(off_forward > 0, yardsGained / off_forward, NA)
  ) |>
  data.frame()

Step 4: test it — is lateral payoff higher than forward payoff?

# Test 1: lateral vs forward payoff across all motion plays
# (paired test needs complete pairs — drop rows missing either payoff first)
paired <- plays_xy |>
  filter(!is.na(lateral_payoff), !is.na(forward_payoff))
mean(paired$lateral_payoff)
## [1] 0.9895594
mean(paired$forward_payoff)
## [1] 5.51857
wilcox.test(paired$lateral_payoff, paired$forward_payoff,
            paired = TRUE)
## 
##  Wilcoxon signed rank test with continuity correction
## 
## data:  paired$lateral_payoff and paired$forward_payoff
## V = 43649, p-value < 2.2e-16
## alternative hypothesis: true location shift is not equal to 0
# Test 2: does defensive sideways movement predict a bigger gain?
cor(plays_xy$def_lateral, plays_xy$yardsGained, use = "complete.obs")
## [1] 0.02627625
cor(plays_xy$def_lateral, plays_xy$expectedPoints, use = "complete.obs")
## [1] -0.0349969

Step 4b: correlation matrix (class #15, Oct 20)

Tallon’s Oct 20 class ran the full correlation matrix with p-values using rcorr() from Hmisc and presented it with kableExtra — same pipeline here, on the directional totals and payoffs, filtered to pass plays (passLength > 0) like class.

load_packages(c("Hmisc", "kableExtra"))
corr_df <- plays_xy |>
  filter(passLength > 0) |>
  select(off_lateral, off_forward, def_lateral, def_forward,
         lateral_payoff, forward_payoff, yardsGained, expectedPoints,
         passLength) |>
  select(where(is.numeric)) |>
  na.omit() |>
  as.matrix()
corr_res <- rcorr(corr_df)
# r and p side by side, the way class showed it
corr_table <- matrix(
  paste0(sprintf("%.3f", corr_res$r),
         "<br><small>(p = ", sprintf("%.3f", corr_res$P), ")</small>"),
  nrow = nrow(corr_res$r), dimnames = dimnames(corr_res$r)
)
diag(corr_table) <- "1.000"
kable(corr_table, format = "html", escape = FALSE,
      caption = "Correlation matrix: directional motion and outcomes") |>
  kable_styling(bootstrap_options = c("striped", "hover"),
                full_width = FALSE, position = "center")
Correlation matrix: directional motion and outcomes
off_lateral off_forward def_lateral def_forward lateral_payoff forward_payoff yardsGained expectedPoints passLength
off_lateral 1.000 0.658
(p = 0.000)
0.686
(p = 0.000)
0.438
(p = 0.000)
-0.217
(p = 0.000)
-0.199
(p = 0.000)
0.008
(p = 0.878)
0.007
(p = 0.891)
-0.004
(p = 0.943)
off_forward 0.658
(p = 0.000)
1.000 0.569
(p = 0.000)
0.542
(p = 0.000)
-0.140
(p = 0.004)
-0.234
(p = 0.000)
-0.016
(p = 0.746)
-0.059
(p = 0.228)
-0.040
(p = 0.417)
def_lateral 0.686
(p = 0.000)
0.569
(p = 0.000)
1.000 0.796
(p = 0.000)
-0.205
(p = 0.000)
-0.233
(p = 0.000)
-0.034
(p = 0.491)
-0.046
(p = 0.352)
-0.007
(p = 0.880)
def_forward 0.438
(p = 0.000)
0.542
(p = 0.000)
0.796
(p = 0.000)
1.000 -0.177
(p = 0.000)
-0.229
(p = 0.000)
0.027
(p = 0.584)
-0.163
(p = 0.001)
0.018
(p = 0.717)
lateral_payoff -0.217
(p = 0.000)
-0.140
(p = 0.004)
-0.205
(p = 0.000)
-0.177
(p = 0.000)
1.000 0.581
(p = 0.000)
0.267
(p = 0.000)
-0.049
(p = 0.317)
0.048
(p = 0.330)
forward_payoff -0.199
(p = 0.000)
-0.234
(p = 0.000)
-0.233
(p = 0.000)
-0.229
(p = 0.000)
0.581
(p = 0.000)
1.000 0.535
(p = 0.000)
-0.041
(p = 0.406)
0.233
(p = 0.000)
yardsGained 0.008
(p = 0.878)
-0.016
(p = 0.746)
-0.034
(p = 0.491)
0.027
(p = 0.584)
0.267
(p = 0.000)
0.535
(p = 0.000)
1.000 -0.130
(p = 0.008)
0.326
(p = 0.000)
expectedPoints 0.007
(p = 0.891)
-0.059
(p = 0.228)
-0.046
(p = 0.352)
-0.163
(p = 0.001)
-0.049
(p = 0.317)
-0.041
(p = 0.406)
-0.130
(p = 0.008)
1.000 -0.098
(p = 0.044)
passLength -0.004
(p = 0.943)
-0.040
(p = 0.417)
-0.007
(p = 0.880)
0.018
(p = 0.717)
0.048
(p = 0.330)
0.233
(p = 0.000)
0.326
(p = 0.000)
-0.098
(p = 0.044)
1.000

Step 5: rank teams on lateral payoff

Team payoff = total yards gained on motion plays divided by total lateral motion on those plays. Teams are ranked on lateral payoff — the stat no one has built yet.

team_payoff <- plays_xy |>
  group_by(possessionTeam) |>
  summarise(
    plays = n(),
    lateral_payoff = sum(yardsGained, na.rm = TRUE) /
      sum(off_lateral, na.rm = TRUE),
    forward_payoff = sum(yardsGained, na.rm = TRUE) /
      sum(off_forward, na.rm = TRUE),
    .groups = "drop"
  ) |>
  arrange(desc(lateral_payoff)) |>
  data.frame()
team_payoff |>
  mutate(across(c(lateral_payoff, forward_payoff), ~ round(.x, 2))) |>
  kable(format = "html",
        caption = "Team rankings: yards gained per yard of pre-snap motion",
        col.names = c("Team", "Motion plays", "Lateral payoff",
                      "Forward payoff")) |>
  kable_styling(bootstrap_options = c("striped", "hover"),
                full_width = FALSE, position = "center")
Team rankings: yards gained per yard of pre-snap motion
Team Motion plays Lateral payoff Forward payoff
BUF 35 0.82 4.72
CAR 29 0.81 3.74
PHI 25 0.78 2.11
KC 49 0.73 3.92
IND 43 0.70 3.24
NYG 36 0.66 3.24
DEN 34 0.65 3.53
JAX 39 0.61 3.68
TB 34 0.60 3.51
BAL 35 0.60 2.84
DET 47 0.58 3.33
LA 25 0.53 1.65
SEA 26 0.53 2.28
GB 28 0.52 2.38
MIN 34 0.50 2.37
ATL 49 0.49 1.73
MIA 45 0.47 2.48
LAC 24 0.47 3.44
NO 19 0.45 2.87
LV 37 0.44 3.00
PIT 32 0.43 1.84
WAS 39 0.43 1.99
NYJ 47 0.40 2.30
ARI 23 0.40 2.06
SF 42 0.38 1.98
TEN 31 0.37 1.98
HOU 23 0.35 2.36
NE 35 0.33 2.80
CLE 46 0.32 1.57
CIN 44 0.30 1.61
DAL 36 0.27 1.60
CHI 37 0.23 1.43

Visualization 1: grouped bar chart by team

Lateral payoff next to forward payoff, so the difference is visible at a glance.

library(tidyr)
team_payoff |>
  pivot_longer(c(lateral_payoff, forward_payoff),
               names_to = "direction", values_to = "payoff") |>
  ggplot(aes(x = reorder(possessionTeam, -payoff), y = payoff,
             fill = direction)) +
  geom_col(position = "dodge") +
  labs(title = "Directional Motion Payoff by Team",
       subtitle = "Yards gained per yard of pre-snap motion",
       x = "Team", y = "Payoff (yards gained per yard moved)",
       fill = "Direction") +
  theme_minimal() +
  theme(axis.text.x = element_text(angle = 45, hjust = 1))

Visualization 2: the defense moves with the motion

One number from Step 4b deserves its own picture: offense and defense lateral motion move together (r = 0.69). When the offense shifts sideways, the defense shifts sideways too — the motion doesn’t catch them standing still. But that defensive movement barely predicts yards gained (r = 0.03), so the defense reacting isn’t what decides the play. The chart is interactive: hover a dot to see that play’s team and yards, scroll to zoom in, drag to select an area, double-click to reset.

load_packages(c("plotly"))
p <- plays_xy |>
  ggplot(aes(x = off_lateral, y = def_lateral,
             text = paste0(possessionTeam, " — game ", gameId,
                           ", play ", playId,
                           "<br>Offense lateral: ",
                           round(off_lateral, 1), " yds",
                           "<br>Defense lateral: ",
                           round(def_lateral, 1), " yds",
                           "<br>Yards gained: ", yardsGained))) +
  geom_point(alpha = 0.4) +
  geom_smooth(method = "lm", se = FALSE) +
  labs(title = "When the offense moves sideways, the defense moves with it",
       subtitle = "Offense vs defense lateral motion per play (r = 0.69)",
       x = "Offense lateral motion (yards)",
       y = "Defense lateral motion (yards)") +
  theme_minimal()
# Interactive: hover a dot to see that play, scroll to zoom in,
# drag to select an area, double-click to reset the view.
plotly::ggplotly(p, tooltip = "text")

Visualization 3: animated play

The animation code is shown below but switched off in this knit — the GIF render stalls on this machine, so the analysis is submitted without it rather than not at all.

One play, animated frame by frame, so a reader can see sideways motion vs forward motion happening. Uses Tallon’s visualize_single_play() from SharedCode.R.

Important: the animation needs the merged data, not the raw tracking file — the function reads team, playDescription, jerseyNumber, time, and absoluteYardlineNumber, which only exist after the merge. load_data_for_one_week() does that merge for you. No separate download is needed for the animation itself: the function installs and loads gganimate and gifski on its own the first time it runs, and pulls the field-drawing code from GitHub.

The play shown is Detroit, week 1 (game 2022091104, play 4001) — 41.5 yards of lateral motion and 13 yards gained on a 5-yard pass, the kind of play this metric is built to find.

Two quirks in Tallon’s code, both found by running it. First, visualize_single_play() draws the bounding box from xmin/xmax/ymin/ymax columns it expects to already be in the data — it only adds them itself on the Voronoi path, not the plain-box path. So the box has to be built first with add_boundingbox_to_df() (same file, class #16), on the single play. Second, the highlight layer only lights up players flagged in motion at the snap (inMotionAtBallSnap); the first play tried here (play 1871) had its motion flagged the other two ways and none at the snap, so the highlight layer was empty and gganimate crashed while rendering. Play 4001 has a player in motion at the snap, so the highlight works.

merged_week1 <- load_data_for_one_week(directory, weekNumber = 1,
                                       merge = TRUE)
one_play <- merged_week1 |> filter(gameId == 2022091104, playId == 4001)
one_play <- add_boundingbox_to_df(one_play)
# Render fix (Nov 10): the full play is 100+ frames and the merged
# week-1 data is several GB in memory, which stalls the GIF render on
# an 8 GB machine. Two changes: keep every 2nd frame (done AFTER the
# bounding box, so the snap frame is found correctly), and drop the big
# tables from memory before rendering. The animation still shows the
# same play, just at half the frame rate.
one_play <- one_play |> filter(frameId %% 2 == 1)
rm(df, merged_week1, player_play, plays, players, games,
   motion_flags, motion_paths, play_totals, off, def,
   paired, corr_df, team_payoff, plays_xy)
gc()
visualize_single_play(one_play,
                      highlight_players_in_motion = TRUE,
                      showBoundingBox = TRUE)

What this means

The answer to the title question is no — sideways motion does not pay off more. Across 1,128 motion plays, forward payoff averages 5.52 yards gained per yard of forward motion, while lateral payoff averages 0.99 (medians 1.74 vs 0.31). The gap is real, not noise: the paired Wilcoxon test gives p < 0.001. So a yard of motion toward the line buys about five and a half times what a yard of sideways motion buys.

That flips the obvious play-calling idea. Jet and cross-formation motion look flashy, but on their own they mostly just drag the defense sideways with the offense (r = 0.69) without buying yards — defensive lateral motion barely predicts yards gained at all (r = 0.03). The motion that pays is the kind that gets a player moving forward. The team ranking tells the same story from the other side: Buffalo gets the most out of its lateral motion (0.82 yards gained per lateral yard) and Chicago the least (0.23), so how a team uses motion matters as much as how much it uses. That is a coordinator’s answer, not just a ranking.

Limitations

  • Week 1 only in this draft — extend weeks to 1:9 for the full sample.
  • Payoff divides by motion totals, so plays with no motion in a direction are excluded from that direction’s average.
  • Yards gained credits the whole play to pre-snap motion; the payoff is descriptive, not causal proof.