1. Introduction

This report is our contribution to the Module 9 collaborative task. The brief asks for two things: (1) a small study built on data from a free, public source, framed so it adds value for classmates rather than repeating what the module already covered, and (2) an investigation of an R library specialised in sport, as an alternative to StatsBombR, with a worked code example of what it buys you.

We deliberately steered away from the two packages almost everyone reaches for first — StatsBombR (used throughout the module) and worldfootballR (the default “next” recommendation, but effectively unmaintained since mid-2024 — its CRAN listing was pulled and the GitHub README now says so directly). Instead:

  • Part 2 — public data source: engsoccerdata, a free package bundling English and European league results back to 1888. No API key, no scraping, no rate limits — just historical data, which lets us ask a genuinely long-run question.
  • Part 3 — specialised library: ggshakeR, an analysis-and-visualisation package built specifically for soccer data. Rather than a generic demo, we use it to break down one real match end-to-end: Chelsea 2–2 Tottenham Hotspur, 2 May 2016 — the “Battle of the Bridge” — the match in which Eden Hazard came off the bench at half-time and scored the stoppage-time equaliser that handed Leicester City the Premier League title.

2. Part One — A Public Data Source: engsoccerdata

2.1 What it is and why it’s useful

engsoccerdata (James Curley, maintained by Ryan Elmore) is not a scraper or an API wrapper — it is the data, bundled straight into an R package. It ships full match-result histories for England’s top four tiers (1888–2025) plus ten other European leagues, along with small helper functions for league tables and head-to-head records. Because it’s just data + helpers, there is nothing to authenticate, nothing to break when a website redesigns its front end, and it installs in seconds. That makes it an excellent teaching example of a public data source: completely free, and precisely because nobody has to scrape it, it’s more reliable than most of the live scraping routes explored elsewhere in the module.

library(engsoccerdata)

top_flight <- england %>% filter(division == "1")

cat("Rows:", nrow(top_flight), "\n")
## Rows: 50570
cat("Seasons covered:", min(top_flight$Season), "-", max(top_flight$Season), "\n")
## Seasons covered: 1888 - 2024
head(top_flight)

The package also ships small convenience functions on top of the raw results. maketable() reconstructs a league table for any season — here’s the final 2023/24 Premier League table, which lines up exactly with the real one (Manchester City champions on 91 points):

maketable(df = england, Season = 2023, tier = 1) %>% head(6)

games_between_sum() gives an all-time head-to-head record. Liverpool vs. Everton is the longest-running top-flight derby in English football — the numbers below cover all 210 meetings recorded in the dataset:

games_between_sum(england, "Liverpool", "Everton")

2.2 A question worth asking: has home advantage actually declined?

“Home advantage” is one of football’s oldest truisms. With 136 seasons of top-flight results in one data frame, we can actually check it — and see what happened when the assumption behind it (fans in the stadium) was removed entirely during COVID-19.

by_season <- top_flight %>%
  group_by(Season) %>%
  summarise(
    home_win_pct = mean(result == "H") * 100,
    draw_pct     = mean(result == "D") * 100,
    away_win_pct = mean(result == "A") * 100,
    .groups = "drop"
  )

# Every season, in 136 years, where away wins outnumbered home wins:
by_season %>% filter(away_win_pct > home_win_pct)

There is exactly one such season on record: 2020/21 — played almost entirely behind closed doors. In every other season since 1888, home teams won more often than away teams. That’s a strong, data-backed way to demonstrate that home advantage is largely a crowd effect, not a fixture-scheduling or travel effect.

by_season_long <- by_season %>%
  pivot_longer(cols = c(home_win_pct, draw_pct, away_win_pct),
               names_to = "outcome", values_to = "pct") %>%
  mutate(outcome = recode(outcome,
                           home_win_pct = "Home win",
                           draw_pct     = "Draw",
                           away_win_pct = "Away win"))

ggplot(by_season_long, aes(x = Season, y = pct, color = outcome)) +
  geom_line(linewidth = 0.8) +
  annotate("rect", xmin = 2019.5, xmax = 2020.5, ymin = -Inf, ymax = Inf,
           alpha = 0.15, fill = "red") +
  annotate("text", x = 2020, y = 5, label = "COVID\n(no crowds)",
           size = 3, color = "gray30") +
  scale_color_manual(values = c("Home win" = "#1b6ca8",
                                 "Draw" = "#999999",
                                 "Away win" = "#c0392b")) +
  labs(title = "English Top-Flight Match Outcomes, 1888-2024",
       subtitle = "Home advantage has eroded over 13+ decades - and briefly vanished when stadiums emptied",
       x = "Season", y = "% of matches", color = NULL,
       caption = "Source: engsoccerdata (jalapic/engsoccerdata)") +
  theme_minimal(base_size = 12) +
  theme(legend.position = "top")

