Introduction

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.

1. Identifying Offensive Motion

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.

2. Building the Play-Level Dataset and Defining Success

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.

3. Ranking Teams by Motion Usage

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.

4. Success With and Without Motion

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.

5. Team Success With and Without Motion

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

6. Logistic Regression: Does Motion Predict Success?

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.

7. Does Any Team Get a Different Payoff?

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.

8. Estimated Motion Benefit by Team

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

9. Robustness Check With Win Probability Added

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.

10. Team Quadrants and Discriminant Analysis

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.

What Motion Looks Like: A Miami Touchdown

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.

Conclusion

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.