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 — Title Race

Skip to content

Football > World Cup 2026 > Title Race

Football · World Cup 2026 · Champion Probability

Who’s most likely to lift the trophy?

A 10,000-tournament Monte Carlo simulation built on panna’s match-prediction model, played on FIFA’s official bracket and re-run live in your browser: finished games are baked in to their real result, and while a game is in progress its odds update every minute from the live score. The same engine powers the interactive Simulator. (The published full-model wc2026_simulation.parquet is the fallback if the live feed is unavailable.)

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

// Live in-browser tournament sim — bakes in real + in-progress results and
// re-runs every minute while games are live (football/wc-live-sim.js). Streams
// are async generators, so every cell below re-evaluates on each live tick.
// _wcSimulation carries both the sim field names AND groups-parquet aliases.
_wcFull = window.wcLiveSim.fullStream()
_wcSimulation = window.wcLiveSim.simStream()
_wcGroups = window.wcLiveSim.groupsStream()
Show code
{ return window.wcLiveSim.liveBadge(_wcFull ? _wcFull.meta : null) }

The favourites

Champion probability from the latest 10,000-tournament simulation. Bars are drawn on a fixed 0–25% scale — even the favourite is far from a lock — and the fair odds carry no bookmaker margin.

Show code
{
  if (_wcSimulation == null) return html`<p class="text-muted">Data failed to load — try refreshing (see console for details).</p>`
  if (_wcSimulation.length === 0) return html`<p class="text-muted">No simulation data available.</p>`

  const maps = window.wcMaps
  const sorted = [..._wcSimulation].sort((a, b) => b.p_champ - a.p_champ)
  const top = sorted.slice(0, 10)
  const restPct = sorted.slice(10).reduce((s, t) => s + t.p_champ, 0)
  // Fixed 0-25% axis (same convention as the hub): bar lengths read as
  // probabilities, so a 17% favourite never renders as a full bar.
  const AXIS = 25
  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 - top.length} 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)
  return list
}

Every team, every round

All 48 teams' chances of reaching each stage, from the same simulation. Sort any column, or download the CSV from the table toolbar.

Show code
{
  if (_wcSimulation == null || _wcSimulation.length === 0) return html``

  // Prefer the sim's true p_R32 (top-2 + best-thirds reach — panna exports
  // it since 2026-06-12; lands with the next pipeline rebuild). Until that
  // parquet refreshes, fall back to the groups parquet's `advance`, which is
  // TOP-TWO ONLY (pos1 + pos2, no best-thirds route — understates P(R32),
  // badly for third-heavy teams) and label it honestly.
  const hasR32 = _wcSimulation.some(r => r.p_R32 != null)
  const advBy = new Map((_wcGroups ?? []).map(r => [r.team, r.advance]))
  const rows = _wcSimulation.map(r => ({ ...r, p_first: hasR32 ? r.p_R32 : (advBy.get(r.team) ?? null) }))

  const fmtPct = x => x != null ? x.toFixed(1) + "%" : "—"
  return window.statsTable(rows, {
    columns: ["team", "group", "p_first", "p_R16", "p_QF", "p_SF", "p_final", "p_champ"],
    header: {
      team: "Team", group: "Grp",
      p_first: hasR32 ? "R32" : "Top 2", p_R16: "R16", p_QF: "QF", p_SF: "SF", p_final: "Final", p_champ: "Champ"
    },
    groups: [
      { label: "", span: 2 },
      { label: "Probability of reaching", span: 6 }
    ],
    format: { p_first: fmtPct, p_R16: fmtPct, p_QF: fmtPct, p_SF: fmtPct, p_final: fmtPct, p_champ: fmtPct },
    tooltip: {
      p_first: hasR32
        ? "Reaching the round of 32 — top-two group finish or one of the eight best third places"
        : "Top-two group finish (automatic qualification) — the best-third route to the round of 32 is not included here"
    },
    render: {
      team: (v, r) => window.wcMaps.teamLinkHtml(r.team, { cls: "player-link" })
    },
    heatmap: {
      p_first: "high-good", p_R16: "high-good", p_QF: "high-good", p_SF: "high-good",
      p_final: "high-good", p_champ: "high-good"
    },
    sort: "p_champ", reverse: true, rows: 48
  })
}

