source("../R/04_storyline_engine.R")
source("../R/05_viz_style.R")
TEAM <- "Syracuse"
OPP <- "California"
SEASON <- params$season
WEEK <- params$week
FIG_DIR <- sprintf("figures/%d_wk%02d_story", SEASON, WEEK)
SCOPE <- sprintf("%d Week %d | %s vs %s", SEASON, WEEK, TEAM, OPP)hist <- read_parquet("~/cfb-data/processed/team_game_metrics.parquet")
cur <- read_parquet("~/cfb-data/processed/team_game_metrics_2026.parquet")
raw <- read_parquet(sprintf("~/cfb-data/raw/pbp_inseason/pbp_%d_wk%02d.parquet",
SEASON, WEEK))
fbs_teams <- unique(hist$team[hist$is_fbs_matchup])
# The peer group: every FBS-vs-FBS team-game this week. Cal is FBS, so unlike
# Week 1 there is no FCS discount to apply -- these are true peers.
peers <- cur |>
filter(team %in% fbs_teams, !low_volume, is_fbs_matchup, week == WEEK)
su <- peers |> filter(team == TEAM)
su1 <- cur |> filter(team == TEAM, week == 1)
peer_rank <- function(col, higher_better = TRUE) {
v <- peers[[col]]
r <- rank(if (higher_better) -v else v, na.last = "keep")
as.integer(round(r[peers$team == TEAM]))
}
n_peers <- nrow(peers)def_rank <- peer_rank("def_epa_play", higher_better = FALSE)
d1 <- peers |>
mutate(is_su = team == TEAM) |>
arrange(desc(def_epa_play)) |>
mutate(team = factor(team, levels = team))
p_def <- ggplot(d1, aes(x = def_epa_play, y = team, color = is_su)) +
geom_vline(xintercept = median(d1$def_epa_play), linetype = "dashed",
color = SU$muted, linewidth = 0.4) +
geom_point(aes(size = is_su)) +
ggrepel::geom_text_repel(
data = filter(d1, is_su),
aes(label = sprintf("%s — %+.3f EPA allowed\n%s of %d",
team, def_epa_play, scales::ordinal(def_rank), n_peers)),
nudge_x = 0.22, size = 3.4, fontface = "bold", segment.color = NA,
lineheight = 0.95, force = 0
) +
scale_color_manual(values = c(`FALSE` = SU$muted, `TRUE` = SU$orange), guide = "none") +
scale_size_manual(values = c(`FALSE` = 1.6, `TRUE` = 3.6), guide = "none") +
labs(
title = sprintf("Syracuse's defense ranked %s of %d against FBS opposition in Week 2",
scales::ordinal(def_rank), n_peers),
subtitle = "EPA allowed per play — further left is better | Dashed line = group median",
x = "Defensive EPA allowed per play", y = NULL,
caption = su_caption(SCOPE)
) +
theme_su(legend_pos = "none") +
theme(axis.text.y = element_text(size = 5.5))
save_fig(p_def, "01_defense_vs_peers", height = 8)The defense was also the week’s biggest improvement: EPA allowed moved from +0.098 in Week 1 to -0.165 against a Power Four opponent.
tibble::tibble(
Metric = c("EPA allowed per play", "Success rate allowed",
"Havoc rate", "Sack rate"),
`Week 1` = c(su1$def_epa_play, su1$def_success_rate,
su1$def_havoc_core, su1$def_sack_rate),
`Week 2` = c(su$def_epa_play, su$def_success_rate,
su$def_havoc_core, su$def_sack_rate),
`Rank of 96` = c(peer_rank("def_epa_play", FALSE), peer_rank("def_success_rate", FALSE),
peer_rank("def_havoc_core"), peer_rank("def_sack_rate"))
) |>
gt() |>
tab_header(
title = md("**Efficient without being disruptive**"),
subtitle = md("Syracuse held California down while generating almost no negative plays — an unusual profile worth watching")
) |>
fmt_number(columns = c(`Week 1`, `Week 2`), decimals = 3) |>
cols_align("center", columns = -Metric) |>
tab_style(style = cell_text(weight = "bold"),
locations = cells_body(columns = Metric)) |>
tab_source_note(md(paste0("**", su_caption(SCOPE),
"** — ranks are among the ", n_peers,
" FBS teams that played FBS opponents in Week 2."))) |>
gt_theme_su()| Efficient without being disruptive | |||
| Syracuse held California down while generating almost no negative plays — an unusual profile worth watching | |||
| Metric | Week 1 | Week 2 | Rank of 96 |
|---|---|---|---|
| EPA allowed per play | 0.098 | −0.165 | 16 |
| Success rate allowed | 0.276 | 0.350 | 25 |
| Havoc rate | 0.103 | 0.017 | 72 |
| Sack rate | 0.161 | 0.029 | 73 |
| Data: CollegeFootballData via cfbfastR | 2026 Week 2 | Syracuse vs California — ranks are among the 96 FBS teams that played FBS opponents in Week 2. | |||
sd_rank <- peer_rank("off_sr_standard", higher_better = TRUE)
d2 <- peers |>
filter(!is.na(off_sr_standard)) |>
mutate(is_su = team == TEAM) |>
arrange(off_sr_standard) |>
mutate(team = factor(team, levels = team))
p_std <- ggplot(d2, aes(x = off_sr_standard, y = team, color = is_su)) +
geom_vline(xintercept = median(d2$off_sr_standard), linetype = "dashed",
color = SU$muted, linewidth = 0.4) +
geom_point(aes(size = is_su)) +
ggrepel::geom_text_repel(
data = filter(d2, is_su),
aes(label = sprintf("%s — %.1f%% on standard downs\n%s of %d",
team, 100 * off_sr_standard,
scales::ordinal(sd_rank), nrow(d2))),
nudge_x = 0.1, size = 3.4, fontface = "bold", segment.color = NA,
lineheight = 0.95, force = 0
) +
scale_color_manual(values = c(`FALSE` = SU$muted, `TRUE` = SU$orange), guide = "none") +
scale_size_manual(values = c(`FALSE` = 1.6, `TRUE` = 3.6), guide = "none") +
scale_x_continuous(labels = scales::percent) +
labs(
title = sprintf("On first and second down, Syracuse ranked %s of %d",
scales::ordinal(sd_rank), nrow(d2)),
subtitle = sprintf(
"Standard-downs success rate — further right is better | Syracuse fell from %.1f%% in Week 1 to %.1f%% | Dashed line = group median",
100 * su1$off_sr_standard, 100 * su$off_sr_standard),
x = "Standard-downs success rate", y = NULL,
caption = su_caption(SCOPE)
) +
theme_su(legend_pos = "none") +
theme(axis.text.y = element_text(size = 5.5))
save_fig(p_std, "02_standard_downs_vs_peers", height = 8)Failing on early downs is the mechanism behind everything in Exhibit 3. An offense that does not win first and second down arrives at third-and-long, stalls short of the end zone, and settles for field goals.
drives <- raw |>
filter(pos_team == TEAM) |>
group_by(drive_id) |>
summarise(
start_ytg = first(drive_start_yards_to_goal),
closest = min(yards_to_goal, na.rm = TRUE),
pts = first(drive_pts),
result = first(drive_result),
plays = n(),
.groups = "drop"
) |>
filter(!is.na(start_ytg), is.finite(closest))
opps <- drives |> filter(closest <= 40) |> arrange(closest)
opps |>
transmute(
`Drive started` = sprintf("own %.0f", 100 - start_ytg),
`Got as close as` = ifelse(closest == 0, "the goal line",
sprintf("the %s %.0f", OPP, closest)),
Plays = plays,
Result = result,
Points = pts
) |>
gt() |>
tab_header(
title = md("**Syracuse reached California's 40-yard line nine times and scored 18 points**"),
subtitle = md("Two missed field goals and an interception at the goal line, in a three-point loss")
) |>
cols_align("center", columns = c(Plays, Points)) |>
cols_align("left", columns = c(`Drive started`, `Got as close as`, Result)) |>
data_color(columns = Points, method = "numeric", palette = SU_SEQ) |>
tab_style(
style = list(cell_fill(color = "#FFF3E6"), cell_text(weight = "bold")),
locations = cells_body(rows = Points == 0)
) |>
tab_source_note(md(paste0(
"**", su_caption(SCOPE), "** — a scoring opportunity is any drive reaching the opponent's 40. ",
sprintf("Syracuse averaged %.2f points per trip, %s of %d nationally.",
su$off_pts_per_opp, scales::ordinal(peer_rank("off_pts_per_opp")), n_peers)))) |>
gt_theme_su()| Syracuse reached California’s 40-yard line nine times and scored 18 points | ||||
| Two missed field goals and an interception at the goal line, in a three-point loss | ||||
| Drive started | Got as close as | Plays | Result | Points |
|---|---|---|---|---|
| own 28 | the goal line | 6 | TD | 6 |
| own 52 | the goal line | 8 | INT | 0 |
| own 100 | the goal line | 22 | MISSED FG | 0 |
| own 25 | the California 2 | 15 | TD | 6 |
| own 36 | the California 7 | 7 | FG | 3 |
| own 40 | the California 15 | 15 | FG | 3 |
| own 25 | the California 22 | 14 | MISSED FG | 0 |
| own 28 | the California 28 | 2 | PUNT | 0 |
| own 20 | the California 40 | 7 | PUNT | 0 |
| Data: CollegeFootballData via cfbfastR | 2026 Week 2 | Syracuse vs California — a scoring opportunity is any drive reaching the opponent’s 40. Syracuse averaged 2.25 points per trip, 83rd of 96 nationally. | ||||
# Final scores reconstructed from the play-by-play, so this never depends on a
# live API call. A handful of games carry corrupted score fields, so margins
# are filtered to a plausible range before use.
scores <- bind_rows(
raw |> transmute(game_id, team = pos_team, pts = pos_team_score),
raw |> transmute(game_id, team = def_pos_team, pts = def_pos_team_score)
) |>
filter(!is.na(team), !is.na(pts), pts >= 0, pts <= 100) |>
group_by(game_id, team) |>
summarise(final = max(pts, na.rm = TRUE), .groups = "drop")
margins <- scores |>
group_by(game_id) |>
filter(n() == 2) |>
mutate(margin = final - rev(final)) |>
ungroup() |>
filter(abs(margin) <= 80)
j <- peers |>
inner_join(margins, by = c("game_id", "team")) |>
mutate(is_su = team == TEAM,
won_epa = net_epa_play > 0,
won_game = margin > 0)
share_won <- mean(j$won_game[j$won_epa])
n_epa_win <- sum(j$won_epa)
p_scatter <- ggplot(j, aes(x = net_epa_play, y = margin)) +
annotate("rect", xmin = 0, xmax = Inf, ymin = -Inf, ymax = 0,
fill = SU$orange, alpha = 0.06) +
geom_hline(yintercept = 0, color = SU$muted, linewidth = 0.4) +
geom_vline(xintercept = 0, color = SU$muted, linewidth = 0.4) +
geom_point(data = filter(j, !is_su), color = SU$muted, alpha = 0.5, size = 2.2) +
geom_point(data = filter(j, is_su), color = SU$orange, size = 4.2) +
ggrepel::geom_text_repel(
data = filter(j, is_su),
aes(label = sprintf("%s\n%+.3f EPA margin, lost by %d", team, net_epa_play, abs(margin))),
nudge_y = -14, nudge_x = 0.05, size = 3.4, fontface = "bold",
segment.color = SU$orange, lineheight = 0.95
) +
annotate("text", x = Inf, y = -Inf, hjust = 1.05, vjust = -1.2,
label = "Won the efficiency battle, lost the game",
size = 3.2, fontface = "italic", color = SU$ink2) +
labs(
title = sprintf("%d of %d teams that won the efficiency battle in Week 2 won the game. Syracuse did not.",
sum(j$won_epa & j$won_game), n_epa_win),
subtitle = sprintf(
"Net EPA per play against final margin | %d FBS-vs-FBS team-games | Shaded quadrant: outplayed the opponent and still lost",
nrow(j)),
x = "Net EPA per play (offense minus defense allowed)",
y = "Final point margin",
caption = su_caption(SCOPE)
) +
theme_su(legend_pos = "none")
save_fig(p_scatter, "04_outplayed_and_lost", height = 6)Among the 16 teams that won the efficiency battle and lost, Syracuse’s 2.25 points per scoring opportunity was among the lowest. The pattern is not bad luck — it is what happens to a team that reaches scoring range and cannot convert.
figs <- list.files(FIG_DIR, pattern = "\\.png$", full.names = TRUE)
if (length(figs) == 0) {
cat("No figures written.\n")
} else {
cat("300-dpi PNGs ready for the piece:\n\n")
for (f in figs) cat(" ", f, " (", round(file.info(f)$size / 1024), " KB)\n", sep = "")
cat("\nThe gt tables render above — copy them from the browser into Google Docs,\n")
cat("where they paste as native editable tables.\n")
}## 300-dpi PNGs ready for the piece:
##
## figures/2026_wk02_story/01_defense_vs_peers.png (350 KB)
## figures/2026_wk02_story/02_standard_downs_vs_peers.png (359 KB)
## figures/2026_wk02_story/04_outplayed_and_lost.png (227 KB)
##
## The gt tables render above — copy them from the browser into Google Docs,
## where they paste as native editable tables.