Two trends sit on top of each other here: a slow, century-long decline in home-win rate (from around 60% in the 1890s to around 45% today — plausibly linked to better travel, TV scouting, and pitch standardisation), and a sharp one-season dip exactly where you’d expect it if crowds are doing a lot of that work.

2.3 Takeaway for classmates

If your project needs long historical time series — trend analysis, home-advantage studies, promotion/relegation patterns, “how has scoring changed” questions — engsoccerdata gets you a clean data frame in one line, with zero scraping fragility. It’s a good complement to event-level packages like StatsBombR, which only cover a handful of individual competitions in depth but nothing close to this time span.


3. Part Two — A Specialized Library: ggshakeR

3.1 Why ggshakeR

StatsBombR (used in the module) is purely a data-access layer — it gets StatsBomb’s raw event data into R and does no plotting or modelling of its own. worldfootballR, the usual next suggestion, is soccer-focused too but is also just an extraction wrapper — and as of this writing its own repository says it’s no longer actively maintained.

ggshakeR sits one layer up: it’s an analysis and visualisation package built specifically for soccer. It takes event data you already have (from StatsBombR, Opta, or Understat) and turns it into pitch-relative plots and possession-value models that would otherwise take dozens of lines of custom ggplot2 code each. That’s a more literal answer to the assignment’s framing — “a library aimed at helping developers in their analysis” — than a second data wrapper would be.

library(StatsBombR)
library(ggshakeR)

Function families in ggshakeR relevant to this demo:

Function What it does
plot_pass() Pass map for a player/team, split by outcome, with progressive/cross/switch filters
plot_heatmap() Touch/event density map (bin, hex, density, or joint-density styles)
plot_passnet() Team pass network — average position + connection strength between players
plot_voronoi() Voronoi tessellation of a team’s average shape
plot_shot() Shot map from Understat data (xG, result, location)
calculate_threat() Karun Singh’s published Expected Threat (xT) model — values every pass/carry by how much it raises the probability of scoring soon after
calculate_epv() Expected Possession Value, an alternative possession-value model

3.2 The match: Chelsea 2–2 Tottenham Hotspur, 2 May 2016

Known as the “Battle of the Bridge,” this match decided the 2015/16 Premier League title — not for either team on the pitch, but for Leicester City. Tottenham led 2–0 at half-time (goals from Harry Kane and Son Heung-min) and just needed a draw to keep the title race alive. Chelsea manager Guus Hiddink brought Eden Hazard on for Pedro at the break. Gary Cahill pulled one back on 57 minutes, and Hazard curled in a stoppage-time equaliser — his first goal in a year — to make it 2–2, confirming Leicester as champions.

We pull the match with StatsBombR (competition 2 = Premier League, season 27 = 2015/16, from StatsBomb’s free open data) and cache the result locally so re-knitting the report doesn’t re-download it every time:

cache_file <- "data/hazard_match_clean.rds"

if (file.exists(cache_file)) {
  plotting_data <- readRDS(cache_file)
} else {
  comp     <- FreeCompetitions() %>% filter(competition_id == 2, season_id == 27)
  matches  <- FreeMatches(comp)
  match    <- matches %>% filter(match_id == 3754092)   # Chelsea vs Tottenham, 2016-05-02
  raw      <- free_allevents(MatchesDF = match, Parallel = TRUE)
  plotting_data <- allclean(raw) %>%
    rename(x = location.x, y = location.y,
           finalX = pass.end_location.x, finalY = pass.end_location.y)
  dir.create("data", showWarnings = FALSE)
  saveRDS(plotting_data, cache_file)
}

nrow(plotting_data)
## [1] 3299

3.3 Match-level: the xG evolution

ggshakeR’s built-in xG-trend function (plot_trendline()) is designed for season-long rolling averages across many matches, not a single match’s shot-by-shot story — so for a single-match cumulative xG timeline (“worm chart”) we combine ggshakeR’s data (StatsBomb’s own xG model, shot.statsbomb_xg) with plain dplyr/ggplot2. This is a natural way to work with the package: it doesn’t try to cover every possible chart, and plugs cleanly into the regular tidyverse for the rest.

shots <- plotting_data %>%
  filter(type.name == "Shot") %>%
  transmute(team.name, minute, xg = shot.statsbomb_xg,
            outcome = shot.outcome.name, player.name,
            is_goal = shot.outcome.name == "Goal") %>%
  arrange(minute)

goals <- shots %>% filter(is_goal) %>% select(minute, team.name, player.name, xg)
goals