Knockout bracket

Show code
viewof wcBracketView = {
  const opts = [["empty", "Empty bracket"], ["predicted", "Most likely"]]
  const wrap = document.createElement("div")
  wrap.className = "wcr-pills"
  wrap.value = "empty"
  for (const [val, label] of opts) {
    const btn = document.createElement("button")
    btn.className = "wcr-pill" + (val === "empty" ? " active" : "")
    btn.textContent = label
    btn.addEventListener("click", () => {
      wrap.querySelectorAll(".wcr-pill").forEach(b => b.classList.remove("active"))
      btn.classList.add("active")
      wrap.value = val
      wrap.dispatchEvent(new Event("input", { bubbles: true }))
    })
    wrap.appendChild(btn)
  }
  return wrap
}

// FIFA's REAL R32 pairings — the shared wcMaps.r32Seeds bracket (wc-maps.js),
// in bracket-tree order (adjacent pairs meet in the R16). Format: 12 groups
// of 4 → R32 with top 2 from each group + the 8 best 3rd-placed teams.
// "1A" = group A winner, "2B" = group B runner-up, "3A/B/C/D/F" = a best-3rd
// slot fed by FIFA's published five-group possibility set.
wcBracketSeeds = window.wcMaps.r32Seeds

{
  if (_wcGroups == null || _wcSimulation == null) return html`<p class="text-muted">Data failed to load — try refreshing (see console for details).</p>`

  const view = wcBracketView

  // Build seed → team lookup for "predicted" view.
  // - 1X / 2X: most-likely group winner / runner-up per group.
  // - 3X/Y/Z composites: candidate group's team with the highest p_third.
  // ONE SLOT PER TEAM across the whole bracket: teams are marked placed as
  // they're assigned, and later picks fall to the next-most-likely candidate
  // (same dedupe principle as the simulator's bracket display — a
  // "most likely" bracket showing the same team twice reads as a bug, not
  // as a marginal probability).
  const seedToTeam = new Map()
  const unresolvedSeeds = []
  if (view === "predicted") {
    const byGroup = new Map()
    for (const r of _wcGroups) {
      if (!byGroup.has(r.group)) byGroup.set(r.group, [])
      byGroup.get(r.group).push(r)
    }
    const placed = new Set()
    for (const [g, rows] of byGroup.entries()) {
      const winner = [...rows].sort((a, b) => b.win_group - a.win_group)[0]
      seedToTeam.set("1" + g, winner?.team || "?")
      if (winner) placed.add(winner.team)
      const runner = [...rows].filter(r => !placed.has(r.team))
        .sort((a, b) => b.runner_up - a.runner_up)[0]
      seedToTeam.set("2" + g, runner?.team || "?")
      if (runner) placed.add(runner.team)
    }
    // Composite third slots: strongest-available-candidate-first greedy, so
    // a team keeps its best slot and collisions fall to the next-best third
    const remaining = wcBracketSeeds.flat().filter(s => s.startsWith("3") && s.includes("/"))
    const bestCandidate = (seed) => seed.slice(1).split("/")
      .flatMap(g => byGroup.get(g) || [])
      .filter(r => r.third != null && !placed.has(r.team))
      .sort((a, b) => b.third - a.third)[0] || null
    while (remaining.length) {
      let bestIdx = -1, best = null
      for (let i = 0; i < remaining.length; i++) {
        const c = bestCandidate(remaining[i])
        if (c && (!best || c.third > best.third)) { best = c; bestIdx = i }
      }
      if (bestIdx === -1) {
        unresolvedSeeds.push(...remaining)
        for (const s of remaining) seedToTeam.set(s, "?")
        break
      }
      const seed = remaining.splice(bestIdx, 1)[0]
      seedToTeam.set(seed, best.team)
      placed.add(best.team)
    }
    if (unresolvedSeeds.length > 0) {
      console.warn("[wc-bracket] no candidates resolved for seeds:", unresolvedSeeds, "(check groups parquet has rows for the candidate groups)")
    }
  }

  function slotLabel(seed) {
    if (view === "empty") return seed
    return seedToTeam.get(seed) || seed
  }

  // SVG dimensions tuned for a 32→1 bracket in 5 columns + champion slot.
  // W accommodates: 5 columns × (170 + 8 gap) = 890, plus a champion box
  // (134 wide + 24 padding) past the Final column = ~1050. Round to 1060.
  const W = 1060, H = 720
  const COL_W = 170, COL_GAP = 8
  const ROUNDS = [
    { name: "R32",   slots: 32, col: 0 },
    { name: "R16",   slots: 16, col: 1 },
    { name: "QF",    slots: 8,  col: 2 },
    { name: "SF",    slots: 4,  col: 3 },
    { name: "Final", slots: 2,  col: 4 }
  ]

  const NS = "http://www.w3.org/2000/svg"
  const svg = document.createElementNS(NS, "svg")
  svg.setAttribute("viewBox", `0 0 ${W} ${H}`)
  svg.setAttribute("class", "wc-bracket-svg")
  svg.style.cssText = "width:100%;height:auto;display:block;max-width:1000px;margin:0.5rem 0"

  const mk = (tag, attrs, text) => {
    const e = document.createElementNS(NS, tag)
    for (const k in attrs) e.setAttribute(k, attrs[k])
    if (text != null) e.textContent = text
    return e
  }

  // Column headers
  for (const r of ROUNDS) {
    const x = r.col * (COL_W + COL_GAP) + COL_W / 2
    svg.appendChild(mk("text", { x, y: 16, "text-anchor": "middle", "font-size": 11, "font-family": "var(--font-family-data, monospace)", fill: "rgba(255,255,255,0.55)" }, r.name))
  }

  // Champion slot label (positioned over the champion box at champX + box-width/2)
  svg.appendChild(mk("text", { x: ROUNDS.length * (COL_W + COL_GAP) + 12 + (COL_W - 30) / 2, y: 16, "text-anchor": "middle", "font-size": 11, "font-family": "var(--font-family-data, monospace)", fill: "rgba(255,255,255,0.55)" }, "Champion"))

  // R32 entries: 32 boxes derived from wcBracketSeeds (16 matches × 2 teams)
  const slotH = (H - 50) / 32
  const SLOT_W = COL_W - 6
  const SLOT_H = Math.max(18, slotH * 0.85)

  function drawSlot(x, y, label, isWinner) {
    const g = mk("g", {})
    g.appendChild(mk("rect", {
      x, y, width: SLOT_W, height: SLOT_H, rx: 3,
      fill: isWinner ? "rgba(90,160,125,0.18)" : "rgba(var(--site-overlay-rgb), 0.05)",
      stroke: isWinner ? "rgba(90,160,125,0.5)" : "rgba(var(--site-overlay-rgb), 0.18)",
      "stroke-width": 0.7
    }))
    // Flag in the predicted view (labels are team names there; the empty
    // view's seed strings have no flag). Cropped to a uniform 16x11 chip.
    const code = view === "predicted" ? window.wcMaps.flag[label] : null
    if (code) {
      g.appendChild(mk("image", {
        href: `https://flagcdn.com/h20/${code}.png`,
        x: x + 6, y: y + (SLOT_H - 11) / 2, width: 16, height: 11,
        preserveAspectRatio: "xMidYMid slice"
      }))
    }
    g.appendChild(mk("text", {
      x: x + (code ? 27 : 8), y: y + SLOT_H / 2 + 3.5,
      "font-size": 10.5, "font-family": "system-ui",
      fill: label === "?" || (view === "empty") ? "rgba(255,255,255,0.4)" : "rgba(255,255,255,0.92)"
    }, label))
    return g
  }

  // Champion-probability lookup (used for "winner-line" highlighting in predicted view)
  const champByTeam = new Map(_wcSimulation.map(t => [t.team, t.p_champ]))

  // Draw R32. One slot entry per BOX (32 total) so the R16-pairing loop
  // can pair boxes 0+1, 2+3, … into 16 R16 winners.
  // Winner-highlight + advance propagation is suppressed when EITHER team
  // is "?" (unresolved composite seed or a teamlabel we couldn't map),
  // so we don't paint a green "advancing" team chosen by tied 0-vs-0
  // champion probabilities.
  const r32X = 0
  const slots = {}
  slots.R32 = []
  for (let i = 0; i < 16; i++) {
    const [seedTop, seedBot] = wcBracketSeeds[i]
    const yBase = 28 + i * (slotH * 2)
    const topY = yBase
    const botY = yBase + slotH
    const teamTop = slotLabel(seedTop)
    const teamBot = slotLabel(seedBot)
    const knownTop = view === "predicted" && teamTop !== "?" && teamTop !== seedTop
    const knownBot = view === "predicted" && teamBot !== "?" && teamBot !== seedBot
    let winnerIdx = -1
    if (knownTop && knownBot) {
      const cTop = champByTeam.get(teamTop) ?? 0
      const cBot = champByTeam.get(teamBot) ?? 0
      winnerIdx = cBot > cTop ? 1 : 0
    } else if (knownTop) {
      winnerIdx = 0
    } else if (knownBot) {
      winnerIdx = 1
    }
    svg.appendChild(drawSlot(r32X + 3, topY, teamTop, winnerIdx === 0))
    svg.appendChild(drawSlot(r32X + 3, botY, teamBot, winnerIdx === 1))
    slots.R32.push({
      x: r32X + 3 + SLOT_W,
      yMid: topY + SLOT_H / 2,
      advance: knownTop ? teamTop : "?"
    })
    slots.R32.push({
      x: r32X + 3 + SLOT_W,
      yMid: botY + SLOT_H / 2,
      advance: knownBot ? teamBot : "?"
    })
  }

  // For each subsequent round, draw slots midway between paired slots from the prior round + connector lines
  const ROUND_KEYS = ["R32", "R16", "QF", "SF", "Final"]
  for (let r = 1; r < ROUNDS.length; r++) {
    const prev = slots[ROUND_KEYS[r - 1]]
    const curr = []
    const colX = r * (COL_W + COL_GAP) + 3
    for (let i = 0; i < ROUNDS[r].slots; i++) {
      const p1 = prev[i * 2]
      const p2 = prev[i * 2 + 1]
      const yMid = (p1.yMid + p2.yMid) / 2 - SLOT_H / 2
      // Determine advancing team for predicted view. Skip the champion-prob
      // tiebreak when either side is "?" — produces a misleading "winner"
      // from a 0-vs-real comparison.
      let team = "?"
      if (view === "predicted") {
        const k1 = p1.advance !== "?"
        const k2 = p2.advance !== "?"
        if (k1 && k2) {
          const c1 = champByTeam.get(p1.advance) ?? 0
          const c2 = champByTeam.get(p2.advance) ?? 0
          team = c2 > c1 ? p2.advance : p1.advance
        } else if (k1) team = p1.advance
        else if (k2) team = p2.advance
      }
      svg.appendChild(drawSlot(colX, yMid, team, team !== "?"))
      // Connector lines from p1 + p2 to this slot
      const connectorX = colX
      svg.appendChild(mk("line", { x1: p1.x, y1: p1.yMid, x2: p1.x + 6, y2: p1.yMid, stroke: "rgba(255,255,255,0.18)", "stroke-width": 0.6 }))
      svg.appendChild(mk("line", { x1: p2.x, y1: p2.yMid, x2: p2.x + 6, y2: p2.yMid, stroke: "rgba(255,255,255,0.18)", "stroke-width": 0.6 }))
      svg.appendChild(mk("line", { x1: p1.x + 6, y1: p1.yMid, x2: p1.x + 6, y2: p2.yMid, stroke: "rgba(255,255,255,0.18)", "stroke-width": 0.6 }))
      svg.appendChild(mk("line", { x1: p1.x + 6, y1: yMid + SLOT_H / 2, x2: connectorX, y2: yMid + SLOT_H / 2, stroke: "rgba(255,255,255,0.18)", "stroke-width": 0.6 }))
      curr.push({ x: colX + SLOT_W, yMid: yMid + SLOT_H / 2, advance: team })
    }
    slots[ROUND_KEYS[r]] = curr
  }

  // Champion slot — single box on the far right
  const final = slots.Final
  const champYMid = (final[0].yMid + final[1].yMid) / 2 - SLOT_H / 2
  let champion = "?"
  if (view === "predicted") {
    const k1 = final[0].advance !== "?"
    const k2 = final[1].advance !== "?"
    if (k1 && k2) {
      const c1 = champByTeam.get(final[0].advance) ?? 0
      const c2 = champByTeam.get(final[1].advance) ?? 0
      champion = c2 > c1 ? final[1].advance : final[0].advance
    } else if (k1) champion = final[0].advance
    else if (k2) champion = final[1].advance
  }
  // Champion box sits AFTER the Final column with a small gap. Previously
  // overlapped the Final slots and overflowed viewBox (W was 920).
  const champX = ROUNDS.length * (COL_W + COL_GAP) + 12
  const champSlot = mk("g", {})
  champSlot.appendChild(mk("rect", {
    x: champX, y: champYMid, width: SLOT_W - 30, height: SLOT_H + 4, rx: 4,
    fill: view === "predicted" ? "rgba(196,115,74,0.25)" : "rgba(var(--site-overlay-rgb), 0.05)",
    stroke: view === "predicted" ? "rgba(196,115,74,0.7)" : "rgba(var(--site-overlay-rgb), 0.25)",
    "stroke-width": 1.2
  }))
  const champCode = view === "predicted" ? window.wcMaps.flag[champion] : null
  if (champCode) {
    champSlot.appendChild(mk("image", {
      href: `https://flagcdn.com/h20/${champCode}.png`,
      x: champX + 8, y: champYMid + (SLOT_H + 4 - 12) / 2, width: 18, height: 12,
      preserveAspectRatio: "xMidYMid slice"
    }))
  }
  champSlot.appendChild(mk("text", {
    x: champX + (champCode ? 32 : 10), y: champYMid + SLOT_H / 2 + 4,
    "font-size": 12, "font-weight": "600", "font-family": "system-ui",
    fill: view === "predicted" ? "rgba(255,255,255,0.96)" : "rgba(255,255,255,0.4)"
  }, "Champion · " + champion))
  svg.appendChild(champSlot)

  const wrap = document.createElement("div")
  const note = document.createElement("p"); note.className = "wcr-cap"
  note.textContent = view === "predicted"
    ? "Each slot shows the likelier team to advance (higher champion probability), on FIFA's official bracket (matches 73–104). Composite third-place slots settle only when the groups finish."
    : "FIFA's 2026 bracket: 12 group winners, 12 runners-up and the 8 best third-placed teams make the round of 32. Toggle to fill it with the most likely advancers."
  wrap.appendChild(note)
  wrap.appendChild(svg)
  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 btn = railBlock("By the numbers")
    btn.appendChild(btnTile(`${sorted[0].p_champ.toFixed(0)}%`, [
      { text: "Title favourite · " }, { text: sorted[0].team, bold: true }
    ]))
    btn.appendChild(btnTile(`${sorted[1].p_champ.toFixed(0)}%`, [
      { text: "2nd favourite · " }, { text: sorted[1].team, bold: true }
    ]))
    btn.appendChild(btnTile("10,000", [
      { text: "Simulated tournaments" }
    ]))
    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">Don't buy it? 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-groups.html"><strong>Group Projections</strong></a><br><span class="text-muted" style="font-size:0.78rem">12 groups · advance %</span>`
  links.appendChild(l1)
  const l2 = document.createElement("div"); l2.style.marginTop = "0.7rem"
  l2.innerHTML = `<a href="world-cup-matches.html"><strong>Match Predictions</strong></a><br><span class="text-muted" style="font-size:0.78rem">72 group-stage fixtures</span>`
  links.appendChild(l2)
  const l3 = document.createElement("div"); l3.style.marginTop = "0.7rem"
  l3.innerHTML = `<a href="world-cup-strength.html"><strong>Team Strength</strong></a><br><span class="text-muted" style="font-size:0.78rem">48 teams across 7 rating systems</span>`
  links.appendChild(l3)
  inner.appendChild(links)

  inner.appendChild(tableSource({
    source: "pannadata",
    sourceUrl: "https://github.com/peteowen1/pannadata",
    sourceNote: "Opta scrape",
    license: "CC BY 4.0",
    asAt: "Live during play",
    hint: "10K-simulation Monte Carlo"
  }))

  return inner
}
 

Pete Owen · Sydney · © 2026 · Source

My Teams | Settings | Photo Credits | Privacy | Disclaimer