World Cup 2026 — Title Race
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
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()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
}