Every goal in this match, with its underlying shot quality:

  • Kane (34’) — xG 0.91 (a very high-probability chance)
  • Son (43’) — xG 0.47 (a good, but not certain, chance)
  • Cahill (57’) — xG 0.09 (a scrappy, low-probability poke-in)
  • Hazard (82’) — xG 0.12 (also low-probability — a first-time curled strike from range)

Two of the four goals in this match were low-probability events, and Hazard’s title-deciding strike was one of them — exactly the “moment of individual quality” the eye-test remembers, now backed by the shot model rather than just the highlight reel.

cum <- shots %>%
  arrange(minute) %>%
  group_by(team.name) %>%
  mutate(cum_xg = cumsum(xg)) %>%
  ungroup()

teams  <- unique(shots$team.name)
starts <- tibble(team.name = teams, minute = 0, cum_xg = 0)
ends   <- cum %>% group_by(team.name) %>% slice_max(minute, n = 1) %>%
  transmute(team.name, minute = 96, cum_xg)

step_data <- bind_rows(starts, cum %>% select(team.name, minute, cum_xg), ends) %>%
  arrange(team.name, minute)

goal_points <- goals %>%
  left_join(cum %>% select(team.name, minute, cum_xg), by = c("team.name", "minute"))

ggplot(step_data, aes(x = minute, y = cum_xg, color = team.name)) +
  geom_step(linewidth = 1) +
  geom_point(data = goal_points, aes(x = minute, y = cum_xg), size = 3) +
  scale_color_manual(values = c("Chelsea" = "#034694", "Tottenham Hotspur" = "#132257")) +
  labs(title = "Cumulative xG - Chelsea 2-2 Tottenham Hotspur (2 May 2016)",
       subtitle = "Hazard's 82nd-minute strike (marked) completed Chelsea's comeback",
       x = "Minute", y = "Cumulative xG", color = NULL,
       caption = "Data: StatsBomb Open Data via StatsBombR + ggshakeR") +
  theme_minimal(base_size = 12) +
  theme(legend.position = "top")

3.4 Player-level: Eden Hazard

Hazard played the entire second half (on at 45’, through to full time), recording 140 touches — a genuinely large involvement for a player introduced at the break.

Pass map (plot_pass()), split by outcome:

hazard_passes <- plotting_data %>%
  filter(player.name == "Eden Hazard", type.name == "Pass")

plot_pass(data = hazard_passes, data_type = "statsbomb", type = "sep")

Heat map (plot_heatmap()) of every touch:

hazard_touches <- plotting_data %>%
  filter(player.name == "Eden Hazard") %>%
  select(x, y)

plot_heatmap(data = hazard_touches, type = "hex")

The concentration in the inside-left channel and half-space matches Hazard’s known role: a nominal left winger who drifted inside to link play and create shooting angles, rather than staying on the touchline.

3.5 A note on OBV — and what we use instead

The brief for this section asked for On-Ball Value (OBV) if possible. OBV is a StatsBomb / Hudl proprietary metric and is not included in their free open data — it ships only through their paid Event Data / IQ products, so it genuinely isn’t reachable from a free public source, which is the whole premise of this task.

ggshakeR gives us the closest open alternative: calculate_threat(), an implementation of Karun Singh’s published Expected Threat (xT) model. Instead of StatsBomb’s proprietary scoring/conceding-probability model, xT values every pass and carry by how much it moves the ball into pitch zones associated with a higher chance of scoring soon after — conceptually the same idea as OBV (value every on-ball action), built entirely on open research.

xt_data <- calculate_threat(data = plotting_data, type = "statsbomb") %>%
  mutate(xT_added = xTEnd - xTStart)

# Match-wide leaderboard, passes and carries only
xt_leaderboard <- xt_data %>%
  filter(type.name %in% c("Pass", "Carry"), !is.na(xT_added)) %>%
  group_by(player.name, team.name) %>%
  summarise(total_xT = sum(xT_added), actions = n(), .groups = "drop") %>%
  arrange(desc(total_xT))

head(xt_leaderboard, 8)

Hazard ranks 4th on the pitch for total xT added (0.51, from 28 valid actions) despite playing only 51 minutes — a high rate of threat creation per action, driven by his passing while Chelsea chased the game. Note what this metric doesn’t capture, though: xT (like OBV) values passes and carries, not shots — so it says nothing about the decisive goal itself. That’s exactly why we paired it with the xG timeline in §3.3: xT tells the buildup story, the xG chart tells the finishing story, and together they cover what a single proprietary OBV number would have summarised in one figure.

