How to Predict Sports in R: Elo, Monte Carlo, and Real Simulations

[This article was first published on Blog - R Programming Books, and kindly contributed to R-bloggers]. (You can report issue about the content on this page here)
Want to share your content on R-bloggers? click here if you have a blog, or here if you don't.


R • Sports Analytics • Ratings • Monte Carlo • Forecasting

Sports are noisy. Teams change. Injuries happen. Upsets happen. But uncertainty is not the enemy—it’s the input. In this hands-on guide you’ll build a practical sports prediction workflow in R using tidyverse, PlayerRatings, and NFLSimulatoR, then connect ratings to Monte Carlo simulations and forecasting models (logistic regression & Poisson).

Goal

Probabilities, not “picks”

Core tools

Ratings + Models + Simulation

Output

Reproducible code & scenarios

Table of Contents

Everything below is meant to be copied into an R script and run. Swap the toy data for your league’s data (football/soccer, basketball, American football, tennis, baseball, esports—you name it).

1) Setup: a reproducible R project

Prediction work gets messy fast if you can’t reproduce it. Start every project with: (1) a clean package setup, (2) a fixed random seed, (3) tidy data conventions.

# Install (run once)
install.packages(c("tidyverse", "lubridate", "PlayerRatings"))

# If you plan to use NFLSimulatoR (CRAN/GitHub availability varies):
# install.packages("NFLSimulatoR") 
# or, if needed:
# install.packages("remotes"); remotes::install_github(".../NFLSimulatoR")

library(tidyverse)
library(lubridate)
library(PlayerRatings)

set.seed(2026)  # reproducibility: simulation + model splits will match

Rule of thumb: if your analysis can’t be rerun end-to-end from a fresh session, it’s not ready for decisions (betting, scouting, or strategy).

2) A tiny dataset (so we can focus on methods)

We’ll build a compact match table with team names, dates, and outcomes. You can replace this with real data later (e.g., football leagues, NBA games, tennis matches).

# Create toy match results
teams <- c("Lions", "Tigers", "Bears", "Wolves", "Hawks", "Sharks")

n_games <- 120

matches <- tibble(
  date  = sample(seq.Date(as.Date("2025-08-01"), as.Date("2026-01-31"), by = "day"), n_games, replace = TRUE),
  home  = sample(teams, n_games, replace = TRUE),
  away  = sample(teams, n_games, replace = TRUE)
) %>%
  filter(home != away) %>%
  arrange(date) %>%
  mutate(
    # "true" (hidden) team strengths to generate outcomes (toy world)
    home_strength = recode(home,
      "Lions"=0.6,"Tigers"=0.5,"Bears"=0.4,"Wolves"=0.2,"Hawks"=0.1,"Sharks"=0.0),
    away_strength = recode(away,
      "Lions"=0.6,"Tigers"=0.5,"Bears"=0.4,"Wolves"=0.2,"Hawks"=0.1,"Sharks"=0.0),

    # Convert strength difference into a win probability
    p_home = plogis(0.4 + 2.0*(home_strength - away_strength)),  # home advantage + skill gap
    home_win = rbinom(n(), size = 1, prob = p_home)
  ) %>%
  select(date, home, away, home_win, p_home)

matches %>% slice(1:8)

This is intentionally minimal: just enough to demonstrate Elo/Glicko, win probabilities, simulation, and modeling.

3) Elo ratings with PlayerRatings

Elo is a rating system that updates after each game. It’s not “magic”—it’s a controlled way to: (1) estimate team strength from history, and (2) turn rating gaps into expected win probabilities.

3.1 Prepare the input format

# PlayerRatings expects a "competitionResult" object.
# We'll represent teams as "players" and each game as a head-to-head match.

elo_input <- matches %>%
  transmute(
    Date = date,
    Player1 = home,
    Player2 = away,
    # For PlayerRatings: 1 = Player1 win, 0 = Player2 win (or use Score columns)
    Result = if_else(home_win == 1, 1, 0)
  )

head(elo_input)

3.2 Fit Elo

# Run Elo with a K-factor (update speed). Tune K by sport and data volume.
elo_fit <- elo(elo_input, k = 20)

# Ratings after processing all games
elo_ratings <- as.data.frame(elo_fit$ratings) %>%
  rownames_to_column("team") %>%
  as_tibble() %>%
  rename(elo = Rating) %>%
  arrange(desc(elo))

elo_ratings

Why Elo is useful: it’s lightweight, interpretable, and often surprisingly competitive as a baseline. Use it to sanity-check more complex models.

4) Glicko ratings (when uncertainty matters)

Elo tells you “strength.” Glicko tells you “strength + uncertainty.” Early season? New team? Few games played? Glicko’s rating deviation helps you avoid false confidence.

# Glicko uses a similar input, but the function differs (depends on PlayerRatings version).
# Many workflows look like this:

glicko_input <- elo_input  # same structure works for many head-to-head methods

# Some installs provide a glicko() function:
# glicko_fit <- glicko(glicko_input)

# If your PlayerRatings build doesn't expose glicko() directly,
# you can still rely on Elo for the main pipeline and add uncertainty via modeling/simulation.

# Pseudo-example structure:
# glicko_ratings <- glicko_fit$ratings

Heads-up: package APIs can change. If your local PlayerRatings version doesn’t expose a direct glicko() helper, you can still do uncertainty-aware prediction by combining ratings with a probabilistic model (logistic regression/Bayesian) and then simulating forward.

5) Turning ratings into win probabilities

Ratings become actionable once you map them to probabilities. The classic Elo expectation uses a logistic curve. You can also learn this mapping empirically (recommended) by fitting a model on past games.

5.1 Join Elo back into match rows

matches_w_elo <- matches %>%
  left_join(elo_ratings %>% rename(home_elo = elo), by = c("home" = "team")) %>%
  left_join(elo_ratings %>% rename(away_elo = elo), by = c("away" = "team")) %>%
  mutate(elo_diff = home_elo - away_elo)

matches_w_elo %>% select(date, home, away, home_win, home_elo, away_elo, elo_diff) %>% slice(1:10)

5.2 A simple Elo-to-probability mapping

# Classic Elo expected score mapping (base-10 logistic).
# "scale" controls steepness. For chess Elo it's 400 by convention.
elo_prob <- function(diff, scale = 400) {
  1 / (1 + 10^(-diff / scale))
}

matches_w_elo <- matches_w_elo %>%
  mutate(p_home_elo = elo_prob(elo_diff, scale = 250))  # scale tuned for our toy world

matches_w_elo %>%
  summarize(
    avg_p = mean(p_home_elo),
    brier = mean((home_win - p_home_elo)^2)
  )

Elo gives a baseline probability. Next we’ll simulate seasons, then we’ll improve the mapping using regression models.

6) Monte Carlo: simulate a season & playoff odds

Monte Carlo simulation is how you turn game probabilities into season-level questions: “What are the chances we finish top-2?” “How often do we make playoffs?” “What is the distribution of wins?”

6.1 Build a future schedule

# Create a "future" schedule (e.g., the next 60 games)
n_future <- 60
future <- tibble(
  date = seq.Date(as.Date("2026-02-01"), by = "day", length.out = n_future),
  home = sample(teams, n_future, replace = TRUE),
  away = sample(teams, n_future, replace = TRUE)
) %>%
  filter(home != away) %>%
  left_join(elo_ratings %>% rename(home_elo = elo), by = c("home" = "team")) %>%
  left_join(elo_ratings %>% rename(away_elo = elo), by = c("away" = "team")) %>%
  mutate(
    elo_diff = home_elo - away_elo,
    p_home = elo_prob(elo_diff, scale = 250)
  )

future %>% slice(1:8)

6.2 Simulate the remaining season many times

simulate_season <- function(future_games, n_sims = 5000) {

  # We'll simulate all games n_sims times, then compute win totals.
  sims <- replicate(n_sims, {
    future_games %>%
      mutate(home_win = rbinom(n(), 1, p_home)) %>%
      transmute(
        home, away,
        home_w = home_win,
        away_w = 1 - home_win
      ) %>%
      pivot_longer(cols = c(home, away), names_to = "side", values_to = "team") %>%
      mutate(win = if_else(side == "home", home_w, away_w)) %>%
      group_by(team) %>%
      summarize(wins = sum(win), .groups = "drop")
  }, simplify = FALSE)

  bind_rows(sims, .id = "sim") %>%
    mutate(sim = as.integer(sim))
}

season_sims <- simulate_season(future, n_sims = 3000)

# Win distribution by team
win_dist <- season_sims %>%
  group_by(team) %>%
  summarize(
    avg_wins = mean(wins),
    p_top2 = mean(rank(-wins, ties.method = "random") <= 2),
    p_top3 = mean(rank(-wins, ties.method = "random") <= 3),
    .groups = "drop"
  ) %>%
  arrange(desc(avg_wins))

win_dist

Interpretation tip: simulation answers “distribution” questions (ranges, tails, chances). That’s exactly what decision-makers care about.

7) “What-if” analysis: injuries, transfers, roster changes

Counterfactuals are where modeling becomes practical: you modify team strength assumptions and see how outcomes shift. For Elo-based pipelines, this is as easy as adding/subtracting rating points.

# Example: star injury reduces Lions by 60 Elo points for the rest of the season
adjusted_ratings <- elo_ratings %>%
  mutate(elo_adj = if_else(team == "Lions", elo - 60, elo))

