Insights · R tutorial
From a football results API to a half-time/full-time heatmap in R
What happens after half-time? In Germany's 3. Liga last season, a team leading at the break at home went on to win 78% of the time; an away team leading at the break won only 67%. And a game level at half-time still tipped towards the home side, 39% to 26%. This tutorial builds that table from scratch in R: load match results from a JSON API, parse the scores, check the data, count, and draw the heatmap.
Everything runs on free data: the Football Charts API serves the current and previous season of 93 leagues without a key. The complete script is on GitHub.
The result
| At half-time ↓ / Full-time → | Home win | Draw | Away win | Matches |
|---|---|---|---|---|
| Home leads | 78% (107) | 14% (20) | 8% (11) | 138 |
| Level | 39% (53) | 36% (49) | 26% (35) | 137 |
| Away leads | 14% (15) | 19% (20) | 67% (70) | 105 |
3. Liga 2025-26, all 380 matches. Row % = share of full-time results for each half-time state. Data by football-charts.com.
1. Load the matches
One request returns every finished match of a league-season as JSON. jsonlite turns it straight into a data frame. 3. Liga's code is germany3; the /leagues/ endpoint lists all 93.
library(jsonlite) library(dplyr) library(tidyr) library(ggplot2) url <- "https://footballcharts-backend.onrender.com/api/v1/leagues/germany3/results/?season=2025-2026" resp <- fromJSON(url) matches <- as_tibble(resp$matches) nrow(matches) # 380
Each row has the date, the teams, the full-time score and the half-time ht_result, both as "home:away" strings, e.g. "1:1".
2. Parse the scores
split_score <- function(x) {
parts <- strsplit(x, ":", fixed = TRUE)
list(home = as.integer(vapply(parts, function(p) p[1], "")),
away = as.integer(vapply(parts, function(p) p[2], "")))
}
ft <- split_score(matches$score)
ht <- split_score(matches$ht_result)
games <- matches |>
transmute(date = as.Date(date),
home = homeTeam, away = awayTeam,
ft_home = ft$home, ft_away = ft$away,
ht_home = ht$home, ht_away = ht$away)3. Check before you count
Four cheap checks catch most data problems: the right number of matches, no missing scores, no duplicated fixtures, and no half-time score larger than the full-time one. A 20-team league should also have exactly 38 matches per team. stopifnot makes the script stop loudly if any of them fails.
stopifnot(
nrow(games) == 380,
!anyNA(games[, c("ft_home", "ft_away", "ht_home", "ht_away")]),
!any(duplicated(games[, c("date", "home", "away")])),
all(games$ht_home <= games$ft_home), all(games$ht_away <= games$ft_away)
)
per_team <- table(c(games$home, games$away))
stopifnot(length(per_team) == 20, all(per_team == 38))4. Half-time state → full-time result
sign() of the goal difference gives home lead / level / away lead. The key step is the percentage: divide by the row total (all matches with the same half-time state), not by 380. Otherwise the table answers a different question: how common each combination is, rather than what tends to happen next.
state <- function(h, a) factor(sign(h - a), levels = c(1, 0, -1),
labels = c("Home leads", "Level", "Away leads"))
result <- function(h, a) factor(sign(h - a), levels = c(1, 0, -1),
labels = c("Home win", "Draw", "Away win"))
htft <- games |>
mutate(ht = state(ht_home, ht_away), ft = result(ft_home, ft_away)) |>
count(ht, ft, .drop = FALSE) |>
group_by(ht) |>
mutate(pct = 100 * n / sum(n)) |>
ungroup()5. Draw the heatmap
ggplot(htft, aes(ft, ht, fill = pct)) +
geom_tile(colour = "white", linewidth = 1) +
geom_text(aes(label = sprintf("%.0f%%\n(%d)", pct, n))) +
scale_fill_gradient(low = "#eef2ff", high = "#1e40af", limits = c(0, 100), guide = "none") +
scale_y_discrete(limits = rev) +
labs(x = "Full-time result", y = "At half-time",
caption = "Data by football-charts.com") +
theme_minimal() +
theme(panel.grid = element_blank())What the table says
- Home leads are safer than away leads: 78% of half-time home leads became wins, against 67% of away leads. Home advantage keeps working in the second half.
- Level at the break still leans home: 39% home wins, 36% draws, 26% away wins.
- Comebacks are rare but real: 11 of 138 half-time home leads (8%) ended as away wins; 15 of 105 away leads (14%) ended as home wins.
- One league, one season. 105 to 138 matches per row is enough for a clear pattern, not for decimal-point precision. Run the same code on another league or season to see whether it holds.
Try it on another league
Change germany3 in the URL to any league code from /api/v1/leagues/: e.g. national (English National League), sweden2 (Superettan) or wgermany1 (Frauen-Bundesliga). No key is needed for 300 requests a day; a free key raises that to 5,000. Seasons before the previous one need a paid key. For a Python version with a Dixon-Coles model, see the penaltyblog example.
Written by the maintainer of Football Charts. Data by football-charts.com, free to use with attribution. Code: MIT licence. Replication package (frozen data, script and reference counts): doi:10.7910/DVN/W8HN9G (Harvard Dataverse).
