For this assignment, the collaborative activity of Module 9. Football Analysis with R from the MsC in Data Analytics in Football, I will comment about a couple of websites that provides free football data, as well as some useful libraries for R that can be used for analysis and presentation of the collected data. In order to so, I’ll use the match that took place on May 2nd 20216, between Chelsea and Tottenham Hotspur, nicknamed the “Battle of the Bridge. I will also analyze individual performance of Eden Hazard during this match as he was chosen as the MVP of the match.

Let’s begin.

Installing Packages

For this assignment, we first need to install some libraries like engsoccerdata, ggshakeR, and statsBombR, as these will be used for:

  • engsoccerdata — public data source: this is a free R package bundling English and European league results back to 1888. No API key, no scraping, no rate limits — just historical data, which I’ll use as my data source for the match analysis.
  • ggshakeR — specialised library:, an analysis-and-visualisation package built specifically for soccer data. I will use it to break down the match end-to-end.
  • StatsBombR — data access layer:, the package this module already uses to pull free match event data (competitions, matches, and full event streams) from StatsBomb’s open data repository into R as clean data frames. It does no analysis or plotting of its own — it’s the plumbing that gets the Chelsea–Spurs match into R in the first place, which ggshakeR then turns into the actual plots below.

All three are R packages/libraries in the programming sense, as we need to install.packages()/remotes::install_github() them first, then library() them to load functions into your session, same as any Python/npm package. We just need to do this once.

knitr::opts_chunk$set(echo = TRUE, message = FALSE, warning = FALSE,
                       fig.align = "center")

options(repos = c(CRAN = "https://cran.r-project.org"))

cran_pkgs <- c("dplyr", "tidyr", "ggplot2", "remotes", "hexbin", "deldir")
missing_cran <- cran_pkgs[!cran_pkgs %in% installed.packages()[, 1]]
if (length(missing_cran) > 0) install.packages(missing_cran, quiet = TRUE)

if (!"engsoccerdata" %in% installed.packages()[, 1]) {
  remotes::install_github("jalapic/engsoccerdata", upgrade = "never", quiet = TRUE)
}
if (!"StatsBombR" %in% installed.packages()[, 1]) {
  remotes::install_github("statsbomb/StatsBombR", upgrade = "never", quiet = TRUE)
}
if (!"ggshakeR" %in% installed.packages()[, 1]) {
  remotes::install_github("abhiamishra/ggshakeR", upgrade = "never", quiet = TRUE)
}

library(dplyr)
library(tidyr)
library(ggplot2)

The Public Data Source: engsoccerdata

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. And because it’s just data and helper functions, there is nothing to authenticate, nothing to break when a website redesigns its front end, and it installs in seconds. This is why I selected it for this assignment: most of other free sites requires you to create an account, get an API Key, and use it as part of your requests, capping you at a certain limit of requests per day; instead, to use engsoccerdata we just need to install the package once and that’s it.

Here’s an example of how to get English Premier League data and which seasons are available:

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

The package also ships small convenience functions on top of the raw results. For example, maketable() reconstructs a league table for any season — here’s the final 2015/216 Premier League table, which lines up exactly with the Leicester City title, title that was secured for Leicester with the late equalizer from Eden Hazard in “The Battle of The Bridge”.

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

Another interesting function is games_between_sum(), which gives an all-time head-to-head record. Chelsea vs. Tottenham Hotspur, for example, has these numbers.

games_between_sum(england, "Chelsea", "Tottenham Hotspur")

Using the Historical Data

As this library has data collected since 1888, we can also do deeper analysis on it. For example, “Home advantage” is one of football’s oldest truisms: we all believe that the Home team has some kind of advantange by simply playing in their own stadium, with their own fans rooting for them. If we analyze this metric using this library, we can aggregate the 136 seasons of top-flight results in one data frame, and we can actually check it:

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 just one such season on record, the 2020/21, were this did not happen. 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 real.

