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.

Setup & Configuration

Load Packages

# 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 Configuration

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

API Helper Functions

#' 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)
}

Data Collection

Nebraska Games (All Seasons)

# ── 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

Enrich Nebraska Game Data

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

All FBS Games (All Seasons)

# 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.

Build Per-Team P5 Records

# 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.

Nebraska One-Score Overview

Overall Record in One-Score Games

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

Season-by-Season Breakdown

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

Win Rate Trend

# 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 Comparison

One-Score Win Rate Rankings

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

Distribution Plot

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 Peers

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


Post-Game Win Probability Analysis

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.

Identifying High-WP Losses

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

Nebraska vs P5: High-WP Loss Frequency

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

Timeline of Nebraska’s High-WP Losses

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


Play-by-Play Deep Dive

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.

Fetch Play-by-Play for High-WP Losses

# 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.

Classify Plays: Turnovers & Penalties

# ── 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))

Turnover & Penalty Summary Table

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

Aggregate Patterns

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

Most Common Penalty Types

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 Analysis

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


Key Play Text Examples

Excerpts from the play-by-play in Nebraska’s highest-WP losses — only plays where Nebraska committed the turnover or penalty.

Critical Turnovers

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

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-Era Comparison

One-Score Records by Coach

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-Era Visualization

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


Summary & Findings

# ── 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 }

The Numbers

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

Interpretation

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

One-Score Futility

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.

The ‘Should Have Won’ Games

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.

Self-Inflicted Wounds

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.

No Coach Has Solved It

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.

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.


Report generated 2026-06-11 12:20:00.098786 | Data: CollegeFootballData.com