Question. Nebraska fans say the Cornhuskers can’t win close games. Is the “curse” real, or just narrative?
Approach. I pulled every Nebraska game 2014–2025 from the CollegeFootballData API, isolated one-score games (≤8-point margin), benchmarked Nebraska against all Power 5 programs, and used post-game win probability + play-by-play data to separate bad luck from self-inflicted failure.
Key findings. - One-score record: 24–50, 32.4% — 66 of 69 Power 5 programs (P5 median 50.9%). - 9 “should-have-won” losses where Nebraska’s post-game win probability was ≥70% — they dominated the stat sheet and lost anyway. - In those losses, Nebraska averaged 1.2 turnovers and 5.9 penalties per game — execution, not luck. - No coaching staff solved it: worst era was Scott Frost (20%); longest sub-.500 streak was 8 seasons.
Takeaway. The curse is real and measurable — a program-level game-management problem that has outlasted four coaching changes, not a run of bad bounces.
# Core data wrangling
library(httr)
library(jsonlite)
library(dplyr)
library(tidyr)
library(purrr)
library(stringr)
library(lubridate)
library(janitor)
library(glue)
# Visualization
library(ggplot2)
library(ggrepel)
library(scales)
# Tables
library(gt)
library(gtExtras)
# Set a consistent theme for all plots
theme_husker <- theme_minimal(base_size = 13) +
theme(
plot.title = element_text(face = "bold", size = 16),
plot.subtitle = element_text(color = "gray40", size = 12),
plot.caption = element_text(color = "gray60", size = 9),
panel.grid.minor = element_blank(),
legend.position = "bottom"
)
theme_set(theme_husker)
# Color palette
husker_red <- "#E41C38"
husker_cream <- "#F5F1EB"
win_color <- "#2E8B57"
loss_color <- "#C62828"
neutral_gray <- "#9E9E9E"
# ── API Key ──────────────────────────────────────────────────────────
# Best practice: store your key in .Renviron as CFBD_API_KEY
# usethis::edit_r_environ() # then add: CFBD_API_KEY=your_key_here
#
# The params block at the top of this Rmd lets you override at render time:
# rmarkdown::render("nebraska_one_score_curse.Rmd",
# params = list(api_key = Sys.getenv("CFBD_API_KEY")))
# ─────────────────────────────────────────────────────────────────────
API_KEY <- Sys.getenv("CFBD_API_KEY")
TEAM <- params$team
START_YEAR <- params$start_year
END_YEAR <- params$end_year
WP_THRESH <- params$wp_threshold
SEASONS <- START_YEAR:END_YEAR
# Power Five conferences (pre- and post-realignment labels used by CFBD)
P5_CONFERENCES <- c("ACC", "Big 12", "Big Ten", "Pac-12", "SEC")
#' Generic CFBD API GET wrapper
#'
#' @param endpoint Character. API path (e.g., "games", "plays").
#' @param params Named list of query parameters.
#' @param api_key Character. Bearer token.
#' @return A tibble (or data frame) parsed from the JSON response.
cfbd_get <- function(endpoint, params = list(), api_key = API_KEY) {
base_url <- "https://api.collegefootballdata.com"
url <- modify_url(base_url, path = endpoint, query = params)
res <- GET(url, add_headers(
Authorization = paste("Bearer", api_key),
Accept = "application/json"
))
if (http_error(res)) {
stop(glue("API error ({status_code(res)}) for {endpoint}: ",
"{substr(content(res, as='text'), 1, 200)}"))
}
out <- fromJSON(content(res, as = "text", encoding = "UTF-8"), flatten = TRUE)
if (length(out) == 0) return(tibble())
as_tibble(out)
}
#' Fetch game-level results for one season, optionally filtered by team or conference.
get_games <- function(year, team = NULL, conference = NULL, api_key = API_KEY) {
params <- list(year = year, seasonType = "both")
if (!is.null(team)) params$team <- team
if (!is.null(conference)) params$conference <- conference
cfbd_get("games", params, api_key) %>% clean_names()
}
#' Fetch play-by-play for a specific game.
get_plays <- function(year, week, team, season_type = "regular", api_key = API_KEY) {
params <- list(year = year, week = week, team = team, seasonType = season_type)
cfbd_get("plays", params, api_key) %>% clean_names()
}
#' Fetch FBS team list (with conference affiliation) for a given year.
get_fbs_teams <- function(year, api_key = API_KEY) {
cfbd_get("teams/fbs", list(year = year), api_key) %>% clean_names()
}
#' Rate-limit polite pause (CFBD asks for ≤10 req/sec)
polite_pause <- function(seconds = 0.15) {
Sys.sleep(seconds)
}
# ── Raw API diagnostic ──
# Hit the API directly (bypassing cfbd_get's error swallowing)
# so we can see exactly what comes back: status code, headers, body.
diag_path <- file.path("..", "data", "api_diagnostics.txt")
test_url <- modify_url(
"https://api.collegefootballdata.com",
path = "games",
query = list(year = SEASONS[1], team = TEAM, seasonType = "both")
)
test_res <- GET(test_url, add_headers(
Authorization = paste("Bearer", API_KEY),
Accept = "application/json"
))
# Capture the raw response for debugging
raw_body <- content(test_res, as = "text", encoding = "UTF-8")
writeLines(c(
paste("Fetch timestamp:", Sys.time()),
paste("Request URL:", test_url),
paste("HTTP status:", status_code(test_res)),
paste("Content-Type:", headers(test_res)$`content-type`),
"",
"Response body (first 2000 chars):",
substr(raw_body, 1, 2000),
"",
"── If status was 200, parsed columns: ──",
if (status_code(test_res) == 200) {
parsed <- fromJSON(raw_body, flatten = TRUE) %>% as_tibble() %>% clean_names()
c(paste("Rows:", nrow(parsed)),
paste("Columns:", ncol(parsed)),
paste(names(parsed), collapse = "\n"))
} else {
"Parsing skipped (non-200 status)"
}
), diag_path)
cat(glue("API diagnostics written to: {diag_path}\n"))
## API diagnostics written to: ../data/api_diagnostics.txt
cat(glue("HTTP status: {status_code(test_res)}\n"))
## HTTP status: 200
# Stop early if the API isn't responding properly
if (status_code(test_res) != 200) {
stop(glue(
"CFBD API returned HTTP {status_code(test_res)}. ",
"Check data/api_diagnostics.txt for details. ",
"Your API key may need to be regenerated for API v2 at https://collegefootballdata.com/key"
))
}
#' Normalize column names from the CFBD /games endpoint.
#'
#' The API migrated to v2 in May 2025 with breaking changes.
#' This function maps whatever the API returns to the stable column
#' names the rest of the pipeline expects.
normalize_game_cols <- function(df) {
cols <- names(df)
# ── Ensure 'completed' exists ──
if (!"completed" %in% cols) {
if ("status" %in% cols) {
df <- df %>% mutate(completed = (status == "completed"))
} else {
# Scan for ANY score-like column to infer completion
all_cols <- names(df)
score_candidates <- all_cols[str_detect(all_cols, regex("point|score", ignore_case = TRUE))]
if (length(score_candidates) > 0) {
df <- df %>% mutate(completed = !is.na(.data[[score_candidates[1]]]))
} else {
df <- df %>% mutate(completed = TRUE)
}
}
}
# ── Build a rename map for all expected columns ──
# Keys = target name we want; Values = possible names the API might use
rename_map <- list(
home_team = c("home_team", "hometeam"),
away_team = c("away_team", "awayteam"),
home_points = c("home_points", "homepoints", "home_score", "homescore"),
away_points = c("away_points", "awaypoints", "away_score", "awayscore"),
home_conference = c("home_conference", "homeconference"),
away_conference = c("away_conference", "awayconference"),
home_id = c("home_id", "homeid"),
away_id = c("away_id", "awayid"),
home_classification = c("home_classification", "homeclassification"),
away_classification = c("away_classification", "awayclassification"),
season_type = c("season_type", "seasontype"),
start_date = c("start_date", "startdate"),
start_time_tbd = c("start_time_tbd", "starttimetbd"),
neutral_site = c("neutral_site", "neutralsite"),
conference_game = c("conference_game", "conferencegame"),
venue_id = c("venue_id", "venueid"),
excitement_index = c("excitement_index", "excitementindex"),
home_line_scores = c("home_line_scores", "homelinescores"),
away_line_scores = c("away_line_scores", "awaylinescores"),
home_postgame_win_probability = c("home_postgame_win_probability",
"homepostgamewinprobability",
"home_postgame_wp", "home_post_win_prob"),
away_postgame_win_probability = c("away_postgame_win_probability",
"awaypostgamewinprobability",
"away_postgame_wp", "away_post_win_prob"),
home_pregame_elo = c("home_pregame_elo", "homepregameelo"),
away_pregame_elo = c("away_pregame_elo", "awaypregameelo"),
home_postgame_elo = c("home_postgame_elo", "homepostgameelo"),
away_postgame_elo = c("away_postgame_elo", "awaypostgameelo")
)
current_cols <- names(df)
for (target in names(rename_map)) {
if (!target %in% current_cols) {
alias <- intersect(current_cols, rename_map[[target]])
if (length(alias) > 0) {
df <- df %>% rename(!!target := !!alias[1])
current_cols <- names(df) # refresh after rename
}
}
}
df
}
# Fetch Nebraska's full game log across all target seasons
nebraska_raw <- map_dfr(SEASONS, function(yr) {
polite_pause()
get_games(yr, team = TEAM)
}) %>%
normalize_game_cols()
cat(glue("{nrow(nebraska_raw)} Nebraska games fetched across {length(SEASONS)} seasons.\n"))
## 145 Nebraska games fetched across 12 seasons.
cat(glue("Columns: {paste(names(nebraska_raw), collapse = ', ')}\n"))
## Columns: id, season, week, season_type, start_date, start_time_tbd, completed, neutral_site, conference_game, attendance, venue_id, venue, home_id, home_team, home_classification, home_conference, home_points, home_line_scores, home_postgame_win_probability, home_pregame_elo, home_postgame_elo, away_id, away_team, away_classification, away_conference, away_points, away_line_scores, away_postgame_win_probability, away_pregame_elo, away_postgame_elo, excitement_index, highlights, notes
nebraska_games <- nebraska_raw %>%
filter(completed == TRUE) %>%
mutate(
# Determine Nebraska's perspective
is_home = (home_team == TEAM),
team_score = if_else(is_home, home_points, away_points),
opp_score = if_else(is_home, away_points, home_points),
opponent = if_else(is_home, away_team, home_team),
opp_conf = if_else(is_home, away_conference, home_conference),
point_diff = team_score - opp_score,
abs_diff = abs(point_diff),
result = case_when(
point_diff > 0 ~ "Win",
point_diff < 0 ~ "Loss",
TRUE ~ "Tie"
),
one_score = abs_diff <= 8,
# Win probability from Nebraska's perspective
team_postgame_wp = if_else(is_home,
home_postgame_win_probability,
away_postgame_win_probability),
opp_postgame_wp = if_else(is_home,
away_postgame_win_probability,
home_postgame_win_probability),
game_date = as.Date(start_date),
# Coach eras (approximate)
coach = case_when(
season <= 2014 ~ "Pelini",
season >= 2015 & season <= 2017 ~ "Riley",
season >= 2018 & season <= 2021 ~ "Frost",
season >= 2022 & season <= 2023 ~ "Rhule (Yr 1-2)",
season >= 2024 ~ "Rhule (Yr 3+)",
TRUE ~ "Unknown"
)
)
# Fetch ALL FBS games per season (no conference filter) — more reliable than
# individual conference pulls, which can silently drop games when the API
# returns NULL conference values or hits rate limits.
# P5 filtering happens downstream when we pivot to per-team records.
all_fbs_raw <- map_dfr(SEASONS, function(yr) {
polite_pause()
get_games(yr)
}) %>%
normalize_game_cols()
cat(glue("{nrow(all_fbs_raw)} total FBS games fetched across {length(SEASONS)} seasons.\n"))
## 28053 total FBS games fetched across 12 seasons.
# Pivot each game into two rows: one from the home team's perspective, one from away.
# Then keep only rows where the team in question is P5.
p5_team_games <- bind_rows(
# Home team perspective
all_fbs_raw %>%
filter(completed == TRUE) %>%
transmute(
game_id = id,
season,
week,
team = home_team,
team_conf = home_conference,
opponent = away_team,
opp_conf = away_conference,
team_score = home_points,
opp_score = away_points,
team_post_wp = home_postgame_win_probability,
opp_post_wp = away_postgame_win_probability
),
# Away team perspective
all_fbs_raw %>%
filter(completed == TRUE) %>%
transmute(
game_id = id,
season,
week,
team = away_team,
team_conf = away_conference,
opponent = home_team,
opp_conf = home_conference,
team_score = away_points,
opp_score = home_points,
team_post_wp = away_postgame_win_probability,
opp_post_wp = home_postgame_win_probability
)
) %>%
filter(team_conf %in% P5_CONFERENCES) %>%
mutate(
point_diff = team_score - opp_score,
abs_diff = abs(point_diff),
result = case_when(
point_diff > 0 ~ "Win",
point_diff < 0 ~ "Loss",
TRUE ~ "Tie"
),
one_score = abs_diff <= 8
)
cat(glue("{n_distinct(p5_team_games$team)} unique P5 teams across all seasons.\n"))
## 69 unique P5 teams across all seasons.
neb_one_score <- nebraska_games %>% filter(one_score)
neb_os_summary <- neb_one_score %>%
count(result, name = "games") %>%
mutate(pct = games / sum(games))
neb_os_summary %>%
gt() %>%
tab_header(
title = md("**Nebraska One-Score Game Record**"),
subtitle = glue("{START_YEAR}–{END_YEAR}")
) %>%
cols_label(result = "Result", games = "Games", pct = "Pct") %>%
fmt_percent(columns = pct, decimals = 1) %>%
tab_source_note("One-score = final margin ≤ 8 points. Source: CollegeFootballData.com")
| Nebraska One-Score Game Record | ||
| 2014–2025 | ||
| Result | Games | Pct |
|---|---|---|
| Loss | 50 | 67.6% |
| Win | 24 | 32.4% |
| One-score = final margin ≤ 8 points. Source: CollegeFootballData.com | ||
neb_yearly <- neb_one_score %>%
count(season, result, name = "games") %>%
pivot_wider(names_from = result, values_from = games, values_fill = 0) %>%
mutate(
Total = Win + Loss,
Win_Rate = Win / Total
)
neb_yearly %>%
gt() %>%
tab_header(
title = md("**Nebraska One-Score Record by Season**"),
subtitle = glue("{START_YEAR}–{END_YEAR}")
) %>%
fmt_percent(columns = Win_Rate, decimals = 1) %>%
data_color(
columns = Win_Rate,
fn = scales::col_numeric(palette = c(loss_color, "white", win_color), domain = c(0, 1))
) %>%
tab_source_note("Source: CollegeFootballData.com")
| Nebraska One-Score Record by Season | ||||
| 2014–2025 | ||||
| season | Loss | Win | Total | Win_Rate |
|---|---|---|---|---|
| 2014 | 3 | 2 | 5 | 40.0% |
| 2015 | 6 | 3 | 9 | 33.3% |
| 2016 | 1 | 3 | 4 | 75.0% |
| 2017 | 3 | 2 | 5 | 40.0% |
| 2018 | 5 | 1 | 6 | 16.7% |
| 2019 | 4 | 2 | 6 | 33.3% |
| 2020 | 3 | 2 | 5 | 40.0% |
| 2021 | 8 | 0 | 8 | 0.0% |
| 2022 | 5 | 2 | 7 | 28.6% |
| 2023 | 5 | 1 | 6 | 16.7% |
| 2024 | 5 | 2 | 7 | 28.6% |
| 2025 | 2 | 4 | 6 | 66.7% |
| Source: CollegeFootballData.com | ||||
# Add coach era for facet coloring
neb_yearly_plot <- neb_yearly %>%
mutate(
coach = case_when(
season <= 2014 ~ "Pelini",
season >= 2015 & season <= 2017 ~ "Riley",
season >= 2018 & season <= 2021 ~ "Frost",
season >= 2022 ~ "Rhule",
TRUE ~ "Other"
)
)
ggplot(neb_yearly_plot, aes(x = season, y = Win_Rate)) +
geom_hline(yintercept = 0.50, linetype = "dashed", color = "gray60") +
geom_col(aes(fill = coach), width = 0.7) +
geom_text(aes(label = glue("{Win}-{Loss}")), vjust = -0.5, size = 3.5) +
scale_y_continuous(labels = percent_format(), limits = c(0, 1.05)) +
scale_fill_manual(values = c(
"Pelini" = "#D4A017", "Riley" = "#6A5ACD",
"Frost" = "#FF6347", "Rhule" = husker_red
)) +
labs(
title = "Nebraska One-Score Win Rate by Season",
subtitle = "Bar labels show W-L record | Dashed line = 50%",
x = NULL, y = "Win Rate", fill = "Head Coach",
caption = "Source: CollegeFootballData.com"
)
p5_one_score <- p5_team_games %>% filter(one_score)
p5_os_summary <- p5_one_score %>%
group_by(team) %>%
summarise(
seasons = n_distinct(season),
games = n(),
wins = sum(result == "Win"),
losses = sum(result == "Loss"),
win_rate = wins / games,
.groups = "drop"
) %>%
arrange(win_rate) %>%
mutate(rank = row_number())
neb_rank <- p5_os_summary %>% filter(team == TEAM) %>% pull(rank)
total_teams <- nrow(p5_os_summary)
cat(glue("Nebraska ranks 66 out of {total_teams} P5 teams in one-score win rate.\n"))
## Nebraska ranks 66 out of 69 P5 teams in one-score win rate.
# Show top 10, bottom 10, and Nebraska (if not already included)
top_bottom <- bind_rows(
p5_os_summary %>% slice_max(win_rate, n = 10) %>% mutate(group = "Top 10"),
p5_os_summary %>% slice_min(win_rate, n = 10) %>% mutate(group = "Bottom 10")
) %>%
distinct(team, .keep_all = TRUE)
# Ensure Nebraska is included
if (!TEAM %in% top_bottom$team) {
top_bottom <- bind_rows(
top_bottom,
p5_os_summary %>% filter(team == TEAM) %>% mutate(group = "Nebraska")
)
}
top_bottom %>%
arrange(desc(win_rate)) %>%
mutate(rank_overall = rank(desc(win_rate), ties.method = "min")) %>%
select(team, games, wins, losses, win_rate, group) %>%
gt(groupname_col = "group") %>%
tab_header(
title = md("**P5 One-Score Win Rate — Top & Bottom 10**"),
subtitle = glue("{START_YEAR}–{END_YEAR} | {total_teams} P5 programs")
) %>%
fmt_percent(columns = win_rate, decimals = 1) %>%
tab_style(
style = list(cell_fill(color = "#FFF3E0"), cell_text(weight = "bold")),
locations = cells_body(rows = team == TEAM)
) %>%
tab_source_note("Source: CollegeFootballData.com")
| P5 One-Score Win Rate — Top & Bottom 10 | ||||
| 2014–2025 | 69 P5 programs | ||||
| team | games | wins | losses | win_rate |
|---|---|---|---|---|
| Top 10 | ||||
| Houston | 15 | 11 | 4 | 73.3% |
| BYU | 14 | 10 | 4 | 71.4% |
| Ohio State | 31 | 22 | 9 | 71.0% |
| Clemson | 49 | 34 | 15 | 69.4% |
| Georgia | 45 | 31 | 14 | 68.9% |
| Michigan State | 49 | 33 | 16 | 67.3% |
| Alabama | 42 | 28 | 14 | 66.7% |
| Michigan | 41 | 26 | 15 | 63.4% |
| Oklahoma | 56 | 35 | 21 | 62.5% |
| Oklahoma State | 62 | 38 | 24 | 61.3% |
| Bottom 10 | ||||
| Iowa State | 70 | 30 | 40 | 42.9% |
| Oregon State | 46 | 19 | 27 | 41.3% |
| Virginia Tech | 56 | 22 | 34 | 39.3% |
| Auburn | 57 | 22 | 35 | 38.6% |
| UCF | 13 | 5 | 8 | 38.5% |
| Purdue | 52 | 19 | 33 | 36.5% |
| Kansas | 41 | 14 | 27 | 34.1% |
| Nebraska | 74 | 24 | 50 | 32.4% |
| Cincinnati | 14 | 4 | 10 | 28.6% |
| Arkansas | 57 | 16 | 41 | 28.1% |
| Source: CollegeFootballData.com | ||||
p5_median <- median(p5_os_summary$win_rate, na.rm = TRUE)
neb_wr <- p5_os_summary %>% filter(team == TEAM) %>% pull(win_rate)
ggplot(p5_os_summary, aes(x = win_rate)) +
geom_histogram(binwidth = 0.03, fill = neutral_gray, color = "white") +
geom_vline(xintercept = neb_wr, color = husker_red, linewidth = 1.2, linetype = "solid") +
geom_vline(xintercept = p5_median, color = "steelblue", linewidth = 1, linetype = "dashed") +
annotate("text", x = neb_wr + 0.01, y = Inf, label = "Nebraska", vjust = 2,
color = husker_red, fontface = "bold", hjust = 0) +
annotate("text", x = p5_median + 0.01, y = Inf, label = "P5 Median", vjust = 3.5,
color = "steelblue", hjust = 0) +
scale_x_continuous(labels = percent_format()) +
labs(
title = "Where Nebraska Falls Among P5 Programs",
subtitle = "Distribution of one-score win rates, all P5 teams",
x = "One-Score Win Rate", y = "Count",
caption = "Source: CollegeFootballData.com"
)
big_ten_os <- p5_os_summary %>%
inner_join(
p5_team_games %>% filter(team_conf == "Big Ten") %>% distinct(team),
by = "team"
) %>%
arrange(desc(win_rate))
ggplot(big_ten_os, aes(x = reorder(team, win_rate), y = win_rate)) +
geom_col(aes(fill = if_else(team == TEAM, "Nebraska", "Other")), width = 0.7) +
geom_text(aes(label = glue("{wins}-{losses}")), hjust = -0.15, size = 3.3) +
coord_flip() +
scale_y_continuous(labels = percent_format(), limits = c(0, max(big_ten_os$win_rate) + 0.08)) +
scale_fill_manual(values = c("Nebraska" = husker_red, "Other" = neutral_gray), guide = "none") +
labs(
title = "Big Ten One-Score Win Rates",
subtitle = glue("{START_YEAR}–{END_YEAR} | Labels = W-L record"),
x = NULL, y = "One-Score Win Rate",
caption = "Source: CollegeFootballData.com"
)
The CFBD post-game win probability quantifies how likely a team “should have” won based on in-game statistical performance — not the final scoreboard. A high post-game WP loss means the team dominated statistically but still found a way to lose.
neb_high_wp_losses <- nebraska_games %>%
filter(
result == "Loss",
!is.na(team_postgame_wp),
team_postgame_wp >= WP_THRESH
) %>%
arrange(desc(team_postgame_wp)) %>%
select(
season, week, game_date, opponent, opp_conf,
team_score, opp_score, point_diff, team_postgame_wp, coach
)
cat(glue("{nrow(neb_high_wp_losses)} Nebraska losses with post-game WP ≥ {percent(WP_THRESH)}.\n"))
## 9 Nebraska losses with post-game WP ≥ 70%.
neb_high_wp_losses %>%
gt() %>%
tab_header(
title = md(glue("**Nebraska Losses Where They 'Should Have Won'**")),
subtitle = glue("Post-game WP ≥ {percent(WP_THRESH)} | {START_YEAR}–{END_YEAR}")
) %>%
cols_label(
season = "Season",
week = "Wk",
game_date = "Date",
opponent = "Opponent",
opp_conf = "Opp Conf",
team_score = "NEB",
opp_score = "OPP",
point_diff = "+/-",
team_postgame_wp = "Post-WP",
coach = "Coach"
) %>%
fmt_percent(columns = team_postgame_wp, decimals = 1) %>%
data_color(
columns = team_postgame_wp,
fn = scales::col_numeric(palette = c("#FFCC80", "#E65100"), domain = c(WP_THRESH, 1))
) %>%
tab_source_note("Post-game WP = probability of winning based on in-game statistical performance. Source: CFBD")
| Nebraska Losses Where They ‘Should Have Won’ | |||||||||
| Post-game WP ≥ 70% | 2014–2025 | |||||||||
| Season | Wk | Date | Opponent | Opp Conf | NEB | OPP | +/- | Post-WP | Coach |
|---|---|---|---|---|---|---|---|---|---|
| 2021 | 13 | 2021-11-26 | Iowa | Big Ten | 21 | 28 | -7 | 96.6% | Frost |
| 2023 | 1 | 2023-09-01 | Minnesota | Big Ten | 10 | 13 | -3 | 95.3% | Rhule (Yr 1-2) |
| 2024 | 14 | 2024-11-30 | Iowa | Big Ten | 10 | 13 | -3 | 93.9% | Rhule (Yr 3+) |
| 2023 | 12 | 2023-11-19 | Wisconsin | Big Ten | 17 | 24 | -7 | 91.6% | Rhule (Yr 1-2) |
| 2018 | 10 | 2018-11-03 | Ohio State | Big Ten | 31 | 36 | -5 | 87.0% | Frost |
| 2019 | 14 | 2019-11-29 | Iowa | Big Ten | 24 | 27 | -3 | 86.1% | Frost |
| 2021 | 4 | 2021-09-25 | Michigan State | Big Ten | 20 | 23 | -3 | 82.7% | Frost |
| 2014 | 1 | 2014-12-28 | USC | Pac-12 | 42 | 45 | -3 | 76.6% | Pelini |
| 2018 | 3 | 2018-09-15 | Troy | Sun Belt | 19 | 24 | -5 | 73.5% | Frost |
| Post-game WP = probability of winning based on in-game statistical performance. Source: CFBD | |||||||||
p5_high_wp_losses <- p5_team_games %>%
filter(
result == "Loss",
!is.na(team_post_wp),
team_post_wp >= WP_THRESH
) %>%
group_by(team) %>%
summarise(
high_wp_losses = n(),
total_losses = sum(p5_team_games$team == first(team) & p5_team_games$result == "Loss"),
seasons_played = n_distinct(season),
per_season = high_wp_losses / seasons_played,
avg_wp = mean(team_post_wp),
.groups = "drop"
) %>%
arrange(desc(high_wp_losses))
# Recalculate total losses properly
p5_total_losses <- p5_team_games %>%
filter(result == "Loss") %>%
count(team, name = "total_losses")
p5_high_wp_losses <- p5_high_wp_losses %>%
select(-total_losses) %>%
left_join(p5_total_losses, by = "team") %>%
mutate(pct_of_losses = high_wp_losses / total_losses)
neb_hwp_rank <- which(p5_high_wp_losses$team == TEAM)
# Top 20 teams by high-WP loss count
top20_hwp <- p5_high_wp_losses %>%
slice_max(high_wp_losses, n = 20)
ggplot(top20_hwp, aes(x = reorder(team, high_wp_losses), y = high_wp_losses)) +
geom_col(aes(fill = if_else(team == TEAM, "Nebraska", "Other")), width = 0.7) +
geom_text(aes(label = high_wp_losses), hjust = -0.3, size = 3.5) +
coord_flip() +
scale_fill_manual(values = c("Nebraska" = husker_red, "Other" = neutral_gray), guide = "none") +
labs(
title = glue("P5 Teams with Most High-WP Losses (≥ {percent(WP_THRESH)})"),
subtitle = glue("{START_YEAR}–{END_YEAR} | Games where the loser 'should have won' statistically"),
x = NULL, y = "Number of High-WP Losses",
caption = "Source: CollegeFootballData.com"
)
if (nrow(neb_high_wp_losses) > 0) {
ggplot(neb_high_wp_losses, aes(x = game_date, y = team_postgame_wp)) +
geom_segment(aes(xend = game_date, yend = WP_THRESH), color = "gray70") +
geom_point(aes(size = abs(point_diff)), color = husker_red, alpha = 0.85) +
geom_text_repel(
aes(label = glue("{opponent}\n{team_score}-{opp_score}")),
size = 3, max.overlaps = 15
) +
geom_hline(yintercept = WP_THRESH, linetype = "dashed", color = "gray50") +
scale_y_continuous(labels = percent_format(), limits = c(WP_THRESH - 0.02, 1)) +
scale_size_continuous(range = c(3, 8), name = "Margin of Loss") +
labs(
title = "Nebraska's 'Should Have Won' Losses Over Time",
subtitle = glue("Each point = a loss where post-game WP ≥ {percent(WP_THRESH)}"),
x = NULL, y = "Post-Game Win Probability",
caption = "Larger dots = wider margin of defeat. Source: CFBD"
)
}
For each high-WP loss, we pull play-by-play data and flag penalties and turnovers to see if common patterns explain how Nebraska loses games they statistically dominate.
# Build a lookup of high-WP loss game identifiers
hwp_game_ids <- nebraska_games %>%
filter(
result == "Loss",
!is.na(team_postgame_wp),
team_postgame_wp >= WP_THRESH
) %>%
select(id, season, week, opponent, team_score, opp_score, team_postgame_wp, coach,
season_type)
# Fetch play-by-play for each high-WP loss
hwp_plays <- map_dfr(seq_len(nrow(hwp_game_ids)), function(i) {
row <- hwp_game_ids[i, ]
polite_pause(0.25)
st <- if_else(row$season_type == "postseason", "postseason", "regular")
plays <- get_plays(
year = row$season,
week = row$week,
team = TEAM,
season_type = st
)
if (nrow(plays) == 0) return(tibble())
plays %>%
mutate(
game_id = row$id,
opponent = row$opponent,
game_label = glue("{row$season} Wk{row$week} vs {row$opponent} ({row$team_score}-{row$opp_score})"),
game_wp = row$team_postgame_wp,
coach = row$coach
)
})
cat(glue("{nrow(hwp_plays)} plays fetched across {n_distinct(hwp_plays$game_id)} high-WP loss games.\n"))
## 1621 plays fetched across 9 high-WP loss games.
# ── Turnover & Penalty Classification ──
#
# TURNOVERS: The CFBD API uses structured play_type values for turnovers
# (e.g., "Fumble Recovery (Opponent)", "Interception Return"). We match
# on these exact types rather than regex on play_text to avoid overcounting.
# Attribution: if offense == TEAM, Nebraska committed the turnover.
#
# PENALTIES: The API has play_type == "Penalty" for standalone penalty plays
# and embeds penalty info in play_text for others. There is NO penalty_team
# field, so we attribute penalties by classifying the infraction as offensive
# or defensive, then checking the offense/defense field accordingly.
# ──────────────────────────────────────────────────────────────────────
# play_type values that represent turnovers (opponent recovered)
TURNOVER_PLAY_TYPES <- c(
"Fumble Recovery (Opponent)",
"Fumble Recovery (Opponent) Touchdown",
"Interception",
"Interception Return",
"Interception Return Touchdown",
"Pass Interception",
"Pass Interception Return"
)
# Penalty infraction keywords classified by who commits them
OFFENSIVE_PENALTY_KW <- regex(
str_c(
"holding", "false start", "illegal formation", "illegal motion",
"illegal shift", "illegal procedure", "ineligible",
"intentional grounding", "chop block", "illegal block",
"tripping", "offensive pass interference", "illegal touching",
"illegal forward pass", "illegal substitution",
sep = "|"
),
ignore_case = TRUE
)
DEFENSIVE_PENALTY_KW <- regex(
str_c(
"offsides", "encroachment", "neutral zone", "roughing the passer",
"roughing the kicker", "running into the kicker",
"defensive pass interference", "defensive holding",
"illegal contact", "leverage", "targeting",
"kick catching interference", "leaping",
sep = "|"
),
ignore_case = TRUE
)
hwp_plays_classified <- hwp_plays %>%
mutate(
play_text_lower = str_to_lower(play_text),
neb_on_offense = coalesce(str_to_upper(offense) == str_to_upper(TEAM), FALSE),
neb_on_defense = coalesce(str_to_upper(defense) == str_to_upper(TEAM), FALSE),
# ── TURNOVERS (Nebraska-committed) ──
# Match on exact play_type values; attribute via offense field.
is_fumble = play_type %in% c(
"Fumble Recovery (Opponent)",
"Fumble Recovery (Opponent) Touchdown"
) & neb_on_offense,
is_interception = play_type %in% c(
"Interception", "Interception Return",
"Interception Return Touchdown",
"Pass Interception", "Pass Interception Return"
) & neb_on_offense,
is_turnover = is_fumble | is_interception,
turnover_type = case_when(
is_fumble ~ "Fumble",
is_interception ~ "Interception",
TRUE ~ NA_character_
),
# ── PENALTIES (Nebraska-committed) ──
# Step 1: Is this play a penalty at all?
has_penalty = coalesce(
play_type == "Penalty" |
str_detect(play_text_lower, "penalty"),
FALSE
),
# Step 2: Classify the infraction as offensive or defensive
penalty_is_offensive = has_penalty & coalesce(str_detect(play_text_lower, OFFENSIVE_PENALTY_KW), FALSE),
penalty_is_defensive = has_penalty & coalesce(str_detect(play_text_lower, DEFENSIVE_PENALTY_KW), FALSE),
# Step 3: Attribute to Nebraska
# - Offensive infraction + Nebraska on offense → Nebraska penalty
# - Defensive infraction + Nebraska on defense → Nebraska penalty
# - Unclassified penalty + team name near "penalty" in text → Nebraska
penalty_unclassified = has_penalty & !penalty_is_offensive & !penalty_is_defensive,
neb_name_in_penalty = has_penalty & coalesce(
str_detect(play_text_lower, regex(
str_c("penalty.*", str_to_lower(TEAM), "|", str_to_lower(TEAM), ".*penalty"),
ignore_case = TRUE
)), FALSE
),
is_penalty = has_penalty & (
(penalty_is_offensive & neb_on_offense) |
(penalty_is_defensive & neb_on_defense) |
(penalty_unclassified & neb_name_in_penalty)
),
# Extract the infraction name for charting
penalty_detail = if_else(
is_penalty,
str_extract(play_text_lower, str_c(
"holding|false start|illegal formation|illegal motion|illegal shift|",
"illegal procedure|ineligible|intentional grounding|chop block|",
"illegal block|tripping|offensive pass interference|illegal touching|",
"offsides|encroachment|neutral zone|roughing the passer|",
"roughing the kicker|running into the kicker|",
"defensive pass interference|defensive holding|",
"illegal contact|leverage|targeting|kick catching interference|",
"unsportsmanlike conduct|face mask|personal foul|",
"illegal substitution|delay of game|too many men|",
"unnecessary roughness|illegal forward pass|leaping"
)) %>% str_to_title(),
NA_character_
),
# ── Game phase ──
game_half = case_when(
period <= 2 ~ "1st Half",
period <= 4 ~ "2nd Half",
TRUE ~ "OT"
),
is_4th_qtr = (period == 4),
clock_min_num = suppressWarnings(as.numeric(str_extract(clock_minutes, "\\d+"))),
is_late_game = coalesce(period == 4 & clock_min_num <= 5, FALSE)
)
# Count turnovers and penalties per game
hwp_game_flags <- hwp_plays_classified %>%
group_by(game_id, game_label, opponent, game_wp, coach) %>%
summarise(
total_plays = n(),
turnovers = sum(is_turnover, na.rm = TRUE),
fumbles = sum(is_fumble, na.rm = TRUE),
interceptions = sum(is_interception, na.rm = TRUE),
penalties = sum(is_penalty, na.rm = TRUE),
late_turnovers = sum(is_turnover & is_late_game, na.rm = TRUE),
late_penalties = sum(is_penalty & is_late_game, na.rm = TRUE),
q4_turnovers = sum(is_turnover & is_4th_qtr, na.rm = TRUE),
q4_penalties = sum(is_penalty & is_4th_qtr, na.rm = TRUE),
.groups = "drop"
) %>%
arrange(desc(game_wp))
hwp_game_flags %>%
select(game_label, game_wp, turnovers, fumbles, interceptions,
penalties, q4_turnovers, q4_penalties) %>%
gt() %>%
tab_header(
title = md("**Nebraska-Committed Turnovers & Penalties in High-WP Losses**"),
subtitle = glue("Only Nebraska's own TOs & flags | Post-game WP ≥ {percent(WP_THRESH)}")
) %>%
cols_label(
game_label = "Game",
game_wp = "Post-WP",
turnovers = "TO",
fumbles = "Fum",
interceptions = "INT",
penalties = "PEN",
q4_turnovers = "Q4 TO",
q4_penalties = "Q4 PEN"
) %>%
fmt_percent(columns = game_wp, decimals = 1) %>%
data_color(
columns = c(turnovers, penalties),
fn = scales::col_numeric(palette = c("white", "#E65100"), domain = NULL)
) %>%
tab_spanner(label = "Overall", columns = c(turnovers, fumbles, interceptions, penalties)) %>%
tab_spanner(label = "4th Quarter", columns = c(q4_turnovers, q4_penalties)) %>%
tab_source_note("Source: CFBD play-by-play data")
| Nebraska-Committed Turnovers & Penalties in High-WP Losses | |||||||
| Only Nebraska's own TOs & flags | Post-game WP ≥ 70% | |||||||
| Game | Post-WP |
Overall
|
4th Quarter
|
||||
|---|---|---|---|---|---|---|---|
| TO | Fum | INT | PEN | Q4 TO | Q4 PEN | ||
| 2021 Wk13 vs Iowa (21-28) | 96.6% | 1 | 0 | 1 | 4 | 1 | 2 |
| 2023 Wk1 vs Minnesota (10-13) | 95.3% | 4 | 1 | 3 | 7 | 2 | 2 |
| 2024 Wk14 vs Iowa (10-13) | 93.9% | 0 | 0 | 0 | 4 | 0 | 0 |
| 2023 Wk12 vs Wisconsin (17-24) | 91.6% | 0 | 0 | 0 | 6 | 0 | 2 |
| 2018 Wk10 vs Ohio State (31-36) | 87.0% | 1 | 1 | 0 | 5 | 0 | 0 |
| 2019 Wk14 vs Iowa (24-27) | 86.1% | 1 | 0 | 1 | 3 | 0 | 2 |
| 2021 Wk4 vs Michigan State (20-23) | 82.7% | 0 | 0 | 0 | 5 | 0 | 0 |
| 2014 Wk1 vs USC (42-45) | 76.6% | 1 | 0 | 1 | 9 | 0 | 3 |
| 2018 Wk3 vs Troy (19-24) | 73.5% | 3 | 1 | 2 | 10 | 1 | 2 |
| Source: CFBD play-by-play data | |||||||
# Average turnovers/penalties across all high-WP losses vs all other losses
neb_loss_game_ids <- nebraska_games %>%
filter(result == "Loss") %>%
pull(id)
all_loss_plays <- map_dfr(seq_len(nrow(nebraska_games %>% filter(result == "Loss"))), function(i) {
row <- (nebraska_games %>% filter(result == "Loss"))[i, ]
polite_pause(0.15)
st <- if_else(row$season_type == "postseason", "postseason", "regular")
plays <- get_plays(row$season, row$week, TEAM, season_type = st)
if (nrow(plays) == 0) return(tibble())
plays %>% mutate(game_id = row$id, team_postgame_wp = row$team_postgame_wp)
})
all_loss_summary <- all_loss_plays %>%
mutate(
play_text_lower = str_to_lower(play_text),
neb_on_offense = coalesce(str_to_upper(offense) == str_to_upper(TEAM), FALSE),
neb_on_defense = coalesce(str_to_upper(defense) == str_to_upper(TEAM), FALSE),
# Nebraska-committed turnovers (exact play_type match + offense check)
is_turnover = (play_type %in% TURNOVER_PLAY_TYPES) & neb_on_offense,
# Nebraska-committed penalties (classify infraction + offense/defense check)
has_penalty = coalesce(
play_type == "Penalty" | str_detect(play_text_lower, "penalty"), FALSE
),
penalty_is_offensive = has_penalty & coalesce(str_detect(play_text_lower, OFFENSIVE_PENALTY_KW), FALSE),
penalty_is_defensive = has_penalty & coalesce(str_detect(play_text_lower, DEFENSIVE_PENALTY_KW), FALSE),
penalty_unclassified = has_penalty & !penalty_is_offensive & !penalty_is_defensive,
neb_name_in_penalty = has_penalty & coalesce(
str_detect(play_text_lower, regex(
str_c("penalty.*", str_to_lower(TEAM), "|", str_to_lower(TEAM), ".*penalty"),
ignore_case = TRUE
)), FALSE
),
is_penalty = has_penalty & (
(penalty_is_offensive & neb_on_offense) |
(penalty_is_defensive & neb_on_defense) |
(penalty_unclassified & neb_name_in_penalty)
),
wp_group = if_else(
!is.na(team_postgame_wp) & team_postgame_wp >= WP_THRESH,
glue("High-WP Loss\n(≥ {percent(WP_THRESH)})"),
"Other Loss"
)
) %>%
group_by(game_id, wp_group) %>%
summarise(
turnovers = sum(is_turnover, na.rm = TRUE),
penalties = sum(is_penalty, na.rm = TRUE),
.groups = "drop"
)
comparison_data <- all_loss_summary %>%
group_by(wp_group) %>%
summarise(
games = n(),
avg_turnovers = mean(turnovers),
avg_penalties = mean(penalties),
.groups = "drop"
) %>%
pivot_longer(cols = c(avg_turnovers, avg_penalties), names_to = "metric", values_to = "avg") %>%
mutate(metric = str_replace(metric, "avg_", "") %>% str_to_title())
ggplot(comparison_data, aes(x = metric, y = avg, fill = wp_group)) +
geom_col(position = "dodge", width = 0.6) +
geom_text(aes(label = round(avg, 1)), position = position_dodge(width = 0.6),
vjust = -0.5, size = 4) +
scale_fill_manual(values = c(husker_red, neutral_gray)) +
labs(
title = "High-WP Losses vs Other Losses: Nebraska-Committed TOs & Penalties",
subtitle = "Average per game | Only counting Nebraska's own turnovers & flags",
x = NULL, y = "Average per Game", fill = NULL,
caption = "Source: CFBD play-by-play data"
)
penalty_breakdown <- hwp_plays_classified %>%
filter(is_penalty, !is.na(penalty_detail)) %>%
count(penalty_detail, sort = TRUE) %>%
slice_head(n = 12) %>%
rename(penalty_name = penalty_detail)
if (nrow(penalty_breakdown) > 0) {
ggplot(penalty_breakdown, aes(x = reorder(penalty_name, n), y = n)) +
geom_col(fill = husker_red, width = 0.7) +
geom_text(aes(label = n), hjust = -0.3, size = 3.5) +
coord_flip() +
labs(
title = "Most Common Nebraska-Committed Penalties in High-WP Losses",
subtitle = glue("Across {n_distinct(hwp_plays_classified$game_id)} games with post-WP ≥ {percent(WP_THRESH)}"),
x = NULL, y = "Occurrences",
caption = "Source: CFBD play-by-play data"
)
}
turnover_timing <- hwp_plays_classified %>%
filter(is_turnover) %>%
count(game_half, turnover_type) %>%
mutate(game_half = factor(game_half, levels = c("1st Half", "2nd Half", "OT")))
if (nrow(turnover_timing) > 0) {
ggplot(turnover_timing, aes(x = game_half, y = n, fill = turnover_type)) +
geom_col(position = "stack", width = 0.6) +
geom_text(aes(label = n), position = position_stack(vjust = 0.5), color = "white", size = 4) +
scale_fill_manual(values = c("Fumble" = "#E65100", "Interception" = "#1565C0")) +
labs(
title = "When Do Nebraska's Turnovers Happen in High-WP Losses?",
subtitle = "Nebraska-committed fumbles vs interceptions by game half",
x = NULL, y = "Turnover Count", fill = "Type",
caption = "Source: CFBD play-by-play data"
)
}
Excerpts from the play-by-play in Nebraska’s highest-WP losses — only plays where Nebraska committed the turnover or penalty.
critical_turnovers <- hwp_plays_classified %>%
filter(is_turnover) %>% # already filtered to Nebraska-committed via offense field
arrange(desc(game_wp), desc(is_late_game)) %>%
select(game_label, period, clock_minutes, play_type, play_text, turnover_type) %>%
slice_head(n = 25)
critical_turnovers %>%
gt() %>%
tab_header(
title = md("**Critical Turnover Plays in High-WP Losses**"),
subtitle = "Sorted by game WP and late-game timing"
) %>%
cols_label(
game_label = "Game",
period = "Qtr",
clock_minutes = "Clock",
play_type = "Type",
play_text = "Description",
turnover_type = "TO Type"
) %>%
tab_style(
style = cell_text(size = "small"),
locations = cells_body(columns = play_text)
) %>%
cols_width(play_text ~ px(350))
| Critical Turnover Plays in High-WP Losses | |||||
| Sorted by game WP and late-game timing | |||||
| Game | Qtr | Clock | Type | Description | TO Type |
|---|---|---|---|---|---|
| 2021 Wk13 vs Iowa (21-28) | 4 | 0 | Interception | Logan Smothers pass intercepted | Interception |
| 2023 Wk1 vs Minnesota (10-13) | 4 | 4 | Fumble Recovery (Opponent) | Anthony Grant run for 9 yds to the MINN 47 Anthony Grant fumbled, recovered by MINN Aidan Gousby | Fumble |
| 2023 Wk1 vs Minnesota (10-13) | 4 | 0 | Interception | Jeff Sims pass intercepted | Interception |
| 2023 Wk1 vs Minnesota (10-13) | 2 | 8 | Interception | Jeff Sims pass intercepted | Interception |
| 2023 Wk1 vs Minnesota (10-13) | 2 | 0 | Interception | Jeff Sims pass intercepted, touchback. | Interception |
| 2018 Wk10 vs Ohio State (31-36) | 2 | 13 | Fumble Recovery (Opponent) | Adrian Martinez run for a loss of 16 yards to the OhSt 26 Adrian Martinez fumbled, recovered by OhSt at OhSt 26. | Fumble |
| 2019 Wk14 vs Iowa (24-27) | 2 | 0 | Pass Interception Return | Adrian Martinez pass intercepted Jack Koerner return for 20 yds to the Iowa 25 | Interception |
| 2014 Wk1 vs USC (42-45) | 2 | 1 | Pass Interception Return | Tommy Armstrong Jr. pass intercepted Su'a Cravens return for no gain to the USC 12 | Interception |
| 2018 Wk3 vs Troy (19-24) | 4 | 2 | Pass Interception Return | Andrew Bunch pass intercepted Will Sunderland return for 4 yds to the Neb 34 | Interception |
| 2018 Wk3 vs Troy (19-24) | 1 | 7 | Fumble Recovery (Opponent) | Andrew Bunch pass complete to Jack Stoll for 9 yds Jack Stoll fumbled, forced by Cedarius Rookard, recovered by Troy Hunter Reese | Fumble |
| 2018 Wk3 vs Troy (19-24) | 1 | 4 | Pass Interception Return | Andrew Bunch pass intercepted Tyler Murray return for 6 yds to the Troy 46 | Interception |
critical_penalties <- hwp_plays_classified %>%
filter(is_penalty) %>% # already filtered to Nebraska-committed via play_text attribution
arrange(desc(game_wp), desc(is_late_game)) %>%
select(game_label, period, clock_minutes, play_type, play_text) %>%
slice_head(n = 25)
critical_penalties %>%
gt() %>%
tab_header(
title = md("**Critical Penalty Plays in High-WP Losses**"),
subtitle = "Sorted by game WP and late-game timing"
) %>%
cols_label(
game_label = "Game",
period = "Qtr",
clock_minutes = "Clock",
play_type = "Type",
play_text = "Description"
) %>%
tab_style(
style = cell_text(size = "small"),
locations = cells_body(columns = play_text)
) %>%
cols_width(play_text ~ px(400))
| Critical Penalty Plays in High-WP Losses | ||||
| Sorted by game WP and late-game timing | ||||
| Game | Qtr | Clock | Type | Description |
|---|---|---|---|---|
| 2021 Wk13 vs Iowa (21-28) | 4 | 2 | Penalty | Smothers,Logan pass complete to Belt,Brody for 1 yard loss to the NEB17 (Jacobs,Jestin) PENALTY IOW Holding (Campbell,Jack) 10 yards from NEB18 to NEB28, 1ST DOWN. NO PLAY. |
| 2021 Wk13 vs Iowa (21-28) | 2 | 13 | Rush | Monte Pottebaum run for 3 yds to the NEBRASKA 49 for a 1ST down NEBRASKA Penalty, UNS: Unsportsmanlike Conduct (Caleb Tannor) to the Neb 34 for a 1ST down |
| 2021 Wk13 vs Iowa (21-28) | 2 | 4 | Penalty | NEBRASKA Penalty, Personal Foul (Marquel Dismuke) to the Neb 41 for a 1ST down |
| 2021 Wk13 vs Iowa (21-28) | 4 | 9 | Safety | Smothers,Logan sacked for loss of 6 yards to the NEB00 (Van Ness,Lukas). Iowa SAFETY, clock 09:56 PENALTY NEB Intentional Grounding (Smothers,Logan). NO PLAY. for a SAFETY |
| 2023 Wk1 vs Minnesota (10-13) | 4 | 3 | Penalty | Nebraska Penalty, Face mask (15 yards) (Cameron Lenhardt) to the NEB 28 for a 1ST down |
| 2023 Wk1 vs Minnesota (10-13) | 1 | 9 | Penalty | Nebraska Penalty, False Start (-5 Yards) to the NEB 6 |
| 2023 Wk1 vs Minnesota (10-13) | 1 | 9 | Penalty | Nebraska Penalty, Illegal Substitution (Nash Hutmacher) to the NEB 26 |
| 2023 Wk1 vs Minnesota (10-13) | 2 | 8 | Penalty | Nebraska Penalty, Disconcerting Signals on REIMER, Luke enforced (Luke Reimer) to the MINN 35 |
| 2023 Wk1 vs Minnesota (10-13) | 2 | 3 | Penalty | Nebraska Penalty, False Start (Ethan Piper) to the MINN 6 |
| 2023 Wk1 vs Minnesota (10-13) | 3 | 8 | Penalty | Nebraska Penalty, Targeting on ROBINSON, Ty enforced (Ty Robinson) to the NEB 45 for a 1ST down |
| 2023 Wk1 vs Minnesota (10-13) | 4 | 15 | Penalty | Nebraska Penalty, False Start (Nate Boerkircher) to the MINN 9 |
| 2024 Wk14 vs Iowa (10-13) | 1 | 3 | Penalty | Nebraska Penalty, Offsides (John Bullock) to the NEB 49 |
| 2024 Wk14 vs Iowa (10-13) | 2 | 12 | Penalty | Nebraska Penalty, Delay Of Game (Vincent Shavers Jr.) to the NEB 28 |
| 2024 Wk14 vs Iowa (10-13) | 2 | 6 | Penalty | Nebraska Penalty, False Start (-5 Yards) to the NEB 22 |
| 2024 Wk14 vs Iowa (10-13) | 3 | 13 | Penalty | Nebraska Penalty, False Start (-5 Yards) to the NEB 49 |
| 2023 Wk12 vs Wisconsin (17-24) | 4 | 3 | Penalty | Shotgun Purdy,Chubba pass incomplete short middle to Lloyd,Jaylen PENALTY WIS Holding (Fourqurean,Nyzier) 10 yards from NEB20 to NEB30, 1ST DOWN. NO PLAY. |
| 2023 Wk12 vs Wisconsin (17-24) | 2 | 1 | Penalty | Nebraska Penalty, Illegal Block (-10 Yards) to the NEB 16 |
| 2023 Wk12 vs Wisconsin (17-24) | 2 | 1 | Penalty | Shotgun Purdy,Chubba pass complete short right to Bullock,Alex for 18 yards to the WIS33, out of bounds at WIS33 PENALTY NEB Holding (Evans-Jenkins,Justin) 10 yards from NEB49 to NEB39. NO PLAY. |
| 2023 Wk12 vs Wisconsin (17-24) | 3 | 7 | Penalty | PENALTY NEB False Start (Prochazka,Teddy) 5 yards from NEB27 to NEB22. NO PLAY. |
| 2023 Wk12 vs Wisconsin (17-24) | 3 | 7 | Penalty | Nebraska Penalty, Ineligible Downfield on Pass (Yards) declined |
| 2023 Wk12 vs Wisconsin (17-24) | 4 | 15 | Penalty | PENALTY NEB False Start (Prochazka,Teddy) 5 yards from NEB50 to NEB45. NO PLAY. |
| 2018 Wk10 vs Ohio State (31-36) | 1 | 14 | Penalty | NEBRASKA Penalty, Illegal Formation (-5 Yards) to the Neb 23 |
| 2018 Wk10 vs Ohio State (31-36) | 1 | 9 | Kickoff | Caleb Lightbourn kickoff for 1 yd NEBRASKA Penalty, Defensive Offside (-5 Yards) to the Neb 31 |
| 2018 Wk10 vs Ohio State (31-36) | 1 | 3 | Penalty | NEBRASKA Penalty, Defensive Pass Interference (14 Yards) to the OhSt 48 for a 1ST down |
| 2018 Wk10 vs Ohio State (31-36) | 2 | 13 | Penalty | NEBRASKA Penalty, False Start (-5 Yards) to the OhSt 10 |
coach_summary <- nebraska_games %>%
filter(one_score) %>%
group_by(coach) %>%
summarise(
seasons = n_distinct(season),
os_games = n(),
os_wins = sum(result == "Win"),
os_losses = sum(result == "Loss"),
os_win_rate = os_wins / os_games,
high_wp_losses = sum(result == "Loss" & !is.na(team_postgame_wp) & team_postgame_wp >= WP_THRESH),
avg_postgame_wp_in_losses = mean(
team_postgame_wp[result == "Loss" & !is.na(team_postgame_wp)], na.rm = TRUE
),
.groups = "drop"
) %>%
arrange(os_win_rate)
coach_summary %>%
gt() %>%
tab_header(
title = md("**One-Score Performance by Coaching Era**"),
subtitle = glue("{START_YEAR}–{END_YEAR}")
) %>%
cols_label(
coach = "Coach",
seasons = "Seasons",
os_games = "1-Score G",
os_wins = "W",
os_losses = "L",
os_win_rate = "Win %",
high_wp_losses = glue("High-WP L\n(≥{percent(WP_THRESH)})"),
avg_postgame_wp_in_losses = "Avg WP in L"
) %>%
fmt_percent(columns = c(os_win_rate, avg_postgame_wp_in_losses), decimals = 1) %>%
data_color(
columns = os_win_rate,
fn = scales::col_numeric(palette = c(loss_color, "white", win_color), domain = c(0, 1))
) %>%
tab_source_note("Source: CollegeFootballData.com")
| One-Score Performance by Coaching Era | |||||||
| 2014–2025 | |||||||
| Coach | Seasons | 1-Score G | W | L | Win % | High-WP L (≥70%) | Avg WP in L |
|---|---|---|---|---|---|---|---|
| Frost | 4 | 25 | 5 | 20 | 20.0% | 5 | 44.6% |
| Rhule (Yr 1-2) | 2 | 13 | 3 | 10 | 23.1% | 2 | 43.4% |
| Pelini | 1 | 5 | 2 | 3 | 40.0% | 1 | 36.3% |
| Riley | 3 | 18 | 8 | 10 | 44.4% | 0 | 21.7% |
| Rhule (Yr 3+) | 2 | 13 | 6 | 7 | 46.2% | 1 | 27.7% |
| Source: CollegeFootballData.com | |||||||
coach_plot_data <- coach_summary %>%
select(coach, os_wins, os_losses, high_wp_losses) %>%
pivot_longer(cols = c(os_wins, os_losses, high_wp_losses),
names_to = "category", values_to = "count") %>%
mutate(
category = case_when(
category == "os_wins" ~ "One-Score Wins",
category == "os_losses" ~ "One-Score Losses",
category == "high_wp_losses" ~ glue("High-WP Losses (≥{percent(WP_THRESH)})")
),
category = factor(category, levels = c("One-Score Wins", "One-Score Losses",
glue("High-WP Losses (≥{percent(WP_THRESH)})")))
)
ggplot(coach_plot_data, aes(x = coach, y = count, fill = category)) +
geom_col(position = "dodge", width = 0.7) +
geom_text(aes(label = count), position = position_dodge(width = 0.7), vjust = -0.4, size = 3.5) +
scale_fill_manual(values = c(win_color, loss_color, "#FF6F00")) +
labs(
title = "One-Score Outcomes by Coaching Era",
subtitle = "Including high-WP losses where Nebraska dominated statistically",
x = NULL, y = "Games", fill = NULL,
caption = "Source: CollegeFootballData.com"
)
# ── Core one-score numbers ──
total_os <- nrow(neb_one_score)
total_os_w <- sum(neb_one_score$result == "Win")
total_os_l <- sum(neb_one_score$result == "Loss")
os_wr <- round(100 * total_os_w / total_os, 1)
neb_wr_dec <- total_os_w / total_os
p5_median_wr <- median(p5_os_summary$win_rate, na.rm = TRUE)
# ── P5 ranking ──
neb_p5_rank <- p5_os_summary %>% filter(team == TEAM) %>% pull(rank)
total_p5 <- nrow(p5_os_summary)
worst_p5 <- p5_os_summary %>% slice_min(win_rate, n = 1) %>% pull(team)
worst_p5_wr <- p5_os_summary %>% slice_min(win_rate, n = 1) %>% pull(win_rate)
# ── High-WP losses ──
hwp_count <- nrow(neb_high_wp_losses)
hwp_avg_wp <- if (hwp_count > 0) round(mean(neb_high_wp_losses$team_postgame_wp) * 100, 1) else NA
hwp_worst <- if (hwp_count > 0) neb_high_wp_losses %>%
slice_max(team_postgame_wp, n = 1) else NULL
hwp_worst_opp <- if (!is.null(hwp_worst)) hwp_worst$opponent else NA
hwp_worst_yr <- if (!is.null(hwp_worst)) hwp_worst$season else NA
hwp_worst_wp <- if (!is.null(hwp_worst)) round(hwp_worst$team_postgame_wp * 100, 1) else NA
# ── Turnover & penalty averages in high-WP losses ──
avg_hwp_to <- if (nrow(hwp_game_flags) > 0) round(mean(hwp_game_flags$turnovers), 1) else NA
avg_hwp_pn <- if (nrow(hwp_game_flags) > 0) round(mean(hwp_game_flags$penalties), 1) else NA
max_hwp_to <- if (nrow(hwp_game_flags) > 0) max(hwp_game_flags$turnovers) else NA
max_hwp_pn <- if (nrow(hwp_game_flags) > 0) max(hwp_game_flags$penalties) else NA
# ── Most common penalty type ──
top_pen <- if (exists("penalty_breakdown") && nrow(penalty_breakdown) > 0) {
penalty_breakdown %>% slice_head(n = 1) %>% pull(penalty_name)
} else { NA }
# ── Coach-era worst ──
worst_coach <- coach_summary %>% slice_min(os_win_rate, n = 1)
# ── Year-over-year streaks ──
consecutive_sub50 <- neb_yearly %>%
arrange(season) %>%
mutate(below_50 = Win_Rate < 0.50) %>%
pull(below_50) %>%
rle()
longest_sub50 <- if (any(consecutive_sub50$values)) {
max(consecutive_sub50$lengths[consecutive_sub50$values])
} else { 0 }
findings <- tibble(
Metric = c(
"One-Score Record",
"One-Score Win Rate",
"P5 Ranking (One-Score Win Rate)",
"P5 Median Win Rate",
glue("Losses with Post-Game WP ≥ {percent(WP_THRESH)}"),
"Avg Post-Game WP in Those Losses",
"Most Painful Loss (Highest WP)",
"Avg Nebraska Turnovers per High-WP Loss",
"Avg Nebraska Penalties per High-WP Loss",
"Most Common Nebraska Penalty in High-WP Losses",
"Worst Coach-Era One-Score Win Rate",
"Longest Streak of Sub-.500 One-Score Seasons"
),
Value = c(
glue("{total_os_w}–{total_os_l} ({total_os} games)"),
glue("{os_wr}%"),
glue("66 of {total_p5} P5 programs"),
glue("{round(p5_median_wr * 100, 1)}%"),
as.character(hwp_count),
if_else(!is.na(hwp_avg_wp), glue("{hwp_avg_wp}%"), "N/A"),
if_else(!is.na(hwp_worst_opp),
glue("{hwp_worst_yr} vs {hwp_worst_opp} ({hwp_worst_wp}% WP)"), "N/A"),
if_else(!is.na(avg_hwp_to), as.character(avg_hwp_to), "N/A"),
if_else(!is.na(avg_hwp_pn), as.character(avg_hwp_pn), "N/A"),
if_else(!is.na(top_pen), top_pen, "N/A"),
glue("{worst_coach$coach[1]} ({round(worst_coach$os_win_rate[1] * 100, 1)}%)"),
glue("{longest_sub50} seasons")
)
)
findings %>%
gt() %>%
tab_header(
title = md("**Nebraska's One-Score Curse — Key Metrics**"),
subtitle = glue("{START_YEAR}–{END_YEAR}")
) %>%
cols_label(Metric = "", Value = "") %>%
tab_style(
style = cell_text(weight = "bold"),
locations = cells_body(columns = Metric)
) %>%
tab_style(
style = list(cell_fill(color = "#FFF3E0"), cell_text(weight = "bold")),
locations = cells_body(rows = str_detect(Metric, "P5 Ranking|Most Painful|Worst Coach"))
) %>%
tab_source_note("Source: CollegeFootballData.com")
| Nebraska’s One-Score Curse — Key Metrics | |
| 2014–2025 | |
| One-Score Record | 24–50 (74 games) |
| One-Score Win Rate | 32.4% |
| P5 Ranking (One-Score Win Rate) | 66 of 69 P5 programs |
| P5 Median Win Rate | 50.9% |
| Losses with Post-Game WP ≥ 70% | 9 |
| Avg Post-Game WP in Those Losses | 87% |
| Most Painful Loss (Highest WP) | 2021 vs Iowa (96.6% WP) |
| Avg Nebraska Turnovers per High-WP Loss | 1.2 |
| Avg Nebraska Penalties per High-WP Loss | 5.9 |
| Most Common Nebraska Penalty in High-WP Losses | False Start |
| Worst Coach-Era One-Score Win Rate | Frost (20%) |
| Longest Streak of Sub-.500 One-Score Seasons | 8 seasons |
| Source: CollegeFootballData.com | |
cat(glue("
### One-Score Futility
Since {START_YEAR}, Nebraska has played **{total_os} one-score games** — games decided
by 8 points or fewer — going **{total_os_w}–{total_os_l}** for a **{os_wr}% win rate**.
The P5 median over the same span is {round(p5_median_wr * 100, 1)}%, placing Nebraska
**66 out of {total_p5}** Power Five programs. This is not a small-sample
anomaly; {total_os} one-score games over {length(SEASONS)} seasons is a large enough
body of work to be statistically meaningful.
### The 'Should Have Won' Games
Of Nebraska's losses in this period, **{hwp_count}** came in games where the team's
post-game win probability was at least {round(WP_THRESH * 100)}%. Post-game WP measures
how likely a team *should* have won based on their in-game statistical performance —
yards, first downs, explosiveness — independent of the final score. An average post-game
WP of {if_else(!is.na(hwp_avg_wp), paste0(hwp_avg_wp, '%'), 'N/A')} across those losses
means Nebraska consistently dominated the stat sheet and still found ways to lose.
### Self-Inflicted Wounds
The play-by-play breakdown reveals a recurring pattern. In those {hwp_count} high-WP
losses, Nebraska committed an average of **{avg_hwp_to} turnovers** and **{avg_hwp_pn}
penalties** per game. The most frequent infraction was **{if_else(!is.na(top_pen), top_pen, 'N/A')}**.
These are not random bad-luck bounces — they are execution failures and discipline
breakdowns that flip the outcome of games Nebraska was otherwise winning on the
stat sheet.
### No Coach Has Solved It
The one-score curse is not tied to a single coaching staff. {worst_coach$coach[1]}
posted the worst one-score win rate at {round(worst_coach$os_win_rate[1] * 100, 1)}%,
but no Nebraska coach in this window has sustained a one-score win rate near the P5
median. Nebraska has gone {longest_sub50} consecutive seasons with a sub-.500 record
in one-score games at its worst stretch — a level of sustained close-game futility
that is rare among Power Five programs.
### Bottom Line
Nebraska's one-score struggles are not narrative — they are one of the most
statistically documented patterns in modern college football. The combination of a
bottom-tier P5 one-score win rate, a disproportionate number of high-WP losses, and
a persistent pattern of turnovers and penalties in those games points to a program-level
issue with game management and late-game execution that has transcended coaching changes.
"))
Since 2014, Nebraska has played 74 one-score games — games decided by 8 points or fewer — going 24–50 for a 32.4% win rate. The P5 median over the same span is 50.9%, placing Nebraska 66 out of 69 Power Five programs. This is not a small-sample anomaly; 74 one-score games over 12 seasons is a large enough body of work to be statistically meaningful.
Of Nebraska’s losses in this period, 9 came in games where the team’s post-game win probability was at least 70%. Post-game WP measures how likely a team should have won based on their in-game statistical performance — yards, first downs, explosiveness — independent of the final score. An average post-game WP of 87% across those losses means Nebraska consistently dominated the stat sheet and still found ways to lose.
The play-by-play breakdown reveals a recurring pattern. In those 9 high-WP losses, Nebraska committed an average of 1.2 turnovers and 5.9 penalties per game. The most frequent infraction was False Start. These are not random bad-luck bounces — they are execution failures and discipline breakdowns that flip the outcome of games Nebraska was otherwise winning on the stat sheet.
The one-score curse is not tied to a single coaching staff. Frost posted the worst one-score win rate at 20%, but no Nebraska coach in this window has sustained a one-score win rate near the P5 median. Nebraska has gone 8 consecutive seasons with a sub-.500 record in one-score games at its worst stretch — a level of sustained close-game futility that is rare among Power Five programs.
Nebraska’s one-score struggles are not narrative — they are one of the most statistically documented patterns in modern college football. The combination of a bottom-tier P5 one-score win rate, a disproportionate number of high-WP losses, and a persistent pattern of turnovers and penalties in those games points to a program-level issue with game management and late-game execution that has transcended coaching changes.
Report generated 2026-06-11 12:20:00.098786 | Data: CollegeFootballData.com