future_counterfactual <- future %>%
  select(date, home, away) %>%
  left_join(adjusted_ratings %>% select(team, home_elo = elo_adj), by = c("home" = "team")) %>%
  left_join(adjusted_ratings %>% select(team, away_elo = elo_adj), by = c("away" = "team")) %>%
  mutate(
    elo_diff = home_elo - away_elo,
    p_home = elo_prob(elo_diff, scale = 250)
  )

baseline <- simulate_season(future, n_sims = 2000) %>% mutate(scenario = "baseline")
injury   <- simulate_season(future_counterfactual, n_sims = 2000) %>% mutate(scenario = "lions_injury")

compare <- bind_rows(baseline, injury) %>%
  group_by(scenario, team) %>%
  summarize(avg_wins = mean(wins), .groups = "drop") %>%
  pivot_wider(names_from = scenario, values_from = avg_wins) %>%
  mutate(delta = lions_injury - baseline) %>%
  arrange(delta)

compare

The same idea works for transfers, coaching changes, or “rest days”—anything you can reasonably encode as a strength shift.

8) Poisson models for scorelines

When you predict scores (not just win/loss), Poisson models are a strong baseline—especially in low-scoring sports. The classic setup models goals scored as Poisson with attack/defense effects and home advantage.

8.1 Generate toy score data

# Create toy goals (home_goals, away_goals) driven by hidden strengths.
# This mimics soccer/hockey-like scoring.

scores <- matches %>%
  mutate(
    lambda_home = exp(-0.2 + 1.2*home_strength - 0.9*away_strength + 0.25),
    lambda_away = exp(-0.2 + 1.2*away_strength - 0.9*home_strength),
    home_goals = rpois(n(), lambda_home),
    away_goals = rpois(n(), lambda_away),
    home_win = as.integer(home_goals > away_goals),
    draw = as.integer(home_goals == away_goals)
  ) %>%
  select(date, home, away, home_goals, away_goals, home_win, draw)

scores %>% slice(1:8)

8.2 Fit Poisson GLMs

# Two-model approach: predict home_goals and away_goals separately.
# Features: team identifiers (as factors) can capture attack/defense proxies in simple form.

po_home <- glm(home_goals ~ home + away, data = scores, family = poisson())
po_away <- glm(away_goals ~ home + away, data = scores, family = poisson())

# Predict expected goals for upcoming games
future_xg <- future %>%
  select(date, home, away) %>%
  mutate(
    xg_home = predict(po_home, newdata = ., type = "response"),
    xg_away = predict(po_away, newdata = ., type = "response")
  )

future_xg %>% slice(1:8)

8.3 Convert expected goals into win probabilities

# Approximate match outcome probabilities by simulating scorelines from the Poisson rates.
scoreline_probs <- function(lambda_home, lambda_away, n = 20000) {
  hg <- rpois(n, lambda_home)
  ag <- rpois(n, lambda_away)
  tibble(
    p_home_win = mean(hg > ag),
    p_draw     = mean(hg == ag),
    p_away_win = mean(hg < ag)
  )
}

example_probs <- scoreline_probs(future_xg$xg_home[1], future_xg$xg_away[1], n = 30000)
example_probs

Why this works: if you can forecast scoring rates, you can forecast any downstream event: win, draw, total goals, both-teams-to-score, etc.

9) Logistic regression for match outcomes

A clean upgrade from “raw Elo mapping” is to fit a logistic regression that learns how rating differences translate to win probability—optionally adding context features (rest, travel, injuries, lineup quality).

# Train on historical matches: outcome ~ elo_diff
train <- matches_w_elo %>% drop_na(home_elo, away_elo)

logit <- glm(home_win ~ elo_diff, data = train, family = binomial())
summary(logit)

# Predict probabilities for future games using the fitted mapping
future_logit <- future %>%
  left_join(elo_ratings %>% rename(home_elo = elo), by = c("home" = "team")) %>%
  left_join(elo_ratings %>% rename(away_elo = elo), by = c("away" = "team")) %>%
  mutate(
    elo_diff = home_elo - away_elo,
    p_home = predict(logit, newdata = ., type = "response")
  )

future_logit %>% slice(1:8)

Logistic regression also makes it easy to incorporate additional predictors beyond ratings.

10) Using NFLSimulatoR as a simulator backbone

If you’re working on American football specifically, a purpose-built simulator can accelerate your pipeline: schedule generation, game simulation, season simulation, and standings logic.

Note: The exact function names can differ depending on the NFLSimulatoR version you have. The pattern below shows a practical structure you can adapt: prepare team strengths → simulate games → aggregate standings → repeat.

# library(NFLSimulatoR)

# 1) Define (or estimate) team strength inputs
team_strengths <- elo_ratings %>% select(team, elo)

