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 <- "UConn"
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")
p <- read_parquet(sprintf("~/cfb-data/raw/pbp_inseason/pbp_%d_wk%02d.parquet",
SEASON, WEEK))
gid <- p$game_id[p$pos_team == TEAM][1]
g <- p |> filter(game_id == gid)
peers <- cur |> filter(team %in% fbs, !low_volume, is_fbs_matchup, week == WEEK)
su5 <- cur |> filter(team == TEAM, week == WEEK)
secs_left <- function(m, s) m * 60 + coalesce(s, 0)
# Elapsed game time, so the whole game lays out on one axis.
g <- g |>
mutate(
sl = secs_left(clock_minutes, clock_seconds),
elapsed = (pmin(period, 4) - 1) * 900 + (900 - sl),
elapsed = ifelse(period >= 5, 3600 + row_number() / n() * 300, elapsed),
su_score = ifelse(pos_team == TEAM, pos_team_score, def_pos_team_score),
opp_score = ifelse(pos_team == TEAM, def_pos_team_score, pos_team_score)
)# Running score across elapsed game time. The flat orange stretch IS the story.
# A running score can only go up. Some plays carry corrupted score fields --
# raw values dip and recover, producing impossible spikes -- so the series is
# forced monotonic with cummax() before plotting.
tl <- g |>
filter(!is.na(su_score), !is.na(opp_score), period <= 4) |>
arrange(elapsed) |>
mutate(Syracuse = cummax(su_score), UConn = cummax(opp_score)) |>
select(elapsed, Syracuse, UConn) |>
pivot_longer(-elapsed, names_to = "team", values_to = "score")
# Drought window: from Syracuse's last regulation score to the end of regulation
su_scores <- tl |> filter(team == "Syracuse")
last_pts <- max(su_scores$score, na.rm = TRUE)
drought_start <- min(su_scores$elapsed[su_scores$score == last_pts], na.rm = TRUE)
# Both teams finish regulation tied at 34, so the end labels would print on top
# of one another. Separate them vertically rather than letting them collide.
ends <- tl |> group_by(team) |> slice_max(elapsed, n = 1) |> ungroup() |>
mutate(lab_y = score + ifelse(team == "Syracuse", 2.6, -2.6))
p_timeline <- ggplot(tl, aes(elapsed, score, color = team)) +
annotate("rect", xmin = drought_start, xmax = 3600, ymin = -Inf, ymax = Inf,
fill = SU$orange, alpha = 0.08) +
annotate("text", x = (drought_start + 3600) / 2, y = 5,
label = sprintf("%d minutes without a point", round((3600 - drought_start) / 60)),
size = 3.4, fontface = "italic", color = SU$ink2) +
geom_step(linewidth = 1.1) +
geom_text(data = ends, aes(y = lab_y, label = sprintf("%s %d", team, score)),
hjust = -0.08, size = 3.6, fontface = "bold") +
scale_color_manual(values = c(Syracuse = SU$orange, UConn = SU$blue), guide = "none") +
scale_x_continuous(breaks = seq(0, 3600, 900),
labels = c("Kickoff", "End Q1", "Half", "End Q3", "End Q4"),
expand = expansion(mult = c(0.02, 0.12))) +
labs(
title = "Syracuse scored four touchdowns in the first half, then nothing until overtime",
subtitle = sprintf(
"Running score through regulation | Shaded: %d minutes of game clock in which Syracuse did not score and %s scored 20",
round((3600 - drought_start) / 60), OPP),
x = NULL, y = "Points",
caption = su_caption(SCOPE)
) +
theme_su(legend_pos = "none")
save_fig(p_timeline, "01_game_timeline", height = 5.2)Syracuse led by as many as three scores. UConn scored 20 unanswered to force overtime, where Syracuse scored first and held on.
halves <- g |>
select(all_of(intersect(NEEDED, names(g))), period) |>
filter(pos_team == TEAM) |>
classify_plays() |>
filter(is_scrimmage, period <= 4) |>
mutate(half = ifelse(period <= 2, "First half", "Second half"),
type = ifelse(pass == 1, "Pass", "Rush")) |>
group_by(half, type) |>
summarise(plays = n(), sr = mean(success, na.rm = TRUE),
epa = mean(EPA, na.rm = TRUE), .groups = "drop")
p_half <- ggplot(halves, aes(x = half, y = epa, fill = type)) +
geom_hline(yintercept = 0, color = SU$muted, linewidth = 0.4) +
geom_col(position = position_dodge(width = 0.72), width = 0.62) +
geom_text(aes(label = sprintf("%+.3f\n%.0f%% success\n%d plays", epa, 100 * sr, plays),
vjust = ifelse(epa >= 0, -0.25, 1.15)),
position = position_dodge(width = 0.72),
size = 3.1, fontface = "bold", lineheight = 0.95, color = SU$ink) +
scale_fill_manual(values = c(Pass = SU$orange, Rush = SU$blue)) +
scale_y_continuous(expand = expansion(mult = c(0.3, 0.3))) +
labs(
title = "The run game was slightly better after halftime. The passing game fell off a cliff.",
subtitle = "EPA per play by half | Regulation only | Higher is better",
x = NULL, y = "EPA per play", fill = NULL,
caption = su_caption(SCOPE)
) +
theme_su(legend_pos = "top")
save_fig(p_half, "02_passing_by_half", height = 5)drives <- g |>
mutate(sl = secs_left(clock_minutes, clock_seconds)) |>
group_by(drive_id) |>
summarise(team = first(pos_team), per = first(period), clk = first(sl),
start = first(drive_start_yards_to_goal),
closest = suppressWarnings(min(yards_to_goal, na.rm = TRUE)),
plays = n(), result = first(drive_result), pts = first(drive_pts),
.groups = "drop") |>
filter(!is.na(team)) |>
arrange(per, desc(clk))
su_dr <- drives |> filter(team == TEAM, per <= 4)
last_i <- max(which(su_dr$pts > 0))
drought <- su_dr |> slice((last_i + 1):n())
opp_pts <- drives |>
filter(team != TEAM, per <= 4,
per > su_dr$per[last_i] |
(per == su_dr$per[last_i] & clk < su_dr$clk[last_i])) |>
summarise(x = sum(pts, na.rm = TRUE)) |> pull(x)
drought |>
transmute(
When = sprintf("Q%d %02d:%02d", per, clk %/% 60, clk %% 60),
`Started at` = sprintf("own %.0f", 100 - start),
`Got as far as` = ifelse(is.finite(closest) & closest <= 50,
sprintf("the %s %.0f", OPP, closest),
sprintf("own %.0f", 100 - closest)),
Plays = plays,
Result = result
) |>
gt() |>
tab_header(
title = md("**After the opening drive of the second half, Syracuse ran four possessions and scored on none of them**"),
subtitle = md(sprintf("UConn scored %d points over the same stretch", opp_pts))
) |>
cols_align("center", columns = Plays) |>
cols_align("left", columns = c(When, `Started at`, `Got as far as`, Result)) |>
tab_style(style = list(cell_fill(color = "#FFF3E6"), cell_text(weight = "bold")),
locations = cells_body(rows = Result %in% c("INT", "DOWNS", "MISSED FG"))) |>
tab_source_note(md(paste0("**", su_caption(SCOPE), "**"))) |>
gt_theme_su()| After the opening drive of the second half, Syracuse ran four possessions and scored on none of them | ||||
| UConn scored 20 points over the same stretch | ||||
| When | Started at | Got as far as | Plays | Result |
|---|---|---|---|---|
| Q3 03:46 | own 25 | own 35 | 2 | INT |
| Q3 03:07 | own 22 | own 43 | 6 | DOWNS |
| Q4 14:55 | own 25 | own 45 | 9 | PUNT |
| Q4 01:36 | own 38 | the UConn 25 | 12 | MISSED FG |
| Data: CollegeFootballData via cfbfastR | 2026 Week 5 | Syracuse at UConn (OT) | ||||
season <- cur |> filter(team == TEAM) |> arrange(week)
wk_rank <- function(w) {
pg <- cur |> filter(team %in% fbs, !low_volume, week == w,
is_fbs_matchup == (w != 1))
as.integer(round(rank(-pg$off_pts_per_opp,
na.last = "keep")[pg$team == TEAM]))
}
wk_n <- function(w) {
nrow(cur |> filter(team %in% fbs, !low_volume, week == w,
is_fbs_matchup == (w != 1)))
}
tibble::tibble(
Game = c("Wk 1 — New Hampshire (FCS)", "Wk 2 — California",
"Wk 3 — Pittsburgh", "Wk 5 — UConn"),
`Scoring opportunities` = season$off_scoring_opps,
`Points per opportunity` = season$off_pts_per_opp,
Rank = map_chr(season$week, \(w) sprintf("%s of %d",
scales::ordinal(wk_rank(w)), wk_n(w)))
) |>
gt() |>
tab_header(
title = md("**Syracuse went from 99th in the country at finishing drives to 22nd in one week**"),
subtitle = md("Points per trip inside the opponent's 40 | Ranks are among teams facing the same opponent class that week")
) |>
fmt_number(columns = `Points per opportunity`, decimals = 2) |>
cols_align("center", columns = -Game) |>
tab_style(style = cell_text(weight = "bold"), locations = cells_body(columns = Game)) |>
tab_style(style = list(cell_fill(color = "#FFF3E6"), cell_text(weight = "bold")),
locations = cells_body(rows = Game == "Wk 5 — UConn")) |>
data_color(columns = `Points per opportunity`, method = "numeric", palette = SU_SEQ) |>
tab_source_note(md(paste0("**", su_caption("2026 Weeks 1-5"),
"** — Week 1 is compared against FBS teams facing FCS opponents; the rest against FBS-vs-FBS."))) |>
gt_theme_su()| Syracuse went from 99th in the country at finishing drives to 22nd in one week | |||
| Points per trip inside the opponent’s 40 | Ranks are among teams facing the same opponent class that week | |||
| Game | Scoring opportunities | Points per opportunity | Rank |
|---|---|---|---|
| Wk 1 — New Hampshire (FCS) | 11 | 5.18 | 21st of 48 |
| Wk 2 — California | 8 | 2.25 | 84th of 98 |
| Wk 3 — Pittsburgh | 7 | 1.86 | 100th of 114 |
| Wk 5 — UConn | 7 | 5.00 | 22nd of 112 |
| Data: CollegeFootballData via cfbfastR | 2026 Weeks 1-5 — Week 1 is compared against FBS teams facing FCS opponents; the rest against FBS-vs-FBS. | |||
pr <- function(col, hb = TRUE) {
v <- peers[[col]]
as.integer(round(rank(if (hb) -v else v, na.last = "keep")[peers$team == TEAM]))
}
tibble::tibble(
Metric = c("Offensive success rate", "Points per scoring opportunity",
"Defensive EPA allowed per play", "Net EPA per play"),
Value = c(su5$off_success_rate, su5$off_pts_per_opp,
su5$def_epa_play, su5$net_epa_play),
Rank = c(sprintf("%s of %d", scales::ordinal(pr("off_success_rate")), nrow(peers)),
sprintf("%s of %d", scales::ordinal(pr("off_pts_per_opp")), nrow(peers)),
sprintf("%s of %d", scales::ordinal(pr("def_epa_play", FALSE)), nrow(peers)),
sprintf("%s of %d", scales::ordinal(pr("net_epa_play")), nrow(peers)))
) |>
gt() |>
tab_header(
title = md("**The offense was among the best in the country. The team was not.**"),
subtitle = md("Syracuse was outplayed on a per-play basis and won in overtime — the mirror image of the California loss")
) |>
fmt_number(columns = Value, 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 among FBS teams that played FBS opponents in Week 5."))) |>
gt_theme_su()| The offense was among the best in the country. The team was not. | ||
| Syracuse was outplayed on a per-play basis and won in overtime — the mirror image of the California loss | ||
| Metric | Value | Rank |
|---|---|---|
| Offensive success rate | 0.506 | 20th of 112 |
| Points per scoring opportunity | 5.000 | 22nd of 112 |
| Defensive EPA allowed per play | 0.178 | 93rd of 112 |
| Net EPA per play | −0.195 | 92nd of 112 |
| Data: CollegeFootballData via cfbfastR | 2026 Week 5 | Syracuse at UConn (OT) — ranks among FBS teams that played FBS opponents in Week 5. | ||
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 you can restyle.\n")
}## 300-dpi PNGs ready for the piece:
##
## figures/2026_wk05_story/01_game_timeline.png (111 KB)
## figures/2026_wk05_story/02_passing_by_half.png (125 KB)
##
## Copy the gt tables from the browser into Google Docs — they paste as
## native editable tables you can restyle.