Notes for Colleagues

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.


Specialized R Library: ggshakeR

There are multiple R libraries specialized in fetching, storing, manipulating, transforming, and displaying the data. For example, StatsBombR library, mentioned in the module knowledge material, is purely a data-access layer — it gets StatsBomb’s raw event data into R and does no plotting or modelling of its own. Another example is worldfootballR, which is soccer-focused too but is also just an extraction wrapper; unfortunately, the latter it’s no longer actively maintained.

The library of ggshakeR sits one layer up: it’s an analysis and visualization package/library built specifically for soccer. It takes event data you already have (from StatsBombR, Opta, or Understat for example) and turns it into pitch-relative plots and possession-value models that would otherwise take dozens of lines of custom ggplot2 code each.

library(StatsBombR)
library(ggshakeR)

The following table describes some useful function families in ggshakeR:

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

To showcase the usage of the mentioned functions I’ll analyze now the performance of Eden Hazard, MVP of the match “The Battle of The Bridge”. That data we retrieved from the engsoccerdata we installed before.

Context of The match

The relevance on this match relies on the fact that it 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. With this tie, Tottenham Hotspur lost any chance of becoming champions, and this confirmed Leicester as the title holders.

Working with The Data

We pull the match from archive in engsoccerdata and we complement it with extra data from 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

Analyzing The xG Evolution

For this part, we will use ggshakeR’s built-in xG-trend function (plot_trendline()) which 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

Below we can see every goal in this match, with its underlying goal chance:

  • 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, which was 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")

Hazard’s Stats

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.

And here comes another useful function from this library, the Pass map (plot_pass()). We can also split them 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 = "density") +
  theme(legend.position = "none")

Brighter areas are where Hazard spent the most time on the ball; darker areas, the least. The bright patch in the inside-left channel and half-space matches his known role: a nominal left winger who drifted inside to link play and create shooting angles, rather than staying on the touchline.

More Complex Player-Level Metrics

A really interesting metrics in the world of football data anlytics it’s the On-Ball Value (OBV): it’s a StatsBomb/Hudl proprietary metric and, unfortunately, is not included in their free open data; it ships only through their paid Event Data/IQ products. However, 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.

To obtain this xT for the players involved in this match, what we do is:

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.

So, in order to provide more context, we paired it with the xG timeline to tell the buildup story: the xG chart (displayed earlier in this report) tells the finishing story, and together they cover what a single proprietary OBV number would have summarized in one figure.

Limitations

When working with these kind of libraries, here or in any other coding language, we should evaluate their limitations to foresee any possible scenarios in which our projects can break, or which can make that the data is not being handled properly. To mention some:

  • plot_shot(), the Understat-based shot-map function, currently fails for this analysis of Hazard; 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 data set 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. This is an example of what could waste your time trying to debug why your project is not running well: these dependencies are not properly documented as hard dependencies, so users might ignore they are needed as well. These are worth installing upfront, as we do in our ‘Installing Packages’ section.
  • 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’s matches only, not the whole competition. Always check FreeMatches() actually contains the team and match you want before building around it.

Conclusion

This assignment shows that free football data is genuinely usable for real analysis, but the kind of free source matters. engsoccerdata is a bundled dataset: nearly maintenance-free, and precisely because of that it let us ask a question no live API easily supports — 136 years of results, and the one and only season where away wins outnumbered home wins. StatsBombR is a maintained API wrapper: reliable, but only as rich as the specific competitions it covers, so it’s what we relied on to actually pull the Chelsea–Tottenham Hotspur match. ggshakeR then showed what a proper analysis library adds on top of that data: xG evolution, a pass map, a heatmap, and a more complex metric such as xT, all in a few lines each instead of hand-built ggplot2. Put together, the two halves of this task told a fuller story about Eden Hazard’s “Battle of the Bridge” than either data source could have on its own.