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

Skip to content

Football > World Cup 2026

Football · World Cup 2026 · Predictions Hub

World Cup 2026

The model’s read on the whole tournament — live scores and what-if simulations for all 48 teams, updated through to the July 19 final.

Show code
html`<div class="byline">
  <span>By <strong>Pete Owen</strong></span>
  <span>Updated · <strong>Weekly (Wed)</strong></span>
  <span><a href="../blog/2026-04-24-understanding-panna/">Methodology &rarr;</a></span>
  <span>Open source · <a href="https://github.com/peteowen1/panna" target="_blank" rel="noopener">panna</a></span>
</div>`
Show code
window.editorial.sidebarToggle()
Show code
_wcFull = window.wcLiveSim.fullStream()
_wcSimulation = window.wcLiveSim.simStream()
_wcStrength = {
  try { return await window.fetchParquet(window.DATA_BASE_URL + "football/wc2026_team_strength.parquet") }
  catch (e) { console.error("[wc2026] team_strength load failed:", e); return null }
}
_wcHubFixtures = {
  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). NULL = feed unavailable
// (outage — the cards strip shows a muted notice), [] = feed loaded but no
// WC rows yet. The cards below degrade gracefully to date-only in both cases.
_wcHubLiveFx = {
  return await window.wcMaps.fetchWcFixtures()
}
Show code
// ── Navigation cards ───────────────────────────────────────
// Card grid mirrors football/index.qmd's section pattern. Each card
// links to one of the WC sub-pages (simulator, bracket picker, title race,
// groups, matches, strength) and previews what the reader will find there.
{
  const ns = "http://www.w3.org/2000/svg"
  const accent = "#5a9a7a"
  const statsEsc = window.statsEsc

  function svgIcon(children) {
    const s = document.createElementNS(ns, "svg")
    s.setAttribute("viewBox", "0 0 24 24")
    s.setAttribute("width", "24"); s.setAttribute("height", "24")
    s.setAttribute("fill", "none"); s.setAttribute("stroke", accent)
    s.setAttribute("stroke-width", "1.5"); s.setAttribute("stroke-linecap", "round"); s.setAttribute("stroke-linejoin", "round")
    for (const c of children) {
      const el = document.createElementNS(ns, c[0])
      for (const [k, v] of Object.entries(c[1])) el.setAttribute(k, v)
      s.appendChild(el)
    }
    return s
  }

  const icons = {
    "title-race": () => svgIcon([["path", { d: "M6 21h12" }], ["path", { d: "M12 17v4" }], ["path", { d: "M7 3h10v6a5 5 0 1 1-10 0V3z" }]]),
    "groups":     () => svgIcon([["rect", { x: 3, y: 3, width: 8, height: 8, rx: 1 }], ["rect", { x: 13, y: 3, width: 8, height: 8, rx: 1 }], ["rect", { x: 3, y: 13, width: 8, height: 8, rx: 1 }], ["rect", { x: 13, y: 13, width: 8, height: 8, rx: 1 }]]),
    "matches":    () => svgIcon([["rect", { x: 2, y: 5, width: 20, height: 14, rx: 2 }], ["line", { x1: 12, y1: 5, x2: 12, y2: 19 }], ["line", { x1: 7, y1: 10, x2: 7, y2: 14 }], ["line", { x1: 17, y1: 10, x2: 17, y2: 14 }]]),
    "strength":   () => svgIcon([["line", { x1: 4, y1: 20, x2: 20, y2: 20 }], ["rect", { x: 5, y: 13, width: 3, height: 7, rx: 0.5 }], ["rect", { x: 10.5, y: 9, width: 3, height: 11, rx: 0.5 }], ["rect", { x: 16, y: 4, width: 3, height: 16, rx: 0.5 }]]),
    "simulator":  () => svgIcon([["rect", { x: 3, y: 3, width: 18, height: 18, rx: 3 }], ["circle", { cx: 8, cy: 8, r: 1.3, fill: accent, stroke: "none" }], ["circle", { cx: 16, cy: 8, r: 1.3, fill: accent, stroke: "none" }], ["circle", { cx: 12, cy: 12, r: 1.3, fill: accent, stroke: "none" }], ["circle", { cx: 8, cy: 16, r: 1.3, fill: accent, stroke: "none" }], ["circle", { cx: 16, cy: 16, r: 1.3, fill: accent, stroke: "none" }]]),
    "bracket":    () => svgIcon([["rect", { x: 3, y: 3, width: 6, height: 4, rx: 1 }], ["rect", { x: 3, y: 17, width: 6, height: 4, rx: 1 }], ["rect", { x: 15, y: 10, width: 6, height: 4, rx: 1 }], ["path", { d: "M9 5h3v7h3" }], ["path", { d: "M9 19h3v-7" }]]),
    "players":    () => svgIcon([["circle", { cx: 12, cy: 7, r: 3.5 }], ["path", { d: "M5 21v-1.5a6 6 0 0 1 6-6h2a6 6 0 0 1 6 6V21" }]]),
    "wallchart":  () => svgIcon([["rect", { x: 3, y: 3, width: 18, height: 18, rx: 1.5 }], ["line", { x1: 3, y1: 9, x2: 21, y2: 9 }], ["line", { x1: 9, y1: 9, x2: 9, y2: 21 }], ["line", { x1: 15, y1: 9, x2: 15, y2: 21 }]]),
    "venues":     () => svgIcon([["path", { d: "M12 21s-7-6.2-7-11a7 7 0 0 1 14 0c0 4.8-7 11-7 11z" }], ["circle", { cx: 12, cy: 10, r: 2.5 }]]),
    "ratings":    () => svgIcon([["polyline", { points: "3 17 9 11 13 15 21 7" }], ["polyline", { points: "15 7 21 7 21 13" }]])
  }

  const pages = [
    { title: "Simulator", desc: "Lock in results and watch every team's odds update live", href: "world-cup-simulator", icon: "simulator" },
    { title: "Wall Chart", desc: "Every group and the whole bracket on one printable poster", href: "world-cup-wallchart", icon: "wallchart" },
    { title: "Pick Your Bracket", desc: "Fill in your own bracket and take on the model", href: "world-cup-predictor", icon: "bracket" },
    { title: "Title Race", desc: "Champion odds from the 10K-tournament sim", href: "world-cup-title-race", icon: "title-race" },
    { title: "Groups", desc: "Finish probabilities for all 12 groups", href: "world-cup-groups", icon: "groups" },
    { title: "Match Predictions", desc: "All 72 fixtures with probabilities and scorelines", href: "world-cup-matches", icon: "matches" },
    { title: "Player Ratings", desc: "Every player at the tournament, rated going in", href: "world-cup-player-ratings", icon: "ratings" },
    { title: "Player Stats", desc: "Tournament box scores, filling in as games are played", href: "world-cup-player-stats", icon: "players" },
    { title: "Venues", desc: "Sixteen stadiums, three countries, on the map", href: "world-cup-venues", icon: "venues" },
    { title: "Team Strength", desc: "Tiento — every squad in goals above average", href: "world-cup-strength", icon: "strength" }
  ]

  const grid = document.createElement("div")
  grid.className = "wc-nav-grid"
  for (const p of pages) {
    const card = document.createElement("a")
    card.className = "wc-nav-card"
    card.href = p.href + ".html"
    const iconEl = icons[p.icon]()
    const header = document.createElement("div")
    header.className = "wc-nav-card-header"
    header.appendChild(iconEl)
    const title = document.createElement("span")
    title.className = "wc-nav-card-title"
    title.textContent = p.title
    header.appendChild(title)
    card.appendChild(header)
    const desc = document.createElement("p")
    desc.className = "wc-nav-card-desc"
    desc.textContent = p.desc
    card.appendChild(desc)
    grid.appendChild(card)
  }
  return grid
}
Show code
// ── On now & next two days — match cards ───────────────────
// Sits above the nav cards: the time-sensitive strip is the newsiest thing
// on the page, then the reader chooses a sub-page. Cards reuse theme.scss's
// shared .match-card language; kickoff times render in the READER'S timezone
// from the fixture feed's utcDate (graceful date-only fallback until the
// worker fixture cron carries WC rows).
{
  const statsEsc = window.statsEsc
  const maps = window.wcMaps
  const liveFx = _wcHubLiveFx || []
  const fixtures = _wcHubFixtures || []
  if (fixtures.length === 0 && liveFx.length === 0) return html``

  // fixture-feed lookup keyed on UTC day + canonical team pair. 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 — pair-only keys let the KO
  // row clobber the group row. Slice the ISO string (UTC day), never round
  // through a local Date.
  const fxKey = m => `${String(m.date || "").slice(0, 10)}|${m._h}|${m._a}`
  const fxByPair = new Map(liveFx.map(m => [fxKey(m), m]))

  const parseDay = d => {
    const m = String(d || "").slice(0, 10).match(/^(\d{4})-(\d{2})-(\d{2})$/)
    return m ? new Date(Date.UTC(+m[1], +m[2] - 1, +m[3], 12)) : null  // noon UTC: stable local date
  }
  const now = new Date()
  const today = new Date(); today.setHours(0, 0, 0, 0)
  const windowEnd = new Date(today.getTime() + 3 * 86400000)  // today + 2 full days
  const recentCut = new Date(now.getTime() - 4 * 3600000)     // keep a 23:00-yesterday kickoff visible

  // UNION of the two sources: the parquet only carries the 72 GROUP-stage
  // fixtures, so from June 28 the knockouts exist ONLY in the feed. Parquet
  // rows join their feed row by day + canonical pair (as before); unmatched
  // feed rows become prediction-less knockout cards.
  const matched = new Set()
  const entries = fixtures.map(f => {
    const dayKey = String(f.match_date || "").replace("Z", "").slice(0, 10)
    const key = `${dayKey}|${maps.normalizeWcTeam(f.home_team)}|${maps.normalizeWcTeam(f.away_team)}`
    const live = fxByPair.get(key)
    if (live) matched.add(key)
    const when = live?.date ? new Date(live.date) : parseDay(f.match_date)
    return { f, live, when }
  })
  for (const m of liveFx) {
    if (matched.has(fxKey(m))) continue
    entries.push({ f: null, live: m, when: m.date ? new Date(m.date) : null })
  }

  // In window: any live game, plus anything from "4h ago" (so a match that
  // kicked off late yesterday and is still in play doesn't vanish at local
  // midnight) up to the end of the 3rd day.
  const upcoming = entries
    .filter(x => x.when && ((x.live && maps.LIVE_STATUSES.has(x.live.status)) ||
      (x.when >= recentCut && x.when < windowEnd)))
    .sort((a, b) => a.when - b.when)
  if (!upcoming.length) return html``

  const fmtTime = dt => dt.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" })

  const wrap = document.createElement("div")
  wrap.className = "wc-hub-cards"
  const label = document.createElement("div")
  label.className = "wc-hero-fxlabel"
  label.textContent = "On now and the next two days — kickoff times in your timezone"
  wrap.appendChild(label)

  // Feed outage (null — distinct from the no-WC-rows-yet []): say so, since
  // the parquet-only cards silently drop live scores and kickoff times.
  if (_wcHubLiveFx === null) {
    const feedNote = document.createElement("p")
    feedNote.className = "text-muted"
    feedNote.style.cssText = "font-size:0.78rem;margin:0 0 0.4rem"
    feedNote.textContent = "Live scores and kickoff times are temporarily unavailable — showing model predictions."
    wrap.appendChild(feedNote)
  }

  let lastHead = null
  const sections = []
  for (const { f, live, when } of upcoming) {
    const head = maps.fmtDateHead(when)
    if (head !== lastHead) { sections.push(`<div class="match-date-header">${statsEsc(head)}</div>`); lastHead = head }

    const isLive = live && maps.LIVE_STATUSES.has(live.status)
    const finished = live && live.status === "FINISHED" && live.homeScore != null
    const hasTime = live && live.date && String(live.date).length > 11
    const infoBits = []
    if (isLive) infoBits.push(`<span class="live-badge">${live.status === "PAUSED" ? "HT" : "LIVE"}</span>`)
    if (hasTime && !finished && !isLive) infoBits.push(statsEsc(fmtTime(when)))
    // Feed rows don't carry a venue for WC games — fall back to the curated
    // group-stage map (keyed on the parquet's UTC day + canonical pair).
    const venue = live?.venue || (f ? maps.venueFor(f.match_date, f.home_team, f.away_team) : null)
    if (venue) {
      const country = maps.venueCountryOf(venue)
      infoBits.push(statsEsc(country ? `${venue} · ${country}` : venue))
    }
    const chip = f
      ? `<a class="wc-group-chip" href="world-cup-group.html#group=${statsEsc(f.group)}">Group ${statsEsc(f.group)}</a>`
      : `<a class="wc-group-chip" href="world-cup-wallchart.html">Knockout</a>`
    const infoHtml = `<div class="match-info">${infoBits.join(" · ")}<span class="wc-chip-slot">${chip}</span></div>`

    const showScore = (finished || isLive) && live.homeScore != null
    const centre = showScore
      ? `<div class="match-score">${live.homeScore} – ${live.awayScore}</div>`
      : "vs"

    // Match-page deep link (stretched via .wc-match-link, see theme.scss) —
    // home/away must be the PARQUET-canonical names: the match page resolves
    // its predictions row by league|date|normalized names, then pulls live
    // xG/shots/chains from the worker by Opta match_id. _h/_a arrive
    // pre-normalized from fetchWcFixtures, parquet rows are canonical already.
    const linkDay = f
      ? String(f.match_date || "").replace("Z", "").slice(0, 10)
      : String(live?.date || "").slice(0, 10)
    const linkHome = f ? f.home_team : live._h
    const linkAway = f ? f.away_team : live._a
    const matchLink = `<a class="wc-match-link" href="match.html#league=WC&date=${linkDay}&home=${encodeURIComponent(linkHome)}&away=${encodeURIComponent(linkAway)}">Match centre &rarr;</a>`

    if (f) {
      const favH = f.predicted === "H", favA = f.predicted === "A"
      // Footer prediction summary — same convention as the AFL/football
      // match cards ("Prediction: Team 1.8 : 0.9 Team")
      const predSummary = (f.pred_home_goals != null && f.pred_away_goals != null)
        ? `<div class="pred-summary">Prediction: ${statsEsc(f.home_team)} ${f.pred_home_goals.toFixed(1)} : ${f.pred_away_goals.toFixed(1)} ${statsEsc(f.away_team)}</div>`
        : ""
      sections.push(`
        <div class="match-card football wc-hub-card wc-linked">
          ${infoHtml}
          <div class="match-teams">
            <div class="team ${favH ? "favoured" : ""}">${maps.teamLinkHtml(f.home_team)}<span class="rating">${f.pred_home_goals?.toFixed(1) ?? ""}</span></div>
            <div class="match-vs">${centre}</div>
            <div class="team ${favA ? "favoured" : ""}">${maps.teamLinkHtml(f.away_team)}<span class="rating">${f.pred_away_goals?.toFixed(1) ?? ""}</span></div>
          </div>
          <div class="match-prediction"><div class="prob-bars-group">
            <div class="prob-bar-row">${maps.probBarHtml(f.prob_home, f.prob_draw, f.prob_away)}</div>
          </div></div>
          <div class="match-card-footer">${predSummary}${matchLink}</div>
        </div>`)
    } else {
      // Feed-only knockout card — no prediction columns: flags + linked
      // names + kickoff/LIVE/score states only.
      sections.push(`
        <div class="match-card football wc-hub-card wc-linked">
          ${infoHtml}
          <div class="match-teams">
            <div class="team">${maps.teamLinkHtml(live._h)}</div>
            <div class="match-vs">${centre}</div>
            <div class="team">${maps.teamLinkHtml(live._a)}</div>
          </div>
          <div class="match-card-footer">${matchLink}</div>
        </div>`)
    }
  }
  sections.push(`<a class="wc-hero-more" href="world-cup-matches.html">All 72 fixtures &rarr;</a>`)
  const inner = document.createElement("div")
  inner.className = "match-cards-container"
  inner.innerHTML = sections.join("")
  wrap.appendChild(inner)
  return wrap
}
Show code
// ── Top 8 favourites teaser (pulls from simulation) ────────
{
  if (_wcSimulation == null || _wcSimulation.length === 0) return html``
  const maps = window.wcMaps
  const statsEsc = window.statsEsc

  const sorted = [..._wcSimulation].sort((a, b) => b.p_champ - a.p_champ)
  const top = sorted.slice(0, 8)
  const restPct = sorted.slice(8).reduce((s, t) => s + t.p_champ, 0)
  // Fixed 0-25% axis: bar lengths are comparable AS probabilities, so a 17%
  // favourite reads as "very much not a lock" rather than a full bar.
  const AXIS = 25

  const wrap = document.createElement("div")
  wrap.style.marginTop = "1.5rem"
  const h2 = document.createElement("h2"); h2.textContent = "Who actually wins this thing?"
  const head = document.createElement("div")
  head.style.cssText = "display:flex;align-items:center;gap:0.6rem;flex-wrap:wrap"
  head.append(h2, window.wcLiveSim.liveBadge(_wcFull ? _wcFull.meta : null))
  wrap.appendChild(head)
  const note = document.createElement("p")
  note.className = "text-muted"
  note.style.cssText = "font-size:0.85rem;margin:0 0 0.6rem"
  note.innerHTML = `Title chances from a 10,000-tournament sim re-run live in your browser — even the favourite wins barely one run in six. Bars drawn to a 25% scale. <a href="world-cup-title-race.html">Full leaderboard &rarr;</a> · <a href="world-cup-simulator.html">Run your own what-ifs &rarr;</a>`
  wrap.appendChild(note)

  const fairOdds = (p) => p > 0 ? `$${(100 / p).toFixed(100 / p >= 20 ? 0 : 1)}` : ""

  const list = document.createElement("div")
  list.className = "wc-titlerace"
  top.forEach((t, i) => {
    const w = Math.min(100, (t.p_champ / AXIS) * 100)
    const row = document.createElement("div")
    row.className = "wc-titlerace-row"
    row.innerHTML = `
      <span class="wc-titlerace-rank">${i + 1}</span>
      <span class="wc-titlerace-team">${maps.teamLinkHtml(t.team)}</span>
      <a class="wc-titlerace-group" href="world-cup-group.html#group=${statsEsc(t.group)}">Grp ${statsEsc(t.group)}</a>
      <div class="wc-titlerace-bar"><div class="wc-titlerace-fill" style="width:${w}%"></div></div>
      <span class="wc-titlerace-pct">${t.p_champ.toFixed(1)}%</span>
      <span class="wc-titlerace-odds" title="Fair decimal odds: no margin, straight from the sim">${fairOdds(t.p_champ)}</span>
    `
    list.appendChild(row)
  })
  // The honest context line: the long tail collectively matters.
  const rest = document.createElement("div")
  rest.className = "wc-titlerace-row wc-titlerace-rest"
  rest.innerHTML = `
    <span class="wc-titlerace-rank"></span>
    <span class="wc-titlerace-team">The other ${sorted.length - 8} teams</span>
    <span class="wc-titlerace-group"></span>
    <div class="wc-titlerace-bar"><div class="wc-titlerace-fill wc-titlerace-fill-rest" style="width:${Math.min(100, (restPct / AXIS) * 100)}%"></div></div>
    <span class="wc-titlerace-pct">${restPct.toFixed(1)}%</span>
    <span class="wc-titlerace-odds"></span>
  `
  list.appendChild(rest)
  wrap.appendChild(list)
  return wrap
}
Show code
{
  const inner = document.createElement("div")
  inner.className = "side-rail-inner"
  const { railBlock, btnTile, tableSource } = window.editorial

  if (_wcSimulation && _wcSimulation.length > 0) {
    const sorted = [..._wcSimulation].sort((a, b) => b.p_champ - a.p_champ)
    const fav = sorted[0]
    const btn = railBlock("By the numbers")
    btn.appendChild(btnTile(`${fav.p_champ.toFixed(0)}%`, [
      { text: "Title favourite · " }, { text: fav.team, bold: true }
    ]))
    if (_wcStrength && _wcStrength.length > 0) {
      // How many of the seven rating systems agree on the top squad —
      // self-explanatory, unlike a raw panna decimal.
      const systems = ["panna", "offense", "defense", "epr", "psr", "elo", "bt"]
      const counts = new Map()
      for (const k of systems) {
        const top = [..._wcStrength].sort((a, b) => (b[k] ?? -Infinity) - (a[k] ?? -Infinity))[0]
        if (top) counts.set(top.team, (counts.get(top.team) || 0) + 1)
      }
      const [topTeam, nAgree] = [...counts.entries()].sort((a, b) => b[1] - a[1])[0]
      btn.appendChild(btnTile(`${nAgree} of 7`, [
        { text: "rating systems say " }, { text: topTeam, bold: true }, { text: " has the strongest squad" }
      ]))
    }
    btn.appendChild(btnTile("48", [
      { text: "Teams qualified" }
    ]))
    btn.appendChild(btnTile("72", [
      { text: "Group-stage fixtures" }
    ]))
    inner.appendChild(btn)
  }

  const links = railBlock("Read next")
  const l1 = document.createElement("div"); l1.innerHTML = `<a href="leagues.html"><strong>Leagues &amp; Sims</strong></a><br><span class="text-muted" style="font-size:0.78rem">Club season projections</span>`
  links.appendChild(l1)
  const l2 = document.createElement("div"); l2.style.marginTop = "0.7rem"
  l2.innerHTML = `<a href="player-ratings.html"><strong>Player Ratings</strong></a><br><span class="text-muted" style="font-size:0.78rem">Panna ratings across 15 leagues</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",
    hint: "Refreshed weekly · 10K-simulation Monte Carlo"
  }))

  return inner
}
 

Pete Owen · Sydney · © 2026 · Source

My Teams | Settings | Photo Credits | Privacy | Disclaimer