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
Offense and defense are split, and teams are ranked on lateral payoff.
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"))
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()
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)
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()
# 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
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")
| 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 |
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 | 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 |
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))
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")
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)
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.
weeks to 1:9 for the
full sample.