College football has changed dramatically over the past decade, especially on the offensive side of the ball. Teams are running more plays, scoring more points, and leaning heavily on explosive, high‑tempo systems. But one question kept coming up as I explored the data:
Was the 2018 season unusually explosive, or has scoring continued to rise in the years since?
Secondary dataset: Multi‑year team performance (yards per game, points per game, win percentage) collected through an API.
Instead of merging the datasets, I treated 2018 as a baseline and compared it to league‑wide trends from 2020 to 2023. I chose 2018 specifically because that is when my favorite team, the Notre Dame Fighting Irish made the playoffs for the first time. I didn’t include 2019 because the 2019 LSU team would have been a huge outlier. This approach lets us see whether 2018 was ahead of its time or simply part of a broader offensive evolution.
The analysis below walks through several visualizations that highlight how scoring and yardage have shifted over time — and what that means for understanding modern college football.
library(tidyverse)
── Attaching core tidyverse packages ──────────────────────── tidyverse 2.0.0 ──
✔ dplyr 1.1.3 ✔ readr 2.1.4
✔ forcats 1.0.0 ✔ stringr 1.5.0
✔ ggplot2 3.4.4 ✔ tibble 3.2.1
✔ lubridate 1.9.3 ✔ tidyr 1.3.0
✔ purrr 1.0.2
── Conflicts ────────────────────────────────────────── tidyverse_conflicts() ──
✖ dplyr::filter() masks stats::filter()
✖ dplyr::lag() masks stats::lag()
ℹ Use the conflicted package (<http://conflicted.r-lib.org/>) to force all conflicts to become errors
Rows: 542 Columns: 10
── Column specification ────────────────────────────────────────────────────────
Delimiter: ","
chr (3): Team, Conference, ConfGroup
dbl (7): Year, Games, Wins, Losses, WinPct, YardsPerGame, PointsPerGame
ℹ Use `spec()` to retrieve the full column specification for this data.
ℹ Specify the column types or set `show_col_types = FALSE` to quiet this message.
data_dictionary <-tibble(variable =c("team_clean", "year", "games", "wins", "losses","off_rank", "off_yards", "off_yards_per_game","def_rank", "yards_per_game_allowed","points_per_game", "avg_points_per_game_allowed","conf", "win_pct", "yards_per_game (API)", "points_per_game (API)" ),description =c("Team name (cleaned)", "Season year", "Games played","Wins", "Losses","Offensive rank", "Total offensive yards", "Offensive yards per game","Defensive rank", "Yards allowed per game","Points scored per game", "Points allowed per game","Conference (API)", "Win percentage (API)","Yards per game from API", "Points per game from API" ))data_dictionary
# A tibble: 16 × 2
variable description
<chr> <chr>
1 team_clean Team name (cleaned)
2 year Season year
3 games Games played
4 wins Wins
5 losses Losses
6 off_rank Offensive rank
7 off_yards Total offensive yards
8 off_yards_per_game Offensive yards per game
9 def_rank Defensive rank
10 yards_per_game_allowed Yards allowed per game
11 points_per_game Points scored per game
12 avg_points_per_game_allowed Points allowed per game
13 conf Conference (API)
14 win_pct Win percentage (API)
15 yards_per_game (API) Yards per game from API
16 points_per_game (API) Points per game from API
This scatterplot shows how 2018 offenses were distributed in terms of yards per game. Most teams clustered between 350–475 yards, with a smaller group pushing above 500 yards. This gives us a baseline for what “normal” offensive production looked like in 2018 before comparing it to later seasons.
cfb18 %>%mutate(win_pct = Win / Games) %>%ggplot(aes(Off.Yards.per.Game, win_pct)) +geom_point(alpha =0.7) +geom_smooth(method ="lm", se =FALSE, color ="red") +labs(title ="Offensive Yards per Game vs Win Percentage (2018)",x ="Offensive Yards per Game",y ="Win %" )
`geom_smooth()` using formula = 'y ~ x'
Defensive Yards Allowed vs Win Percentage (2018)
This scatterplot highlights the relationship between defensive performance and winning in the 2018 season. Teams that allowed fewer yards per game generally achieved higher win percentages, forming a downward‑sloping trend. While there are a few outliers — teams that won despite giving up significant yardage — the overall pattern reinforces a core football principle: limiting opponent yardage is strongly associated with winning games. This visualization helps establish how important defensive efficiency was during the 2018 season.
cfb18 %>%mutate(win_pct = Win / Games) %>%ggplot(aes(Yards.Per.Game.Allowed, win_pct)) +geom_point(alpha =0.7, color ="steelblue") +geom_smooth(method ="lm", se =FALSE) +labs(title ="Defensive Yards Allowed vs Win Percentage (2018)",x ="Yards Allowed per Game",y ="Win %" )
`geom_smooth()` using formula = 'y ~ x'
Points Scored vs Points Allowed (2018)
This plot compares offensive and defensive scoring for each team in 2018. Teams in the top‑left quadrant struggled, while teams in the bottom‑right quadrant represent the strongest programs. The diagonal separation between successful and unsuccessful teams is clear: winning teams tend to both score more and allow fewer points. This graph visually captures the balance required for success and shows how 2018 teams clustered around different performance profiles.
cfb18 %>%ggplot(aes(Points.Per.Game, Avg.Points.per.Game.Allowed)) +geom_point(alpha =0.7) +labs(title ="Points Scored vs Points Allowed (2018)",x ="Points Scored per Game",y ="Points Allowed per Game" )
Distribution of Offensive Yards per Game (2018)
This histogram shows how 2018 offenses were distributed in terms of yards per game. Most teams fell between 350 and 475 yards, with a smaller group pushing above 500 yards per game, indicating elite offensive production. The distribution is moderately right‑skewed, suggesting that while a handful of teams were exceptionally explosive, the majority operated within a more typical performance band. This provides a clear baseline for comparing 2018 offenses to later seasons. There is one outlier with over 550 yards per game. That is crazy to think about.
cfb18 %>%ggplot(aes(Off.Yards.per.Game)) +geom_histogram(bins =20, fill ="darkgreen", alpha =0.7) +labs(title ="Distribution of Offensive Yards per Game (2018)",x ="Offensive Yards per Game" )
Points per Game by Conference (API Data)
This boxplot compares scoring across conferences using the API dataset. Power 5 conferences tend to show higher medians and wider scoring ranges, reflecting deeper talent pools and more explosive offenses. Group of 5 conferences generally show lower medians but also more variability, with some teams outperforming Power 5 averages. This visualization highlights how conference context shapes offensive output, giving important background for interpreting where 2018 teams fit within the broader landscape of college football. It is interesting to see because I have long thought of the Big 12 conference to be full of the highest flying offenses. However, based on the data, the SEC and Pac 12 are right up there with them.
games %>%ggplot(aes(conf, points_per_game)) +geom_boxplot() +coord_flip() +labs(title ="Points per Game by Conference (API Data)",x ="Conference",y ="Points per Game" )
Average Points per Game Over Time (2019–2022)
This line chart illustrates how scoring has changed across the seasons following 2018. The trend shows whether offenses became more productive, stagnated, or regressed. It provides essential context for evaluating whether 2018 was an outlier or part of a larger pattern.
games %>%group_by(year) %>%summarise(avg_points =mean(points_per_game, na.rm =TRUE),avg_yards =mean(yards_per_game, na.rm =TRUE) ) %>%ggplot(aes(year, avg_points)) +geom_line(size =1.2, color ="purple") +geom_point(size =3) +labs(title ="Average Points per Game Over Time (API Data)",x ="Year",y ="Avg Points per Game" )
Warning: Using `size` aesthetic for lines was deprecated in ggplot2 3.4.0.
ℹ Please use `linewidth` instead.
Average Points per Game from 2020–2023 compared to 2018
By adding a horizontal line representing the average points per game in 2018, we can directly compare that season to the multi‑year trend. Although it is only a three point per game difference, we can see that since 2018 offensive points per game are tending to decrease. This is not what the national media seems to think and is definitely something to consider when comparing the different eras of college football.
cfb18_avg <- cfb18 %>%summarise(avg_points_2018 =mean(Points.Per.Game, na.rm =TRUE)) %>%pull(avg_points_2018)games %>%group_by(year) %>%summarise(avg_points =mean(points_per_game, na.rm =TRUE)) %>%ggplot(aes(year, avg_points)) +geom_line(size =1.2, color ="purple") +geom_point(size =3) +geom_hline(yintercept = cfb18_avg, color ="red", linetype ="dashed") +labs(title ="API Scoring Trend vs 2018 Baseline",subtitle ="Red dashed line = 2018 average scoring",x ="Year",y ="Avg Points per Game" )
CONCLUSION
This analysis reveals how the 2018 college football season fits into the broader offensive landscape of recent years. By comparing 2018’s scoring and yardage distributions to multi‑year API trends, we can see that as far as offense is concerned, 2018 represented a peak.
The results show that:
2018 provides a strong baseline, with healthy offensive production across the board.
There is clear movement from season to season, either upward or downward, depending on the metric.
Conference‑level differences remain significant, reinforcing that offensive output is shaped not only by team talent but also by league style and competition level.
Overall, treating 2018 as a reference point allows us to better understand how the sport has evolved. Whether you’re analyzing team performance, offensive schemes, or long‑term trends, this approach highlights the shifting dynamics of modern college football and provides a foundation for deeper exploration.
If you showed someone in the 1980’s these graphics about how powerful offense has become, they would be shocked. The game changes every year, which is why we love to watch. Who knows, maybe another defense defined era is on the way.