In The Game
  • Home
  • World Cup
  • Blog
  • Games
  • AFL
    • Overview

    • Matches
    • Ladder

    • Player Stats
    • Player Ratings
    • Player Game Logs
    • Player Comparison
    • Card Deck
    • Age Curves

    • Team Stats
    • Team Game Logs
    • Team Ratings

    • Definitions
  • Football
    • Overview

    • Matches
    • Leagues

    • World Cup 2026
    • Simulator
    • Wall Chart
    • Pick Your Bracket
    • Title Race
    • Group Projections
    • Match Predictions
    • Player Stats
    • Player Ratings
    • Venues
    • Team Strength
    • Head to Head

    • Player Stats
    • Player Ratings
    • Player Game Logs
    • Player Comparison
    • Card Deck

    • Team Stats
    • Team Game Logs
    • Team Ratings

    • Definitions
  • About

World Cup 2026 — Match Predictions

Skip to content

Football > World Cup 2026 > Match Predictions

Football · World Cup 2026 · Match Predictions

What’s the model picking?

Win / draw / loss probabilities and predicted scorelines for all 72 group-stage fixtures, from panna’s XGBoost goals + outcome model. The three host nations get a home-ground edge in their own games; every other fixture is treated as neutral-venue.

Show code
html`<div class="byline">
  <span>By <strong>Pete Owen</strong></span>
  <span>Updated · <strong>Weekly</strong></span>
  <span><a href="world-cup-2026.html">World Cup 2026 home &uarr;</a></span>
  <span>&approx; 4 min read</span>
</div>`
Show code
window.editorial.sidebarToggle()
Show code
statsEsc = window.statsEsc

_wcPredictions = {
  try { return await window.fetchParquet(window.DATA_BASE_URL + "football/wc2026_predictions.parquet") }
  catch (e) { console.error("[wc2026] predictions load failed:", e); return null }
}

