library(tidyverse); library(arrow); library(gt); library(ggrepel); library(scales)
library(cfbfastR)options(cfb.lib_only = TRUE)
source("../R/02_team_game_metrics.R")
source("../R/04_storyline_engine.R")
source("../R/05_viz_style.R")
TEAM <- "Syracuse"; OPP <- "North Carolina"
SEASON <- params$season; WEEK <- params$week
FIG_DIR <- sprintf("figures/%d_wk%02d_story", SEASON, WEEK)
SCOPE <- sprintf("%d Week %d | %s at %s (OT)", SEASON, WEEK, TEAM, OPP)cur <- read_parquet("~/cfb-data/processed/team_game_metrics_2026.parquet")
fbs <- readRDS("~/cfb-data/fbs_teams_2026.rds")
su <- cur |> filter(team == TEAM) |> arrange(week)
su8 <- su |> filter(week == WEEK)
peers <- cur |> filter(team %in% fbs, !low_volume, is_fbs_matchup, week == WEEK)
pr <- function(col, hb = TRUE) {
v <- peers[[col]]
as.integer(round(rank(if (hb) -v else v, na.last = "keep")[peers$team == TEAM]))
}
n_peers <- nrow(peers)
sched <- suppressWarnings(cfbd_game_info(SEASON, team = TEAM)) |>
filter(!is.na(home_points)) |> arrange(week) |>
mutate(opp = ifelse(home_team == TEAM, away_team, home_team),
su_pts = ifelse(home_team == TEAM, home_points, away_points),
op_pts = ifelse(home_team == TEAM, away_points, home_points),
margin = su_pts - op_pts,
one_score = abs(margin) <= 8,
label = sprintf("Wk %d %s", week, opp))off <- su |>
left_join(sched |> select(week, opp, margin), by = "week") |>
mutate(is_wk8 = week == WEEK,
lab = sprintf("wk%d\n%s", week, opp))
p_off <- ggplot(off, aes(week, off_epa_play)) +
geom_hline(yintercept = 0, color = SU$muted, linewidth = 0.4) +
geom_line(color = SU$orange, linewidth = 1) +
geom_point(aes(size = is_wk8), color = SU$orange) +
geom_text_repel(data = filter(off, is_wk8),
aes(label = sprintf("%s\n%+.3f — %s of %d",
opp, off_epa_play,
scales::ordinal(pr("off_epa_play")), n_peers)),
nudge_y = 0.07, size = 3.5, fontface = "bold",
segment.color = NA, lineheight = 0.95, force = 0) +
scale_size_manual(values = c(`FALSE` = 2.4, `TRUE` = 4.4), guide = "none") +
scale_x_continuous(breaks = off$week, labels = off$lab) +
labs(
title = sprintf("%s's offense has climbed all season, and peaked against %s", TEAM, OPP),
subtitle = sprintf(
"EPA per play by game | Week 2 low of %+.3f to a season high of %+.3f | Week 1 came against an FCS opponent",
min(off$off_epa_play), max(off$off_epa_play)),
x = NULL, y = "Offensive EPA per play",
caption = su_caption("2026 season to date")
) +
theme_su(legend_pos = "none") +
theme(axis.text.x = element_text(size = 7.5))
save_fig(p_off, "01_offense_season_arc", height = 5)tibble::tibble(
Metric = c("Net EPA per play", "Offensive EPA per play", "Rushing EPA per play",
"Passing EPA per play", "Standard-downs success rate",
"Points per scoring opportunity"),
Value = c(su8$net_epa_play, su8$off_epa_play, su8$off_epa_rush,
su8$off_epa_pass, su8$off_sr_standard, su8$off_pts_per_opp),
Rank = c(sprintf("%s of %d", scales::ordinal(pr("net_epa_play")), n_peers),
sprintf("%s of %d", scales::ordinal(pr("off_epa_play")), n_peers),
sprintf("%s of %d", scales::ordinal(pr("off_epa_rush")), n_peers),
sprintf("%s of %d", scales::ordinal(pr("off_epa_pass")), n_peers),
sprintf("%s of %d", scales::ordinal(pr("off_sr_standard")), n_peers),
sprintf("%s of %d", scales::ordinal(pr("off_pts_per_opp")), n_peers))
) |>
gt() |>
tab_header(
title = md(sprintf("**%s was the seventh-most efficient team in the country on Saturday**", TEAM)),
subtitle = md("Week 8 ranks among FBS teams that played FBS opponents")
) |>
fmt_number(columns = Value, decimals = 3) |>
cols_align("center", columns = -Metric) |>
tab_style(style = cell_text(weight = "bold"), locations = cells_body(columns = Metric)) |>
tab_style(style = list(cell_fill(color = "#FFF3E6"), cell_text(weight = "bold")),
locations = cells_body(rows = Metric == "Net EPA per play")) |>
tab_source_note(md(paste0("**", su_caption(SCOPE), "**"))) |>
gt_theme_su()| Syracuse was the seventh-most efficient team in the country on Saturday | ||
| Week 8 ranks among FBS teams that played FBS opponents | ||
| Metric | Value | Rank |
|---|---|---|
| Net EPA per play | 0.258 | 7th of 112 |
| Offensive EPA per play | 0.232 | 19th of 112 |
| Rushing EPA per play | 0.271 | 15th of 112 |
| Passing EPA per play | 0.178 | 27th of 112 |
| Standard-downs success rate | 0.550 | 21st of 112 |
| Points per scoring opportunity | 4.800 | 28th of 112 |
| Data: CollegeFootballData via cfbfastR | 2026 Week 8 | Syracuse at North Carolina (OT) | ||
tibble::tibble(
Metric = c("Success rate allowed", "Havoc rate", "EPA allowed per play",
"Opponent scoring opportunities", "Points allowed per opportunity"),
Value = c(su8$def_success_rate, su8$def_havoc_core, su8$def_epa_play,
su8$def_scoring_opps, su8$def_pts_per_opp),
Rank = c(sprintf("%s of %d", scales::ordinal(pr("def_success_rate", FALSE)), n_peers),
sprintf("%s of %d", scales::ordinal(pr("def_havoc_core")), n_peers),
sprintf("%s of %d", scales::ordinal(pr("def_epa_play", FALSE)), n_peers),
"—", "—")
) |>
gt() |>
tab_header(
title = md("**North Carolina moved the ball at will and was held to four field goals**"),
subtitle = md(sprintf(
"UNC reached scoring range %d times to Syracuse's %d, and scored %.2f points per trip",
su8$def_scoring_opps, su8$off_scoring_opps, su8$def_pts_per_opp))
) |>
fmt_number(columns = Value, decimals = 3) |>
cols_align("center", columns = -Metric) |>
tab_style(style = cell_text(weight = "bold"), locations = cells_body(columns = Metric)) |>
tab_style(style = list(cell_fill(color = "#FFF3E6"), cell_text(weight = "bold")),
locations = cells_body(rows = Metric %in%
c("Success rate allowed", "Havoc rate"))) |>
tab_source_note(md(paste0("**", su_caption(SCOPE),
"** — EPA allowed ranked mid-pack because Syracuse forced field goals; ",
"success rate allowed is the truer measure of how easily UNC moved."))) |>
gt_theme_su()| North Carolina moved the ball at will and was held to four field goals | ||
| UNC reached scoring range 8 times to Syracuse’s 5, and scored 3.75 points per trip | ||
| Metric | Value | Rank |
|---|---|---|
| Success rate allowed | 0.464 | 84th of 112 |
| Havoc rate | 0.029 | 82nd of 112 |
| EPA allowed per play | −0.027 | 48th of 112 |
| Opponent scoring opportunities | 8.000 | — |
| Points allowed per opportunity | 3.750 | — |
| Data: CollegeFootballData via cfbfastR | 2026 Week 8 | Syracuse at North Carolina (OT) — EPA allowed ranked mid-pack because Syracuse forced field goals; success rate allowed is the truer measure of how easily UNC moved. | ||
Success rate allowed has now sat between .440 and .473 in every game since Week 3 — the one part of this team that has not changed all season.
m <- sched |>
mutate(lab = sprintf("Wk %d %s", week, opp),
lab = factor(lab, levels = rev(lab)),
outcome = ifelse(margin > 0, "Win", "Loss"))
p_margin <- ggplot(m, aes(x = margin, y = lab)) +
# NOTE: y here is a discrete factor, so any annotation must use -Inf/Inf or a
# real factor level -- a numeric y breaks continuous-scale training.
annotate("rect", xmin = -8, xmax = 8, ymin = -Inf, ymax = Inf,
fill = SU$orange, alpha = 0.08) +
geom_vline(xintercept = 0, color = SU$muted, linewidth = 0.5) +
geom_segment(aes(x = 0, xend = margin, yend = lab, color = outcome),
linewidth = 1.6) +
geom_point(aes(color = outcome), size = 3.4) +
geom_text(aes(label = sprintf("%+d", margin),
hjust = ifelse(margin > 0, -0.45, 1.45)),
size = 3.3, fontface = "bold", color = SU$ink) +
scale_color_manual(values = c(Win = SU$orange, Loss = SU$blue), name = NULL) +
scale_x_continuous(expand = expansion(mult = c(0.14, 0.14))) +
labs(
title = sprintf("%d of Syracuse's 7 games have been decided by one score",
sum(sched$one_score)),
subtitle = sprintf(
"Final margin by game | Shaded band: within 8 points | Syracuse is %d-%d in those games, and %d-%d overall",
sum(sched$one_score & sched$margin > 0), sum(sched$one_score & sched$margin < 0),
sum(sched$margin > 0), sum(sched$margin < 0)),
x = "Final margin", y = NULL,
caption = su_caption("2026 season to date")
) +
theme_su(legend_pos = "top")
save_fig(p_margin, "03_one_score_games", height = 5.5)scores <- bind_rows(
read_parquet(sprintf("~/cfb-data/raw/pbp_inseason/pbp_%d_wk%02d.parquet", SEASON, WEEK)) |>
transmute(game_id, team = pos_team, pts = pos_team_score),
read_parquet(sprintf("~/cfb-data/raw/pbp_inseason/pbp_%d_wk%02d.parquet", SEASON, WEEK)) |>
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), .groups = "drop") |>
group_by(game_id) |> filter(n() == 2) |>
mutate(margin = final - rev(final)) |> ungroup() |>
filter(abs(margin) <= 80)
j <- peers |> inner_join(scores, by = c("game_id", "team")) |>
mutate(is_su = team == TEAM)
p_sc <- ggplot(j, aes(net_epa_play, margin)) +
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) +
geom_text_repel(data = filter(j, is_su),
aes(label = sprintf("%s\n%+.3f net EPA, won by %d", team, net_epa_play, margin)),
nudge_y = 16, nudge_x = -0.04, size = 3.4, fontface = "bold",
segment.color = SU$orange, lineheight = 0.95) +
labs(
title = sprintf("Only %d teams outplayed their opponent by more than Syracuse did — and most of them won comfortably",
sum(j$net_epa_play > j$net_epa_play[j$is_su])),
subtitle = sprintf("Net EPA per play against final margin | %d FBS-vs-FBS team-games in Week %d",
nrow(j), WEEK),
x = "Net EPA per play (offense minus defense allowed)", y = "Final margin",
caption = su_caption(SCOPE)
) +
theme_su(legend_pos = "none")
save_fig(p_sc, "04_dominant_but_close", height = 6)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("\nCopy the gt tables from the browser into Google Docs — they paste as\n")
cat("native editable tables.\n")
}## 300-dpi PNGs ready for the piece:
##
## figures/2026_wk08_story/01_offense_season_arc.png (149 KB)
## figures/2026_wk08_story/03_one_score_games.png (133 KB)
## figures/2026_wk08_story/04_dominant_but_close.png (228 KB)
##
## Copy the gt tables from the browser into Google Docs — they paste as
## native editable tables.