Skip to contents

Introduction

This vignette demonstrates how to create rich CFB (College Football) visualizations by combining cfbfastR for play-by-play data, cfbseedR for conference standings and College Football Playoff seeding, and sdvplotR for team logos, colors, and gt tables.

Setup

library(sdvplotR)
library(ggplot2)
library(cfbfastR)
library(cfbseedR)
library(dplyr)
library(gt)

# Get valid CFB team abbreviations
cfb_teams <- valid_team_names("cfb")
length(cfb_teams)  # every FBS and FCS program
head(cfb_teams)

Loading CFB Data

Use cfbfastR to load a season of play-by-play data:

# The last completed regular season (named for the year it starts; the regular
# season ends in mid-December)
season <- as.integer(format(Sys.Date(), "%Y")) -
  (format(Sys.Date(), "%m-%d") < "12-15")

pbp <- cfbfastR::load_cfb_pbp(seasons = season)

# Pass plays with an EPA value; clean_team_abbrs() turns the offense's school
# name into sdvplotR's abbreviation
pass_plays <- pbp |>
  filter(pass == 1, !is.na(EPA), !is.na(pos_team)) |>
  mutate(team_abbr = clean_team_abbrs(pos_team, sport = "cfb"))

Conference Standings with Logos

Rank one conference’s passing offenses by EPA per play:

# EPA per pass play for every team, with the conference sdvplotR keeps for it
team_perf <- pass_plays |>
  group_by(team_abbr) |>
  summarise(
    mean_epa = mean(EPA, na.rm = TRUE),
    n_plays = n(),
    .groups = "drop"
  ) |>
  filter(n_plays >= 100) |>
  inner_join(
    select(team_reference("cfb"), team_abbr, team_location, conference, division),
    by = "team_abbr"
  ) |>
  filter(division == "FBS")

sec_teams <- team_perf |>
  filter(conference == "SEC") |>
  arrange(desc(mean_epa))

ggplot(sec_teams, aes(x = reorder(team_abbr, mean_epa), y = mean_epa)) +
  geom_col(aes(fill = team_abbr), width = 0.7) +
  scale_fill_sdv(sport = "cfb", alpha = 0.8) +
  labs(
    title = "SEC Passing Efficiency",
    subtitle = paste("Season", season),
    x = NULL,
    y = "EPA per Pass Play",
    caption = "Data: cfbfastR | Viz: sdvplotR"
  ) +
  theme_minimal() +
  theme(
    axis.text.x = element_text(angle = 45, hjust = 1),
    legend.position = "none"
  )

Playoff Bracket with cfbseedR

cfbseedR applies each conference’s tiebreakers and seeds a 12-team College Football Playoff from a season’s results:

sched <- cfbfastR::load_cfb_schedules(season)

# FBS and FCS teams: games against FCS count toward records, but only FBS
# teams are seeded
teams <- bind_rows(
  sched |> transmute(team = home_team, conference = home_conference, division = toupper(home_division)),
  sched |> transmute(team = away_team, conference = away_conference, division = toupper(away_division))
) |>
  distinct(team, .keep_all = TRUE) |>
  filter(division %in% c("FBS", "FCS"))

games <- cfbseedR::cfb_games_from_schedule(sched) |>
  filter(
    game_type != "POST", !is.na(result),
    home_team %in% teams$team, away_team %in% teams$team
  )

standings <- cfbseedR::cfb_standings(games, teams, verbosity = "NONE")

bracket_data <- standings |>
  filter(team %in% teams$team[teams$division == "FBS"]) |>
  # the auto-bid rule in force that season
  cfbseedR::cfb_playoff_seeds(playoff_seeds = 12, autobid = if (season >= 2026) "2026" else "2025") |>
  filter(!is.na(seed)) |>
  mutate(team_abbr = clean_team_abbrs(team, sport = "cfb"))

ggplot(bracket_data, aes(x = seed, y = 1)) +
  geom_sdv_logos(
    aes(team = team_abbr),
    sport = "cfb",
    width = 0.07
  ) +
  scale_x_continuous(breaks = 1:12) +
  labs(
    title = "College Football Playoff Seeds",
    subtitle = paste("Season", season, "from results alone"),
    x = "Seed",
    y = NULL
  ) +
  theme_minimal() +
  theme(
    axis.text.y = element_blank(),
    panel.grid.major.y = element_blank(),
    panel.grid.minor = element_blank()
  )

autobid picks the automatic-bid rule in force that season. Without committee rankings the field is seeded from results alone; pass the committee’s final rankings as rankings = to cfb_playoff_seeds() to seed the bracket the way the committee did.

CFB Team Tiers

Create a tier plot for top CFB teams:

# The 25 best passing offenses by EPA per play, in tiers
top_25 <- team_perf |>
  slice_max(mean_epa, n = 25, with_ties = FALSE) |>
  mutate(
    tier_no = case_when(
      mean_epa > 0.3 ~ 1,
      mean_epa > 0.2 ~ 2,
      mean_epa > 0.1 ~ 3,
      mean_epa > 0.0 ~ 4,
      TRUE ~ 5
    )
  ) |>
  select(tier_no, team = team_abbr)

sdv_team_tiers(
  top_25,
  sport = "cfb",
  title = "CFB Passing Offense Tiers",
  subtitle = paste("EPA per pass play, season", season),
  tier_desc = c(
    "1" = "Elite",
    "2" = "Playoff Contenders",
    "3" = "Top 25",
    "4" = "Bowl Teams",
    "5" = "Rebuilding"
  ),
  presort = TRUE
)

Conference Map

Visualize all FBS teams by conference:

# FBS conferences, from the conferences sdvplotR keeps for every team
conference_map <- team_reference("cfb") |>
  filter(division == "FBS") |>
  arrange(conference, team_location) |>
  group_by(conference) |>
  mutate(team_rank = row_number()) |>
  ungroup() |>
  mutate(conference_num = as.numeric(factor(conference)))

ggplot(conference_map, aes(x = conference_num, y = team_rank)) +
  geom_sdv_logos(
    aes(team = team_abbr),
    sport = "cfb",
    width = 0.04
  ) +
  scale_x_continuous(
    breaks = seq_along(levels(factor(conference_map$conference))),
    labels = levels(factor(conference_map$conference))
  ) +
  scale_y_reverse() +
  labs(
    title = "FBS Teams by Conference",
    x = NULL,
    y = NULL
  ) +
  theme_minimal() +
  theme(
    axis.text.x = element_text(angle = 45, hjust = 1),
    axis.text.y = element_blank(),
    panel.grid = element_blank()
  )

CFB Standings Table with Logos

Create a gt table with team logos:

team_perf |>
  slice_max(mean_epa, n = 15, with_ties = FALSE) |>
  mutate(rank = row_number(), logo = team_abbr) |>
  select(rank, logo, team_location, conference, mean_epa, n_plays) |>
  gt() |>
  gt_sdv_logos(columns = "logo", sport = "cfb", height = 30) |>
  fmt_number(columns = "mean_epa", decimals = 3) |>
  fmt_number(columns = "n_plays", decimals = 0) |>
  cols_label(
    rank = "#",
    logo = "",
    team_location = "Team",
    conference = "Conf",
    mean_epa = "EPA/Play",
    n_plays = "Plays"
  ) |>
  tab_header(
    title = "Top 15 CFB Passing Offenses",
    subtitle = paste("Season", season)
  )

Rivalry Matchups

Visualize rivalry games with both team logos:

# Define rivalries
rivalries <- data.frame(
  team1 = c("ALA", "AUB", "OSU", "MICH", "UGA", "FLA"),
  team2 = c("AUB", "ALA", "MICH", "OSU", "FLA", "UGA"),
  rivalry = c("Iron Bowl", "Iron Bowl", "The Game", "The Game",
              "World's Largest Outdoor Cocktail Party",
              "World's Largest Outdoor Cocktail Party")
) |>
  distinct(rivalry, .keep_all = TRUE) |>
  mutate(row = row_number())

ggplot(rivalries, aes(x = 1, y = row)) +
  geom_sdv_logos(
    aes(team = team1),
    sport = "cfb",
    width = 0.075,
    hjust = 0
  ) +
  geom_sdv_logos(
    aes(team = team2),
    sport = "cfb",
    width = 0.075,
    hjust = 1
  ) +
  geom_label(
    aes(label = rivalry),
    nudge_y = -0.3,
    alpha = 0.5
  ) +
  scale_x_continuous(limits = c(0.5, 1.5)) +
  labs(
    title = "College Football Rivalries",
    x = NULL,
    y = NULL
  ) +
  theme_void()

Axis Labels with Logos

Replace axis labels with team logos:

top_10 <- team_perf |>
  slice_max(mean_epa, n = 10, with_ties = FALSE) |>
  mutate(team_abbr = factor(team_abbr, levels = team_abbr))

ggplot(top_10, aes(x = team_abbr, y = mean_epa)) +
  geom_col(aes(fill = team_abbr), width = 0.6) +
  scale_fill_sdv(sport = "cfb", alpha = 0.7) +
  scale_x_sdv(sport = "cfb") +
  theme_minimal() +
  theme_x_sdv() +
  labs(
    title = "Top 10 CFB Teams by Pass EPA",
    x = NULL,
    y = "Mean EPA per Play"
  ) +
  theme(legend.position = "none")

Next Steps