Pre-snap motion is one of the most visible tools an offense has for creating confusion before the ball is snapped. For this project I used the 2025 NFL Big Data Bowl data (weeks 1 to 9 of the 2023 season) to build a motion metric, rank teams on it, and test whether motion is actually associated with better offensive results.
The analysis has two parts. The first measures how much each team uses motion, defined as having a player in motion at the moment of the snap. The second asks whether motion pays off, using a binary logistic regression that controls for down, distance, score differential, and quarter. I also checked whether any individual team gets a different payoff from motion than the rest of the league, tested the results with a second definition of success, and used a discriminant analysis to check a quadrant classification of teams. The short version of the findings: teams differ a lot in how often they use motion, but I could not detect that motion changes the chance a play succeeds, for the league or for any single team.
The player-level file records whether each player was in motion at the snap. I joined it to the play file to identify which team had the ball, kept only offensive players, and collapsed the data to one row per play. A play counts as a motion play if at least one offensive player was moving at the snap. I chose the at-the-snap flag because it matches the benchmark counts from class (Tennessee at 75 motion plays) and because motion at the snap is the movement a defense actually has to react to.
source("https://raw.githubusercontent.com/ptallon/SportsAnalytics_Fall2026/refs/heads/main/SharedCode.R")
load_packages(c("data.table", "dplyr", "ggplot2", "stringr", "scales", "hms", "kableExtra", "sf", "plotly"))
player_play <- fread("player_play.csv")
plays <- fread("plays.csv")
games <- fread("games.csv")
players <- fread("players.csv")
The next chunk attaches the team with the ball to each player row, keeps only offensive players, and builds the one-row-per-play motion table.
# attach the team with the ball to each player row
player_play_context <- player_play %>%
left_join(
plays %>% select(gameId, playId, possessionTeam),
by = c("gameId", "playId")
)
# offensive players only
offensive_players <- player_play_context %>%
filter(teamAbbr == possessionTeam)
# one row per play, motion = a player moving at the snap
motion_by_play <- offensive_players %>%
group_by(gameId, playId) %>%
summarise(
motion = any(inMotionAtBallSnap %in% TRUE, na.rm = TRUE),
shift = any(shiftSinceLineset %in% TRUE, na.rm = TRUE),
motion_players = sum(inMotionAtBallSnap %in% TRUE, na.rm = TRUE),
.groups = "drop"
)
At this point every play has a yes or no motion flag and a count of how many offensive players were moving at the snap. The next section attaches the game context and the success measures to these flags.
Next I merged the motion flags back onto every play and added the
game context needed for the models. A play counts as a success if it
added positive expected points for the offense
(expectedPointsAdded > 0). As a second, independent
definition I also used win probability added, picking the home or
visitor column depending on which team had the ball. Score differential
is measured from the offense’s point of view.
analysis_data <- plays %>%
left_join(motion_by_play, by = c("gameId", "playId")) %>%
left_join(
games %>% select(gameId, week, gameDate, homeTeamAbbr, visitorTeamAbbr),
by = "gameId"
)
analysis_data <- analysis_data %>%
mutate(
motion = ifelse(is.na(motion), FALSE, motion),
shift = ifelse(is.na(shift), FALSE, shift),
motion_players = ifelse(is.na(motion_players), 0, motion_players),
# score difference from the offense's view
score_diff = case_when(
possessionTeam == homeTeamAbbr ~ preSnapHomeScore - preSnapVisitorScore,
possessionTeam == visitorTeamAbbr ~ preSnapVisitorScore - preSnapHomeScore,
TRUE ~ NA_real_
),
# win probability added for the offense
wpa = case_when(
possessionTeam == homeTeamAbbr ~ homeTeamWinProbabilityAdded,
possessionTeam == visitorTeamAbbr ~ visitorTeamWinProbilityAdded, # spelled this way in plays.csv
TRUE ~ NA_real_
),
# success 1: positive expected points added
success = case_when(
!is.na(expectedPointsAdded) & expectedPointsAdded > 0 ~ 1,
!is.na(expectedPointsAdded) & expectedPointsAdded <= 0 ~ 0,
TRUE ~ NA_real_
),
# success 2: positive win probability added
success_wpa = case_when(
!is.na(wpa) & wpa > 0 ~ 1,
!is.na(wpa) & wpa <= 0 ~ 0,
TRUE ~ NA_real_
)
)
I also removed plays that are not real offensive plays (kneels, spikes, and plays nullified by a penalty) and any row missing an input the models need. The chunk below applies those filters and prints how many plays are left.
# drop kneels, spikes, nullified plays, and missing values
analysis_data <- analysis_data %>%
filter(
!(qbKneel %in% 1),
!(qbSpike %in% TRUE),
!(playNullifiedByPenalty %in% "Y"),
!is.na(possessionTeam),
!is.na(down),
!is.na(yardsToGo),
!is.na(score_diff),
!is.na(quarter),
!is.na(success),
!is.na(success_wpa)
)
cat("Analysis plays:", nrow(analysis_data), "\n")
## Analysis plays: 15936
table(analysis_data$motion)
##
## FALSE TRUE
## 11458 4478
The filters leave 15,936 plays out of 16,124, so only about 1 percent of plays were removed. Of those, 4,478 had a player in motion at the snap and 11,458 did not, so motion was used on roughly 28 percent of plays. That gives both groups plenty of data for the comparisons that follow.
This is the volume ranking: what share of each team’s offensive plays had a player in motion at the snap. The table lists every team, and I checked it against the benchmark counts from class for Miami and Tennessee.
team_motion_summary <- analysis_data %>%
group_by(possessionTeam) %>%
summarise(
total_plays = n(),
motion_plays = sum(motion, na.rm = TRUE),
motion_rate = motion_plays / total_plays,
avg_motion_players = ifelse(motion_plays > 0, mean(motion_players[motion], na.rm = TRUE), 0),
.groups = "drop"
) %>%
arrange(desc(motion_rate))
print(team_motion_summary, n = 32)
## # A tibble: 32 × 5
## possessionTeam total_plays motion_plays motion_rate avg_motion_players
## <chr> <int> <int> <dbl> <dbl>
## 1 MIA 512 271 0.529 1.04
## 2 KC 522 246 0.471 1
## 3 NYJ 539 223 0.414 1.01
## 4 BAL 509 184 0.361 1.03
## 5 TB 553 193 0.349 1.05
## 6 SF 452 151 0.334 1.01
## 7 LA 460 153 0.333 1
## 8 WAS 564 187 0.332 1.01
## 9 DAL 444 139 0.313 1.03
## 10 JAX 537 168 0.313 1.02
## 11 ATL 505 156 0.309 1
## 12 BUF 460 141 0.307 1.03
## 13 LV 465 135 0.290 1.05
## 14 DET 474 134 0.283 1.01
## 15 GB 544 151 0.278 1.01
## 16 NYG 463 128 0.276 1.05
## 17 MIN 479 131 0.273 1.05
## 18 CHI 505 136 0.269 1.03
## 19 LAC 496 132 0.266 1.03
## 20 DEN 484 123 0.254 1
## 21 NE 517 124 0.240 1
## 22 CLE 506 120 0.237 1.02
## 23 NO 544 128 0.235 1.04
## 24 PIT 477 104 0.218 1
## 25 CAR 455 96 0.211 1.06
## 26 SEA 452 94 0.208 1
## 27 ARI 563 112 0.199 1
## 28 HOU 429 82 0.191 1
## 29 IND 574 108 0.188 1.03
## 30 TEN 411 75 0.182 1
## 31 PHI 498 75 0.151 1
## 32 CIN 543 78 0.144 1.05
# benchmark check from class
team_motion_summary %>% filter(possessionTeam == "MIA" | possessionTeam == "TEN")
## # A tibble: 2 × 5
## possessionTeam total_plays motion_plays motion_rate avg_motion_players
## <chr> <int> <int> <dbl> <dbl>
## 1 MIA 512 271 0.529 1.04
## 2 TEN 411 75 0.182 1
Miami leads the league, using motion on 271 of its 512 plays (52.9 percent), followed by Kansas City (47.1 percent) and the New York Jets (41.4 percent). Cincinnati (14.4 percent), Philadelphia (15.1 percent), and Tennessee (18.2 percent) use it least. Tennessee’s 75 motion plays match the benchmark count from class exactly, and Miami’s 271 is close to the roughly 281 expected. The average number of players in motion is almost always about one, so teams differ in how often they use motion, not in how many players they send. The chart below shows the full ranking, with darker bars for heavier motion usage.
ggplot(team_motion_summary, aes(x = reorder(possessionTeam, motion_rate),
y = motion_rate, fill = motion_rate)) +
geom_col() +
geom_text(aes(label = percent(motion_rate, accuracy = 1)), hjust = -0.15, size = 3) +
coord_flip() +
scale_fill_gradient(low = "#9ecae1", high = "#08306b", guide = "none") +
scale_y_continuous(labels = percent_format(), limits = c(0, 0.6)) +
labs(title = "NFL Teams Ranked by Pre-Snap Motion Usage",
subtitle = "Share of offensive plays with a player in motion at the snap",
x = "Team", y = "Percentage of Offensive Plays Using Motion") +
theme_minimal() +
theme(plot.title = element_text(face = "bold"))
The gap between the top and bottom is large. Miami uses motion on more than half of its plays, which is about 3.5 times as often as Cincinnati. This ranking is the clearest result in the project. The next sections ask whether all that motion actually helps.
Using motion a lot only matters if it helps, so the next question is whether motion plays succeed more often. The table and chart compare the success rate and the average expected points added for plays with and without motion across the whole league.
motion_success_summary <- analysis_data %>%
group_by(motion) %>%
summarise(
total_plays = n(),
successful_plays = sum(success == 1),
success_rate = mean(success),
average_EPA = mean(expectedPointsAdded, na.rm = TRUE),
.groups = "drop"
)
print(motion_success_summary)
## # A tibble: 2 × 5
## motion total_plays successful_plays success_rate average_EPA
## <lgl> <int> <int> <dbl> <dbl>
## 1 FALSE 11458 4881 0.426 -0.0411
## 2 TRUE 4478 1967 0.439 0.000775
ggplot(motion_success_summary,
aes(x = factor(motion, levels = c(FALSE, TRUE), labels = c("No Motion", "Motion")),
y = success_rate, fill = motion)) +
geom_col(width = 0.6) +
geom_text(aes(label = percent(success_rate, accuracy = 0.1)), vjust = -0.5, size = 4) +
scale_fill_manual(values = c("FALSE" = "#f39c12", "TRUE" = "#1f4e79"), guide = "none") +
scale_y_continuous(labels = percent_format(), limits = c(0, 0.5)) +
labs(title = "Offensive Success With and Without Motion",
x = "Play Type", y = "Success Rate") +
theme_minimal() +
theme(plot.title = element_text(face = "bold"))
Motion plays succeeded 43.9 percent of the time compared with 42.6 percent for plays without motion, a gap of about 1.3 percentage points. Average expected points added was about 0.001 with motion and negative 0.041 without. The gap is small and the two bars look almost the same. It also does not yet account for the situation each play was run in, since teams may use motion more on certain downs or distances. The regression in Section 6 handles that.
Here I split the same comparison by team. The table shows each team’s success rate and average expected points added with and without motion.
team_success_summary <- analysis_data %>%
group_by(possessionTeam, motion) %>%
summarise(
plays = n(),
success_rate = mean(success),
average_EPA = mean(expectedPointsAdded, na.rm = TRUE),
.groups = "drop"
)
print(team_success_summary, n = 64)
## # A tibble: 64 × 5
## possessionTeam motion plays success_rate average_EPA
## <chr> <lgl> <int> <dbl> <dbl>
## 1 ARI FALSE 451 0.424 -0.0293
## 2 ARI TRUE 112 0.438 -0.0884
## 3 ATL FALSE 349 0.479 0.0275
## 4 ATL TRUE 156 0.449 0.00683
## 5 BAL FALSE 325 0.449 -0.00695
## 6 BAL TRUE 184 0.473 0.127
## 7 BUF FALSE 319 0.464 0.00260
## 8 BUF TRUE 141 0.546 0.256
## 9 CAR FALSE 359 0.368 -0.134
## 10 CAR TRUE 96 0.344 -0.0906
## 11 CHI FALSE 369 0.396 -0.0914
## 12 CHI TRUE 136 0.426 0.0616
## 13 CIN FALSE 465 0.465 0.0744
## 14 CIN TRUE 78 0.436 -0.135
## 15 CLE FALSE 386 0.461 0.00443
## 16 CLE TRUE 120 0.433 0.185
## 17 DAL FALSE 305 0.449 -0.0336
## 18 DAL TRUE 139 0.432 -0.00705
## 19 DEN FALSE 361 0.360 -0.130
## 20 DEN TRUE 123 0.382 -0.181
## 21 DET FALSE 340 0.409 -0.112
## 22 DET TRUE 134 0.425 0.0942
## 23 GB FALSE 393 0.433 -0.0936
## 24 GB TRUE 151 0.437 -0.176
## 25 HOU FALSE 347 0.380 -0.167
## 26 HOU TRUE 82 0.488 -0.0550
## 27 IND FALSE 466 0.371 -0.217
## 28 IND TRUE 108 0.435 -0.0486
## 29 JAX FALSE 369 0.485 0.0627
## 30 JAX TRUE 168 0.494 0.0508
## 31 KC FALSE 276 0.428 0.0828
## 32 KC TRUE 246 0.496 0.275
## 33 LA FALSE 307 0.404 -0.188
## 34 LA TRUE 153 0.412 -0.00816
## 35 LAC FALSE 364 0.451 0.0342
## 36 LAC TRUE 132 0.402 -0.124
## 37 LV FALSE 330 0.455 0.0717
## 38 LV TRUE 135 0.444 -0.123
## 39 MIA FALSE 241 0.461 0.109
## 40 MIA TRUE 271 0.446 0.0108
## 41 MIN FALSE 348 0.428 -0.0856
## 42 MIN TRUE 131 0.489 0.0678
## 43 NE FALSE 393 0.427 -0.0426
## 44 NE TRUE 124 0.395 -0.163
## 45 NO FALSE 416 0.464 -0.0657
## 46 NO TRUE 128 0.469 0.117
## 47 NYG FALSE 335 0.424 0.0243
## 48 NYG TRUE 128 0.406 -0.0668
## 49 NYJ FALSE 316 0.361 -0.156
## 50 NYJ TRUE 223 0.426 -0.00755
## 51 PHI FALSE 423 0.480 0.167
## 52 PHI TRUE 75 0.467 -0.0802
## 53 PIT FALSE 373 0.399 -0.168
## 54 PIT TRUE 104 0.346 -0.0968
## 55 SEA FALSE 358 0.427 0.0418
## 56 SEA TRUE 94 0.521 0.156
## 57 SF FALSE 301 0.435 0.0154
## 58 SF TRUE 151 0.404 -0.0334
## 59 TB FALSE 360 0.428 -0.0468
## 60 TB TRUE 193 0.409 -0.103
## 61 TEN FALSE 336 0.402 -0.0792
## 62 TEN TRUE 75 0.453 -0.0137
## 63 WAS FALSE 377 0.369 -0.131
## 64 WAS TRUE 187 0.396 -0.120
The results are mixed. Eighteen teams had a higher success rate with motion and fourteen had a lower one, which is close to a coin flip. Houston, Seattle, Buffalo, and Kansas City gained the most, while Pittsburgh, the Chargers, and New England dropped. Miami is a useful example: it uses motion more than any other team, yet its success rate is slightly lower with motion (44.6 percent) than without (46.1 percent). Kansas City pairs heavy use with a clear gain (49.6 percent versus 42.8 percent). Most teams have only 75 to 270 motion plays, so these raw gaps are noisy, which is the reason for the formal tests that follow.
ggplot(team_success_summary, aes(x = possessionTeam, y = success_rate, fill = motion)) +
geom_col(position = "dodge") +
coord_flip() +
scale_fill_manual(values = c("FALSE" = "#f39c12", "TRUE" = "#1f4e79"),
labels = c("No Motion", "Motion")) +
scale_y_continuous(labels = percent_format()) +
labs(title = "Team Success Rates With and Without Motion",
x = "Team", y = "Success Rate", fill = NULL) +
theme_minimal() +
theme(plot.title = element_text(face = "bold"))
To test the motion effect fairly, I fit a binary logistic regression predicting whether a play succeeded from whether it had motion, while controlling for down, yards to go, score differential, and quarter. Down is treated as a category because each down is not a fixed step.
motion_model <- glm(
success ~ motion + factor(down) + yardsToGo + score_diff + quarter,
data = analysis_data,
family = binomial
)
summary(motion_model)
##
## Call:
## glm(formula = success ~ motion + factor(down) + yardsToGo + score_diff +
## quarter, family = binomial, data = analysis_data)
##
## Coefficients:
## Estimate Std. Error z value Pr(>|z|)
## (Intercept) 0.402221 0.065796 6.113 9.77e-10 ***
## motionTRUE 0.025096 0.036113 0.695 0.487095
## factor(down)2 -0.100886 0.038619 -2.612 0.008991 **
## factor(down)3 -0.198168 0.046361 -4.274 1.92e-05 ***
## factor(down)4 -0.201852 0.120565 -1.674 0.094089 .
## yardsToGo -0.067967 0.004714 -14.419 < 2e-16 ***
## score_diff -0.006474 0.001770 -3.658 0.000254 ***
## quarter -0.022014 0.014315 -1.538 0.124074
## ---
## Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
##
## (Dispersion parameter for binomial family taken to be 1)
##
## Null deviance: 21776 on 15935 degrees of freedom
## Residual deviance: 21534 on 15928 degrees of freedom
## AIC: 21550
##
## Number of Fisher Scoring iterations: 4
The controls behave sensibly. More yards to go lowers the odds of success, later downs are harder than first down, and teams that are ahead have slightly lower success rates, which fits with leading teams playing conservatively. The motion coefficient is small (0.025) and not statistically significant (p = 0.49). The next chunk converts the coefficients to odds ratios, which are easier to interpret.
motion_odds_ratios <- exp(coef(motion_model))
print(motion_odds_ratios)
## (Intercept) motionTRUE factor(down)2 factor(down)3 factor(down)4
## 1.4951420 1.0254135 0.9040356 0.8202320 0.8172159
## yardsToGo score_diff quarter
## 0.9342909 0.9935470 0.9782263
The odds ratio for motion is about 1.03, meaning motion plays had roughly 3 percent higher odds of success than comparable plays without motion. Since the effect is not significant, the data are consistent with a small benefit, no effect, or a small harm. After accounting for the situation, motion plays do not succeed at a detectably different rate than other plays.
An average effect near zero could hide real differences between teams, with some benefiting and others hurt. To test this, I let each team have its own motion effect by interacting motion with team. The first comparison checks the full model against the league-wide model.
team_motion_model <- glm(
success ~ motion * possessionTeam + factor(down) + yardsToGo + score_diff + quarter,
data = analysis_data,
family = binomial
)
# includes team baselines AND team motion effects
anova(motion_model, team_motion_model, test = "Chisq")
## Analysis of Deviance Table
##
## Model 1: success ~ motion + factor(down) + yardsToGo + score_diff + quarter
## Model 2: success ~ motion * possessionTeam + factor(down) + yardsToGo +
## score_diff + quarter
## Resid. Df Resid. Dev Df Deviance Pr(>Chi)
## 1 15928 21534
## 2 15866 21424 62 109.61 0.0001845 ***
## ---
## Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
This comparison is significant (p = 0.0002), but it is easy to misread. The full model adds two things at once: a baseline for each team and a separate motion effect for each team. The significant result could come entirely from teams having different overall success rates. To isolate the motion effects, I added a middle model that gives each team its own baseline but one shared motion effect, and compared it to the full model.
team_base_model <- glm(
success ~ motion + possessionTeam + factor(down) + yardsToGo + score_diff + quarter,
data = analysis_data,
family = binomial
)
# the real test, team motion effects beyond team baselines
anova(team_base_model, team_motion_model, test = "Chisq")
## Analysis of Deviance Table
##
## Model 1: success ~ motion + possessionTeam + factor(down) + yardsToGo +
## score_diff + quarter
## Model 2: success ~ motion * possessionTeam + factor(down) + yardsToGo +
## score_diff + quarter
## Resid. Df Resid. Dev Df Deviance Pr(>Chi)
## 1 15897 21445
## 2 15866 21424 31 21.245 0.9053
This is the comparison that answers the question, and it is not significant (p = 0.91). Letting motion work differently for each team adds nothing beyond what chance would produce. The deviance from the 31 extra terms is even smaller than the roughly 31 points you would expect from pure noise. There is no evidence that any team gets a different payoff from motion than the rest of the league.
Even though the test above says team differences are not distinguishable from chance, it is still useful to see the estimates. For every play I predicted the chance of success with motion and without motion, then averaged the difference by team.
# predicted success with and without motion for every play
with_motion <- analysis_data %>% mutate(motion = TRUE)
without_motion <- analysis_data %>% mutate(motion = FALSE)
team_benefit <- analysis_data %>%
mutate(
p_motion = predict(team_motion_model, newdata = with_motion, type = "response"),
p_no_motion = predict(team_motion_model, newdata = without_motion, type = "response")
) %>%
group_by(possessionTeam) %>%
summarise(motion_benefit = mean(p_motion - p_no_motion), .groups = "drop") %>%
arrange(desc(motion_benefit))
print(team_benefit, n = 32)
## # A tibble: 32 × 2
## possessionTeam motion_benefit
## <chr> <dbl>
## 1 HOU 0.105
## 2 SEA 0.0801
## 3 BUF 0.0675
## 4 NYJ 0.0659
## 5 IND 0.0590
## 6 KC 0.0584
## 7 MIN 0.0499
## 8 TEN 0.0424
## 9 CHI 0.0308
## 10 WAS 0.0216
## 11 BAL 0.0197
## 12 DEN 0.0172
## 13 DET 0.0140
## 14 ARI 0.00940
## 15 LA 0.00550
## 16 NO 0.00504
## 17 JAX 0.00388
## 18 GB -0.00240
## 19 MIA -0.0137
## 20 CAR -0.0194
## 21 TB -0.0215
## 22 SF -0.0218
## 23 PHI -0.0231
## 24 DAL -0.0262
## 25 LV -0.0289
## 26 NE -0.0340
## 27 CIN -0.0356
## 28 NYG -0.0371
## 29 ATL -0.0407
## 30 CLE -0.0482
## 31 LAC -0.0514
## 32 PIT -0.0617
Houston (+10.5 points), Seattle (+8.0), and Buffalo (+6.8) come out highest, and Pittsburgh (-6.2), the Chargers (-5.1), and Cleveland (-4.8) lowest. These values are close to the raw gaps from Section 5, and most of the teams at the top have fewer than about 110 motion plays, which makes their estimates the least reliable. Because the formal test found no evidence of real team differences, the chart below should be read as the size of the noise in the estimates, not as a ranking of who uses motion best. Green bars are teams with a positive estimate and red bars are teams with a negative one.
ggplot(team_benefit, aes(x = reorder(possessionTeam, motion_benefit),
y = motion_benefit, fill = motion_benefit > 0)) +
geom_col() +
coord_flip() +
scale_fill_manual(values = c("TRUE" = "#2e8b57", "FALSE" = "#c0392b"), guide = "none") +
geom_hline(yintercept = 0, color = "gray30") +
labs(title = "Estimated Change in Success Probability from Motion, by Team",
subtitle = "Differences between teams are consistent with chance (p = 0.91)",
x = "Team", y = "Average Change in Success Probability") +
theme_minimal() +
theme(plot.title = element_text(face = "bold"))
To make sure the result does not depend on how success is defined, I repeated the analysis using win probability added instead of expected points added. The first chunk builds the team estimates with the new definition and checks whether the two definitions rank teams the same way.
team_motion_model_wpa <- glm(
success_wpa ~ motion * possessionTeam + factor(down) + yardsToGo + score_diff + quarter,
data = analysis_data,
family = binomial
)
team_benefit_wpa <- analysis_data %>%
mutate(
p_motion = predict(team_motion_model_wpa, newdata = with_motion, type = "response"),
p_no_motion = predict(team_motion_model_wpa, newdata = without_motion, type = "response")
) %>%
group_by(possessionTeam) %>%
summarise(motion_benefit_wpa = mean(p_motion - p_no_motion), .groups = "drop")
benefit_compare <- team_benefit %>%
left_join(team_benefit_wpa, by = "possessionTeam")
# do both definitions rank teams the same way
cor(benefit_compare$motion_benefit, benefit_compare$motion_benefit_wpa)
## [1] 0.8456519
The correlation of 0.85 means the two definitions of success rank teams in nearly the same order. That shows the team estimates are stable across definitions, but it does not show the differences are real, since both measures come from the same plays. The scatter plot below shows the agreement, with each point a team.
ggplot(benefit_compare, aes(x = motion_benefit, y = motion_benefit_wpa, label = possessionTeam)) +
geom_abline(slope = 1, intercept = 0, linetype = "dashed", color = "gray50") +
geom_point(color = "#1f4e79", size = 3, alpha = 0.8) +
geom_text(vjust = -0.8, size = 3, show.legend = FALSE) +
labs(title = "Team Motion Benefit: Expected Points vs. Win Probability",
subtitle = "Teams near the dashed line rank the same under both definitions",
x = "Benefit (expected points definition)", y = "Benefit (win probability definition)") +
theme_minimal() +
theme(plot.title = element_text(face = "bold"))
The more important checks are the same two tests as before, rerun with win probability as the outcome.
# league-wide effect with win probability success
motion_model_wpa <- glm(
success_wpa ~ motion + factor(down) + yardsToGo + score_diff + quarter,
data = analysis_data,
family = binomial
)
summary(motion_model_wpa)
##
## Call:
## glm(formula = success_wpa ~ motion + factor(down) + yardsToGo +
## score_diff + quarter, family = binomial, data = analysis_data)
##
## Coefficients:
## Estimate Std. Error z value Pr(>|z|)
## (Intercept) 0.456708 0.065150 7.010 2.38e-12 ***
## motionTRUE 0.024895 0.035875 0.694 0.4877
## factor(down)2 -0.208780 0.038310 -5.450 5.04e-08 ***
## factor(down)3 -0.326476 0.045998 -7.098 1.27e-12 ***
## factor(down)4 -0.291833 0.120458 -2.423 0.0154 *
## yardsToGo -0.061277 0.004648 -13.183 < 2e-16 ***
## score_diff 0.007624 0.001757 4.339 1.43e-05 ***
## quarter 0.010318 0.014212 0.726 0.4679
## ---
## Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
##
## (Dispersion parameter for binomial family taken to be 1)
##
## Null deviance: 21965 on 15935 degrees of freedom
## Residual deviance: 21747 on 15928 degrees of freedom
## AIC: 21763
##
## Number of Fisher Scoring iterations: 4
# team motion effects beyond team baselines
team_base_model_wpa <- glm(
success_wpa ~ motion + possessionTeam + factor(down) + yardsToGo + score_diff + quarter,
data = analysis_data,
family = binomial
)
anova(team_base_model_wpa, team_motion_model_wpa, test = "Chisq")
## Analysis of Deviance Table
##
## Model 1: success_wpa ~ motion + possessionTeam + factor(down) + yardsToGo +
## score_diff + quarter
## Model 2: success_wpa ~ motion * possessionTeam + factor(down) + yardsToGo +
## score_diff + quarter
## Resid. Df Resid. Dev Df Deviance Pr(>Chi)
## 1 15897 21690
## 2 15866 21670 31 19.987 0.9361
The results match the expected points version almost exactly. The league-wide motion coefficient is 0.025 (p = 0.49) under both definitions, and team-specific motion effects add nothing under either one (p = 0.91 with expected points, p = 0.94 with win probability). The finding holds no matter how success is defined. One detail worth noting is that score differential changes sign between the two models. Teams that are ahead tend to run conservatively, which lowers expected points, but they also gain win probability as the clock runs down. That difference shows the two measures capture different things, which makes their agreement on motion more convincing.
To summarize usage and estimated payoff together, I placed each team in one of four quadrants using the league median on each axis: high usage and high benefit (1), high usage and low benefit (2), low usage and high benefit (3), and low usage and low benefit (4).
team_quadrants <- team_motion_summary %>%
left_join(team_benefit, by = "possessionTeam") %>%
mutate(
quadrant = case_when(
motion_rate >= median(motion_rate) & motion_benefit >= median(motion_benefit) ~ 1,
motion_rate >= median(motion_rate) & motion_benefit < median(motion_benefit) ~ 2,
motion_rate < median(motion_rate) & motion_benefit >= median(motion_benefit) ~ 3,
TRUE ~ 4
)
)
ggplot(team_quadrants, aes(x = motion_rate, y = motion_benefit,
color = factor(quadrant), label = possessionTeam)) +
geom_point(size = 3.5) +
geom_text(vjust = -0.8, size = 3, show.legend = FALSE) +
geom_vline(xintercept = median(team_quadrants$motion_rate), linetype = "dashed", color = "gray40") +
geom_hline(yintercept = median(team_quadrants$motion_benefit), linetype = "dashed", color = "gray40") +
scale_color_manual(
values = c("1" = "#2e8b57", "2" = "#e67e22", "3" = "#1f77b4", "4" = "#c0392b"),
labels = c("1: High use, high benefit", "2: High use, low benefit",
"3: Low use, high benefit", "4: Low use, low benefit")
) +
scale_x_continuous(labels = percent_format()) +
labs(title = "Team Motion Quadrants: Usage vs. Success Benefit",
x = "Motion Rate", y = "Motion Benefit", color = NULL) +
theme_minimal() +
theme(plot.title = element_text(face = "bold"), legend.position = "bottom")
Teams spread across all four quadrants. Kansas City and the Jets are in the top right, with high usage and a positive estimated benefit. Miami sits far to the right of every other team but slightly below zero on benefit, so it leads the league in motion with no measurable payoff. Houston, Seattle, Indianapolis, and Tennessee use motion rarely but did well when they did, and Pittsburgh, the Chargers, Cleveland, and Cincinnati are low on both. The next step is a discriminant analysis that uses the same two variables to check whether it recovers these manual groups.
# MASS called directly so select() is not masked
lda_model <- MASS::lda(quadrant ~ motion_rate + motion_benefit, data = team_quadrants)
team_quadrants$lda_predicted <- predict(lda_model)$class
table(Manual = team_quadrants$quadrant, LDA = team_quadrants$lda_predicted)
## LDA
## Manual 1 2 3 4
## 1 4 1 2 0
## 2 0 9 0 0
## 3 0 0 9 0
## 4 0 0 0 7
mean(team_quadrants$quadrant == team_quadrants$lda_predicted)
## [1] 0.90625
# teams classified differently by the discriminant analysis
team_quadrants %>%
filter(quadrant != lda_predicted) %>%
select(possessionTeam, motion_rate, motion_benefit, quadrant, lda_predicted)
## # A tibble: 3 × 5
## possessionTeam motion_rate motion_benefit quadrant lda_predicted
## <chr> <dbl> <dbl> <dbl> <fct>
## 1 LA 0.333 0.00550 1 2
## 2 BUF 0.307 0.0675 1 3
## 3 DET 0.283 0.0140 1 3
The discriminant analysis classifies 29 of 32 teams the same way (90.6 percent). Quadrants 2, 3, and 4 are recovered perfectly, and the three disagreements (LA, BUF, and DET) were all placed in quadrant 1 manually and all sit close to the cutoff lines. This result should be read with two cautions. The quadrants were built from the same two variables the discriminant analysis uses, so agreement is partly expected. The benefit axis is also mostly noise, based on the team-level test in Section 7, so the horizontal position (how much a team uses motion) is the reliable part of the plot.
To show what pre-snap motion looks like, the animation below replays a Miami play from week 1: Tagovailoa’s 42 yard touchdown pass to Waddle. Miami used motion on more snaps than any other team, and this play is a clean example with a clear result. The player in motion at the snap is highlighted. I rendered the animation once in the console and saved it as a GIF, since the animation function does not run reliably inside a knitted document. The code that created it is shown below, followed by the animation.
# this code was run once in the console to create the GIF
game_to_show <- 2022091106
play_to_show <- 1653
one_week_df <- load_data_for_one_week(
directory = getwd(),
weekNumber = 1,
merge = TRUE
)
one_play <- one_week_df %>%
filter(gameId == game_to_show, playId == play_to_show)
play_anim <- visualize_single_play(
game_df = one_play,
highlight_players_in_motion = TRUE,
showBoundingBox = FALSE,
showVoronoi = FALSE
)
anim_save("mia_motion_play.gif", animation = play_anim)
Watch the highlighted player before the snap. Motion like this forces the defense to decide how to respond while the offense is still adjusting, and that is what the usage ranking above is counting.
This project asked two questions: which teams use pre-snap motion the most, and does motion actually help? The key findings are:
There are a few reasons this might happen, though the data cannot test them. Motion is easy to copy, since any offense can add it to its playbook, and defenses can prepare for it, so any advantage may be competed away across the league. It may also be that motion helps only on specific plays, such as a particular concept or personnel group, and those effects average out when all plays are pooled. Parity here also applies only to the payoff from motion. Teams still differ in their overall success rates, which is why the first team-level comparison in Section 7 was significant, so this finding does not mean all offenses are equally good.
For an offensive coordinator, the practical takeaway is that motion by itself is not a dependable way to improve a play’s chances. Miami uses it more than anyone and gets no measurable benefit, while Kansas City pairs heavy use with a better success rate on motion plays, although the model cannot distinguish that from chance. Any value from motion probably depends on the specific play design and personnel, which this dataset does not isolate. A coordinator should treat motion as one tool among many, useful for the specific looks it creates, rather than a general advantage.
A few limits are worth stating. The data cover only nine weeks, so each team has at most a few hundred motion plays, and the team level estimates are noisy. The analysis shows association rather than cause, since teams choose when to use motion. A small positive effect cannot be ruled out. Finally, the motion flag counts whether a player was moving at the snap, not what kind of motion it was. Splitting out jet motion, orbit motion, and shifts, and comparing them by formation or play type, would be the natural next step.