3.6 Other things worth knowing before you build on this

  • plot_shot(), the Understat-based shot-map function, currently fails for us — Understat.com changed the internal JSON structure its page embeds, which broke understatr (the scraper ggshakeR relies on for that path). It’s a good reminder that scraped sources, however free, carry real maintenance risk, unlike a bundled dataset like engsoccerdata or a maintained API wrapper like StatsBombR.
  • plot_heatmap(type = "hex") and plot_voronoi() need the hexbin and deldir packages installed as well — not documented as hard dependencies, so worth installing upfront (as our setup chunk does).
  • StatsBomb’s free/open data only covers specific competitions and seasons in full — e.g. their Champions League free data for several seasons is scoped to Barcelona/Messi matches only, not the whole competition. Always check FreeMatches() actually contains the team and match you want before building around it.

3.7 Takeaway for classmates

If your project already has StatsBomb (or Opta/Understat) event data and you want pitch plots, pass networks, or possession-value models without writing the pitch-drawing and geometry code yourself, ggshakeR is a strong, actively maintained choice — and a more literal fit for “a library aimed at helping developers in their analysis” than a pure data-extraction package.


4. Conclusion

Both halves of this task point at the same broader lesson: free public data is genuinely usable for real analysis, but the kind of free source matters. A bundled dataset (engsoccerdata) is nearly maintenance-free and lets you ask questions no live API easily supports (136 years of results). A maintained API wrapper (StatsBombR) is reliable but only as good as its data coverage. A scraper (understatr, and until recently worldfootballR) is the most fragile of the three — genuinely free, but liable to break without warning when the source site changes. ggshakeR shows what’s possible once you’re past the data-access problem: turning event data into pitch-relative visual and possession-value analysis with a few lines of code, on top of whichever of the three sources above you chose.

5. Reproducibility

sessionInfo()
## R version 4.5.3 (2026-03-11)
## Platform: aarch64-apple-darwin20
## Running under: macOS Sequoia 15.7.4
## 
## Matrix products: default
## BLAS:   /Library/Frameworks/R.framework/Versions/4.5-arm64/Resources/lib/libRblas.0.dylib 
## LAPACK: /Library/Frameworks/R.framework/Versions/4.5-arm64/Resources/lib/libRlapack.dylib;  LAPACK version 3.12.1
## 
## locale:
## [1] en_US/en_US/en_US/C/en_US/en_US
## 
## time zone: America/New_York
## tzcode source: internal
## 
## attached base packages:
## [1] parallel  stats     graphics  grDevices utils     datasets  methods  
## [8] base     
## 
## other attached packages:
##  [1] ggshakeR_0.2.0.9002 StatsBombR_0.1.0    sp_2.2-3           
##  [4] purrr_1.2.2         jsonlite_2.0.0      httr_1.4.8         
##  [7] doParallel_1.0.17   iterators_1.0.14    foreach_1.5.2      
## [10] RCurl_1.98-1.20     rvest_1.0.5         tibble_3.3.1       
## [13] stringr_1.6.0       stringi_1.8.9       engsoccerdata_0.1.8
## [16] ggplot2_4.0.3       tidyr_1.3.2         dplyr_1.2.1        
## 
## loaded via a namespace (and not attached):
##  [1] sass_0.4.10        generics_0.1.4     bitops_1.1-0       xml2_1.6.0        
##  [5] lattice_0.22-9     digest_0.6.39      magrittr_2.0.5     evaluate_1.0.5    
##  [9] grid_4.5.3         RColorBrewer_1.1-3 fastmap_1.2.0      ggrepel_0.9.8     
## [13] gridExtra_2.3.1    ggtext_0.1.2       viridisLite_0.4.3  scales_1.4.0      
## [17] tweenr_2.0.3       codetools_0.2-20   jquerylib_0.1.4    cli_3.6.6         
## [21] rlang_1.3.0        polyclip_1.10-7    withr_3.0.3        cachem_1.1.0      
## [25] yaml_2.3.12        otel_0.2.0         ggsoccer_0.2.0     tools_4.5.3       
## [29] curl_8.0.0         vctrs_0.7.3        R6_2.6.1           zoo_1.9-0         
## [33] lifecycle_1.0.5    MASS_7.3-65        pkgconfig_2.0.3    hexbin_1.28.6     
## [37] pillar_1.11.1      bslib_0.12.0       gtable_0.3.6       Rcpp_1.1.2        
## [41] glue_1.8.1         ggforce_0.5.0      xfun_0.60          tidyselect_1.2.1  
## [45] knitr_1.51         farver_2.1.2       htmltools_0.5.9    xts_0.14.2        
## [49] rmarkdown_2.31     labeling_0.4.3     compiler_4.5.3     S7_0.2.2          
## [53] TTR_0.24.4         gridtext_0.1.6