# 2) Build or import a schedule (week, home, away)
# nfl_schedule <- NFLSimulatoR::load_schedule(season = 2026)
# or your own:
nfl_schedule <- tibble(
  week = rep(1:10, each = 3),
  home = sample(teams, 30, replace = TRUE),
  away = sample(teams, 30, replace = TRUE)
) %>% filter(home != away)

# 3) Map strengths to win probs (Elo-based or model-based)
nfl_games <- nfl_schedule %>%
  left_join(team_strengths %>% rename(home_elo = elo), by = c("home" = "team")) %>%
  left_join(team_strengths %>% rename(away_elo = elo), by = c("away" = "team")) %>%
  mutate(
    elo_diff = home_elo - away_elo,
    p_home = elo_prob(elo_diff, scale = 250)
  )

# 4) Simulate a season many times
simulate_nfl_like <- function(games, n_sims = 5000) {
  sims <- replicate(n_sims, {
    games %>%
      mutate(home_win = rbinom(n(), 1, p_home)) %>%
      transmute(home, away, home_w = home_win, away_w = 1 - home_win) %>%
      pivot_longer(cols = c(home, away), names_to = "side", values_to = "team") %>%
      mutate(win = if_else(side == "home", home_w, away_w)) %>%
      group_by(team) %>%
      summarize(wins = sum(win), .groups = "drop")
  }, simplify = FALSE)

  bind_rows(sims, .id = "sim") %>%
    mutate(sim = as.integer(sim))
}

nfl_sims <- simulate_nfl_like(nfl_games, n_sims = 3000)

nfl_summary <- nfl_sims %>%
  group_by(team) %>%
  summarize(
    avg_wins = mean(wins),
    p_10plus = mean(wins >= 10),
    .groups = "drop"
  ) %>%
  arrange(desc(avg_wins))

nfl_summary

Key idea: you don’t need a “perfect” game model to get useful season-level probabilities. A reasonable win-probability engine + Monte Carlo is already powerful.

11) Evaluation: calibration + proper scoring

In prediction, accuracy alone is not enough. You want probabilities that are well-calibrated and score well under proper scoring rules (Brier score, log loss).

11.1 Brier score

brier_score <- function(y, p) mean((y - p)^2)

# Compare Elo mapping vs logistic regression mapping (on the same historical set)
eval_tbl <- train %>%
  mutate(
    p_elo   = elo_prob(elo_diff, scale = 250),
    p_logit = predict(logit, newdata = ., type = "response")
  ) %>%
  summarize(
    brier_elo = brier_score(home_win, p_elo),
    brier_logit = brier_score(home_win, p_logit)
  )

eval_tbl

11.2 Calibration table (quick check)

calibration_table <- function(y, p, bins = 10) {
  tibble(y = y, p = p) %>%
    mutate(bin = ntile(p, bins)) %>%
    group_by(bin) %>%
    summarize(
      p_avg = mean(p),
      y_avg = mean(y),
      n = n(),
      .groups = "drop"
    )
}

calib <- train %>%
  mutate(p_logit = predict(logit, newdata = ., type = "response")) %>%
  calibration_table(y = home_win, p = p_logit, bins = 10)

calib

Interpretation: In each probability bin, the average predicted probability should roughly match the observed win rate. If not, your model is miscalibrated (common—and fixable).

12) Wrap-up: a practical workflow you can reuse

Here’s the repeatable blueprint you just built:

  1. Ingest results into a tidy match table (one row per game).
  2. Estimate strengths (Elo/Glicko or similar).
  3. Map strengths → probabilities (Elo curve or a fitted logistic model).
  4. Simulate forward with Monte Carlo for season and playoff probabilities.
  5. Run counterfactuals by adjusting strengths or model features.
  6. Evaluate with proper scoring and calibration checks.

If you liked this approach and want a compact, hands-on walkthrough that stitches these pieces into a single practical learning path—ratings (Elo/Glicko), Monte Carlo seasons/tournaments, and forecasting models with runnable R code— you can find a focused guide here: Sports Prediction and Simulation with R.

It’s built to be “open it, run the code, understand the method”—so you can apply the same pipeline to football, basketball, American football, tennis, baseball, esports, and beyond.

Disclaimer: This article is educational and focuses on modeling and simulation techniques. Real-world performance depends on data quality, league structure, and how well your features capture the game.

The post How to Predict Sports in R: Elo, Monte Carlo, and Real Simulations appeared first on R Programming Books.

To leave a comment for the author, please follow the link and comment on their blog: Blog - R Programming Books.

R-bloggers.com offers daily e-mail updates about R news and tutorials about learning R and many other topics. Click here if you're looking to post or find an R/data-science job.
Want to share your content on R-bloggers? click here if you have a blog, or here if you don't.

Never miss an update!
Subscribe to R-bloggers to receive
e-mails with the latest R posts.
(You will not see this message again.)

Click here to close (This popup will not appear again)