// Live fixture feed (kickoff times, scores, status) — same shared loader as
// the hub (wcMaps.fetchWcFixtures: Worker→R2 fallback, feed-side name
// normalisation into _h/_a). NULL = feed unavailable (outage — the cards
// cell shows a muted notice), [] = feed loaded but has no WC rows (silent).
// Either way, consumers must degrade to the prediction-only view when the
// feed has no WC rows. The parquet side is normalised at join time
// (wcMaps.normalizeWcTeam on BOTH sides).
_wcLiveFx = {
  return await window.wcMaps.fetchWcFixtures()
}
Show code
// ── Filter row: group dropdown + team select + CSV export ────
// Inputs.form composes both selects into one viewof so they sit on one row;
// value is { group, team }. Each filter narrows the 72 fixtures (a team
// select shows that side's 3 group games). The Download CSV button
// serializes the full predictions parquet — not the filtered view — via a
// Blob download.
viewof wcFilters = {
  if (_wcPredictions == null) return html``
  const groups = [...new Set(_wcPredictions.map(d => String(d.group || "")))]
    .filter(Boolean).sort()
  const teams = [...new Set(_wcPredictions.flatMap(d => [d.home_team, d.away_team]))]
    .filter(Boolean).sort((a, b) => String(a).localeCompare(String(b)))

  const csvBtn = document.createElement("button")
  csvBtn.type = "button"
  csvBtn.className = "wc-csv-btn"
  csvBtn.textContent = "Download CSV"
  csvBtn.title = "All 72 fixture predictions as CSV"
  csvBtn.onclick = () => {
    const cols = ["match_date", "group", "home_team", "away_team",
      "prob_home", "prob_draw", "prob_away", "pred_home_goals", "pred_away_goals", "predicted"]
    const cell = v => {
      if (v == null) return ""
      const s = String(v)
      return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s
    }
    const lines = [cols.join(",")]
    for (const r of _wcPredictions) {
      lines.push(cols.map(c => cell(c === "match_date" ? String(r[c] ?? "").replace("Z", "") : r[c])).join(","))
    }
    const url = URL.createObjectURL(new Blob([lines.join("\n")], { type: "text/csv;charset=utf-8" }))
    const a = document.createElement("a")
    a.href = url
    a.download = "wc2026-predictions.csv"
    document.body.appendChild(a)
    a.click()
    a.remove()
    URL.revokeObjectURL(url)
  }

  return Inputs.form(
    {
      group: Inputs.select(["All groups", ...groups], {
        label: "Group",
        format: g => g === "All groups" ? g : "Group " + g
      }),
      team: Inputs.select(["All teams", ...teams], { label: "Team" })
    },
    { template: (inputs) => htl.html`<div class="wc-filter-row">${Object.values(inputs)}${csvBtn}</div>` }
  )
}
Show code
// ── Live / All / Fixtures / Results toggle ───────────────────
// Mirrors football/matches.qmd's epv-toggle. Live = in-play, Results =
// finished (newest first), Fixtures = upcoming (incl. the knockout
// schedule), All = everything chronological. Default "All" — most of the
// tournament window has no live game, so Live would land empty.
viewof wcMatchView = {
  const wrap = document.createElement("div")
  wrap.className = "epv-toggle"
  wrap.value = "All"
  for (const label of ["Live", "All", "Fixtures", "Results"]) {
    const btn = document.createElement("button")
    btn.className = "epv-toggle-btn" + (label === "All" ? " active" : "")
    btn.dataset.view = label
    btn.textContent = label
    btn.addEventListener("click", () => {
      wrap.querySelectorAll(".epv-toggle-btn").forEach(b => b.classList.remove("active"))
      btn.classList.add("active")
      wrap.value = label
      wrap.dispatchEvent(new Event("input", { bubbles: true }))
    })
    wrap.appendChild(btn)
  }
  return wrap
}
Show code
// ── Match cards, grouped by date ─────────────────────────────
// Mirrors football/matches.qmd's card pattern (match-cards-container /
// match-date-header / match-card.football / prob bars / footer summary).
// Cards join the live fixture feed by normalised home|away pair: kickoff
// time for upcoming games, LIVE/HT badge + score in-play, final score +
// a correct/wrong prediction tag when finished. No fixture row → the card
// must degrade to the prediction-only view. Each card stretch-links to the
// football match page (match.html#league=WC&…) — predictions.parquet carries
// all 104 WC fixtures, so the match page resolves the Opta match_id and pulls
// live xG/shots/chains from the worker even before the parquet rebuild lands.
// Cards can't be wrapped in an <a> (team names + group chip are anchors), so
// the footer .wc-match-link grows a ::after overlay instead (theme.scss).
{
  if (_wcPredictions == null) return html`<p class="text-muted">Data failed to load — try refreshing (see console for details).</p>`
  if (_wcPredictions.length === 0) return html`<p class="text-muted">No prediction data available.</p>`

  const wc = window.wcMaps
  const f = wcFilters || {}
  const groupSel = f.group || "All groups"
  const teamSel = f.team || "All teams"

  // Fixture-feed lookup keyed on UTC day + canonical team pair (hub
  // convention). Date is part of the key because from the QF onward a
  // knockout tie can be an exact same-orientation rematch of a group fixture
  // (plus the 3rd-place game) — pair-only keys let the KO row clobber the
  // group row, showing the wrong score/verdict on finished group cards.
  // Slice the ISO string (UTC day), never round through a local Date.
  const fxByPair = new Map((_wcLiveFx || []).map(m => [`${String(m.date || "").slice(0, 10)}|${m._h}|${m._a}`, m]))
  const LIVE = wc.LIVE_STATUSES
  const fmtTime = dt => dt.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" })
  // xG comes from the worker live model (not the parquet — it carries no WC
  // xG). Awaited up front for EVERY started game so each card paints with its
  // Pred+xG bars at once — no deferred patch, no "some cards have xG" flicker.
  const xgMap = await wc.fetchWcXgMap(_wcLiveFx)

  let rows = [..._wcPredictions]
  if (groupSel !== "All groups") rows = rows.filter(r => String(r.group) === groupSel)
  if (teamSel !== "All teams") rows = rows.filter(r => r.home_team === teamSel || r.away_team === teamSel)
  rows.sort((a, b) => {
    const d = String(a.match_date || "").localeCompare(String(b.match_date || ""))
    if (d !== 0) return d
    return String(a.group || "").localeCompare(String(b.group || ""))
  })

  // ── Toggle filter (Live / All / Fixtures / Results) ──
  // statusOf reads the same live-feed join the cards use below.
  const statusOf = (m) => {
    const day = String(m.match_date || "").replace("Z", "").slice(0, 10)
    const lv = fxByPair.get(`${day}|${wc.normalizeWcTeam(m.home_team)}|${wc.normalizeWcTeam(m.away_team)}`)
    if (lv && LIVE.has(lv.status)) return "live"
    if (lv && lv.status === "FINISHED" && lv.homeScore != null) return "finished"
    return "upcoming"
  }
  const view = wcMatchView || "All"
  if (view === "Live") rows = rows.filter(m => statusOf(m) === "live")
  else if (view === "Fixtures") rows = rows.filter(m => statusOf(m) === "upcoming")
  else if (view === "Results") {
    rows = rows.filter(m => statusOf(m) === "finished")
    rows.sort((a, b) => String(b.match_date || "").localeCompare(String(a.match_date || "")))  // newest first
  }
  // All: keep chronological (already sorted ascending).

  // Knockout schedule cards are upcoming fixtures with TBD teams — show them
  // under All and Fixtures, but only when not narrowed to a single group/team.
  const showKo = (view === "All" || view === "Fixtures") && groupSel === "All groups" && teamSel === "All teams"

  if (rows.length === 0 && !showKo) {
    const emptyMsg = view === "Live" ? "No World Cup games are in play right now."
      : view === "Results" ? "No completed World Cup games yet."
      : view === "Fixtures" ? "No upcoming fixtures match the current filters."
      : "No fixtures match the current filters."
    return html`<p class="text-muted">${emptyMsg}</p>`
  }

  // Group by calendar date (strip trailing "Z" — keys are plain YYYY-MM-DD)
  const byDate = new Map()
  for (const m of rows) {
    const key = String(m.match_date || "").replace("Z", "").slice(0, 10)
    if (!byDate.has(key)) byDate.set(key, [])
    byDate.get(key).push(m)
  }

  // Construct at NOON UTC (Date.UTC(..., 12)) so wcMaps.fmtDateHead — which
  // formats in the reader's locale — never shifts the weekday a day in
  // negative-offset timezones (match_date strings carry no time component).
  const formatDate = (key) => {
    const [y, mo, da] = key.split("-").map(Number)
    if (!y || !mo || !da) return key
    return wc.fmtDateHead(new Date(Date.UTC(y, mo - 1, da, 12)))
  }

  // Earlier / Upcoming anchor jump (anchor-only hash — data-loader's
  // hashchange reload only fires on key=value param changes, so this is safe)
  const today = new Date().toISOString().slice(0, 10)
  const dateKeys = [...byDate.keys()]
  const firstUpcoming = dateKeys.find(d => d >= today)
  const hasPast = dateKeys.some(d => d < today)
  const jumpHtml = (hasPast && firstUpcoming)
    ? `<div class="wc-jump"><a href="#wc-upcoming">Jump to upcoming fixtures &darr;</a></div>`
    : ""

  // Home side: wcMaps' standard flag-then-name anchor. Away side mirrors it
  // (name then flag) using the same wcMaps teamHref/flagImg primitives.
  const teamAnchor = (team, mirrored) => {
    if (team == null || team === "") return ""
    if (!mirrored) return wc.teamLinkHtml(team)
    return `<a class="wc-team-link" href="${wc.teamHref(team)}"><span>${wc.esc(team)}</span>${wc.flagImg(team)}</a>`
  }

  const sections = []
  for (const [dateKey, matches] of byDate) {
    const anchorId = (firstUpcoming && dateKey === firstUpcoming && hasPast) ? ` id="wc-upcoming"` : ""
    sections.push(`<div class="match-date-header"${anchorId}>${formatDate(dateKey)}</div>`)

    for (const m of matches) {
      const pH = m.prob_home, pD = m.prob_draw, pA = m.prob_away
      const hasPred = pH != null && pD != null && pA != null
      // Favoured side: trust the model's pick column, fall back to prob compare
      const pick = m.predicted || (hasPred
        ? (pH >= pA && pH >= pD ? "H" : pA > pH && pA >= pD ? "A" : "D")
        : null)
      const homeFav = pick === "H"
      const awayFav = pick === "A"

      // Live fixture join (normalised on both sides; dateKey is the UTC day
      // of the parquet's match_date, matching the feed's utcDate day)
      const pairKey = `${dateKey}|${wc.normalizeWcTeam(m.home_team)}|${wc.normalizeWcTeam(m.away_team)}`
      const live = fxByPair.get(pairKey)
      const isLive = live && LIVE.has(live.status)
      const finished = live && live.status === "FINISHED" && live.homeScore != null
      const hasTime = live && live.date && String(live.date).length > 11

      // xG for started games — from the worker live model (xgMap, awaited up
      // front), so every started card paints with its Pred+xG bars at once.
      const xg = (finished || isLive) ? (xgMap.get(pairKey) || null) : null

      const g = String(m.group || "")
      const chip = g
        ? `<a class="wc-group-chip" href="world-cup-group.html#group=${encodeURIComponent(g)}" title="Group ${statsEsc(g)} deep dive">Group ${statsEsc(g)}</a>`
        : ""
      const infoBits = []
      if (isLive) {
        // Opta feed carries a real match minute; show "LIVE 69'" when present.
        const liveLabel = live.status === "PAUSED" ? "HT"
          : (typeof live.minute === "number" && live.minute > 0 ? `LIVE ${live.minute}'` : "LIVE")
        infoBits.push(`<span class="live-badge">${statsEsc(liveLabel)}</span>`)
      }
      if (hasTime && !finished && !isLive) infoBits.push(statsEsc(fmtTime(new Date(live.date))))
      // Feed rows don't carry a venue for WC games — fall back to the curated
      // group-stage map (same dateKey + canonical pair as the live join above).
      const venue = live?.venue || wc.venueFor(dateKey, m.home_team, m.away_team)
      if (venue) {
        const country = wc.venueCountryOf(venue)
        infoBits.push(statsEsc(country ? `${venue} · ${country}` : venue))
      }
      // With feed content the info row goes flex (bits left, chip right);
      // without it only the group chip renders.
      const chipHtml = infoBits.length
        ? `<div class="match-info wc-info-row">${infoBits.join(" · ")}<span class="wc-chip-slot">${chip}</span></div>`
        : (chip ? `<div class="match-info">${chip}</div>` : "")

      // Centre: real score replaces "vs" for in-play + finished games, with
      // the xG line underneath (same .match-xg convention as matches.qmd).
      // Cards awaiting the live top-up render a hidden slot the patch fills.
      const showScore = (finished || isLive) && live.homeScore != null
      const xgLine = xg ? `<div class="match-xg">xG: ${xg.home.toFixed(1)} – ${xg.away.toFixed(1)}</div>` : ""
      const centre = showScore
        ? `<div class="match-score">${live.homeScore} – ${live.awayScore}${xgLine}</div>`
        : "vs"

      // Prediction verdict once the final whistle has gone
      let resultTag = ""
      if (finished && pick) {
        const actual = live.homeScore > live.awayScore ? "H" : live.homeScore < live.awayScore ? "A" : "D"
        resultTag = pick === actual
          ? `<span class="legend-tag legend-good wc-pred-tag">Correct</span>`
          : `<span class="legend-tag legend-bad wc-pred-tag">Wrong</span>`
      }

      const homeRating = m.pred_home_goals != null ? `<span class="rating">${m.pred_home_goals.toFixed(1)}</span>` : ""
      const awayRating = m.pred_away_goals != null ? `<span class="rating">${m.pred_away_goals.toFixed(1)}</span>` : ""

      // Dual Pred/xG bars for finished games (matches.qmd convention) — the
      // xG bar converts the totals to H/D/A via the shared Poisson model.
      const xgProb = ((finished || isLive) && xg && window.footballXgProb) ? window.footballXgProb(xg.home, xg.away) : null
      const barHtml = hasPred ? `
          <div class="match-prediction${xgProb ? " has-dual-bar" : ""}">
            <div class="prob-bars-group">
              <div class="prob-bar-row">${xgProb ? '<span class="prob-bar-label">Pred</span>' : ""}${wc.probBarHtml(pH, pD, pA, {
                title: `${m.home_team} ${(pH * 100).toFixed(0)}% · Draw ${(pD * 100).toFixed(0)}% · ${m.away_team} ${(pA * 100).toFixed(0)}%`
              })}</div>
              ${xgProb ? `<div class="prob-bar-row"><span class="prob-bar-label">xG</span>${wc.probBarHtml(xgProb.home, xgProb.draw, xgProb.away, {
                title: `xG-implied: ${m.home_team} ${(xgProb.home * 100).toFixed(0)}% · Draw ${(xgProb.draw * 100).toFixed(0)}% · ${m.away_team} ${(xgProb.away * 100).toFixed(0)}%`
              })}</div>` : ""}
            </div>
          </div>` : ""

      const predSummary = (m.pred_home_goals != null && m.pred_away_goals != null)
        ? `<div class="pred-summary">Prediction: ${statsEsc(m.home_team)} ${m.pred_home_goals.toFixed(1)} : ${m.pred_away_goals.toFixed(1)} ${statsEsc(m.away_team)}${resultTag}</div>`
        : resultTag
      // Match-page deep link — home/away must be the PARQUET names (the match
      // page resolves its predictions row by league|date|normalized names).
      const matchHref = `match.html#league=WC&date=${dateKey}&home=${encodeURIComponent(m.home_team)}&away=${encodeURIComponent(m.away_team)}`
      const matchLink = `<a class="wc-match-link" href="${matchHref}">Match centre &rarr;</a>`
      const footerHtml = `<div class="match-card-footer">${predSummary || ""}${matchLink}</div>`

      sections.push(`
        <div class="match-card football wc-linked">
          ${chipHtml}
          <div class="match-teams">
            <div class="team ${homeFav ? 'favoured' : ''}">${teamAnchor(m.home_team)}${homeRating}</div>
            <div class="match-vs">${centre}</div>
            <div class="team ${awayFav ? 'favoured' : ''}">${teamAnchor(m.away_team, true)}${awayRating}</div>
          </div>
          ${barHtml}
          ${footerHtml}
        </div>`)
    }
  }

  // ── Knockout schedule (M73–104) ──────────────────────────────
  // Teams are TBD until the groups resolve, but the WHEN and WHERE are fixed
  // by FIFA's match-number tree (wcMaps.koSchedule) — date, round, bracket
  // slot and venue for every knockout game. These cards are static; once
  // football-data.org creates the knockout fixtures (teams + kickoff times),
  // they surface as normal feed-joined cards in the dated sections above.
  // Shown under All / Fixtures only (showKo) — not Live or Results.
  if (showKo) {
  sections.push(`<div class="match-date-header" id="wc-knockout" style="margin-top:1.6rem">Knockout — Round of 32 to the Final</div>`)
  sections.push(`<p class="text-muted" style="font-size:0.82rem;margin:0.3rem 0 0.6rem">Dates, venues and bracket slots are locked; teams fill in as the groups finish. "1A" = Group A winner, "2B" = runner-up, "3rd" = a best-third slot, "W79" = winner of match 79.</p>`)
  let lastKoDay = null
  for (const k of wc.koSchedule) {
    const venueCountry = wc.venueCountryOf(k.key)
    const dayLabel = (() => {
      const [y, mo, da] = k.day.split("-").map(Number)
      return wc.fmtDateHead(new Date(Date.UTC(y, mo - 1, da, 12)))
    })()
    if (k.day !== lastKoDay) { sections.push(`<div class="wc-ko-day">${statsEsc(dayLabel)}</div>`); lastKoDay = k.day }
    sections.push(`
      <div class="match-card football wc-ko-card" title="FIFA match ${k.m}">
        <div class="match-info wc-info-row">${statsEsc(k.key)}${venueCountry ? ` · ${statsEsc(venueCountry)}` : ""}<span class="wc-chip-slot"><span class="wc-group-chip">${statsEsc(k.round)}</span></span></div>
        <div class="match-teams">
          <div class="team wc-ko-slot">${statsEsc(k.pair.split(" v ")[0])}</div>
          <div class="match-vs">vs</div>
          <div class="team wc-ko-slot" style="justify-content:flex-end">${statsEsc(k.pair.split(" v ")[1])}</div>
        </div>
      </div>`)
  }
  }

  // Feed outage (null, not the no-WC-rows-yet []): one muted line above the
  // cards so prediction-only rendering reads as degraded, not as the truth.
  const feedNote = _wcLiveFx === null
    ? `<p class="text-muted" style="font-size:0.82rem;margin:0 0 0.5rem">Live scores and kickoff times are temporarily unavailable — showing model predictions.</p>`
    : ""

  const root = html`<div>${feedNote}${jumpHtml}<div class="match-cards-container">${sections.join("")}</div></div>`
  return root
}
Show code
wcMatchesAsAt = window.editorial.dataUpdated(window.DATA_BASE_URL + "football/wc2026_predictions.parquet")
Show code
{
  const inner = document.createElement("div")
  inner.className = "side-rail-inner"
  const { railBlock, btnTile, tableSource } = window.editorial
  const asAt = await wcMatchesAsAt

  if (_wcPredictions && _wcPredictions.length > 0) {
    // Find the closest match between equally-rated teams (smallest gap in prob_home vs prob_away)
    const tossUps = [..._wcPredictions]
      .map(p => ({ ...p, gap: Math.abs(p.prob_home - p.prob_away) }))
      .sort((a, b) => a.gap - b.gap)
    const tightest = tossUps[0]
    // Heaviest mismatch
    const blowouts = [..._wcPredictions].sort((a, b) => Math.max(b.prob_home, b.prob_away) - Math.max(a.prob_home, a.prob_away))
    const blow = blowouts[0]
    const blowFav = blow.prob_home > blow.prob_away ? blow.home_team : blow.away_team
    const blowDog = blow.prob_home > blow.prob_away ? blow.away_team : blow.home_team
    const blowPct = (Math.max(blow.prob_home, blow.prob_away) * 100).toFixed(0)

    const btn = railBlock("By the numbers")
    btn.appendChild(btnTile("72", [{ text: "Group-stage fixtures" }]))
    const tightTile = btnTile(`${(tightest.prob_home * 100).toFixed(0)}/${(tightest.prob_draw * 100).toFixed(0)}/${(tightest.prob_away * 100).toFixed(0)}`, [
      { text: "Closest match · " }, { text: `${tightest.home_team} v ${tightest.away_team}`, bold: true }
    ])
    tightTile.title = `Home / Draw / Away win probability — ${(tightest.prob_home * 100).toFixed(0)}% / ${(tightest.prob_draw * 100).toFixed(0)}% / ${(tightest.prob_away * 100).toFixed(0)}%`
    btn.appendChild(tightTile)
    btn.appendChild(btnTile(`${blowPct}%`, [
      { text: "Heaviest favourite · " }, { text: blowFav, bold: true }, { text: ` v ${blowDog}` }
    ]))
    inner.appendChild(btn)
  }

  const links = railBlock("Read next")
  const l0 = document.createElement("div"); l0.innerHTML = `<a href="world-cup-simulator.html"><strong>Simulator</strong></a><br><span class="text-muted" style="font-size:0.78rem">Disagree? Lock results, re-run the odds</span>`
  links.appendChild(l0)
  const l1 = document.createElement("div"); l1.style.marginTop = "0.7rem"
  l1.innerHTML = `<a href="world-cup-title-race.html"><strong>Title Race</strong></a><br><span class="text-muted" style="font-size:0.78rem">Champion probabilities</span>`
  links.appendChild(l1)
  const l2 = document.createElement("div"); l2.style.marginTop = "0.7rem"
  l2.innerHTML = `<a href="world-cup-strength.html"><strong>Team Strength</strong></a><br><span class="text-muted" style="font-size:0.78rem">48 teams · 7 rating systems</span>`
  links.appendChild(l2)
  inner.appendChild(links)

  inner.appendChild(tableSource({
    source: "pannadata",
    sourceUrl: "https://github.com/peteowen1/pannadata",
    sourceNote: "Opta scrape",
    license: "CC BY 4.0",
    asAt: asAt || "Refreshed weekly",
    hint: "XGBoost outcome + goals model"
  }))

  return inner
}
 

Pete Owen · Sydney · © 2026 · Source

My Teams | Settings | Photo Credits | Privacy | Disclaimer