World Cup 2026 — Groups
Football > World Cup 2026 > Groups
Football · World Cup 2026 · Group Stage Projections
Who escapes their group?
Probabilities each team wins, finishes runner-up, or advances out of the group stage, plus every group’s six fixtures with model scorelines. The top two in each of the 12 groups reach the round of 32 automatically; the 8 best third-placed teams join them.
Show code
statsEsc = window.statsEsc
// Group finish probabilities from the LIVE in-browser sim (wc-live-sim.js):
// real + in-progress results baked in, re-run every minute while games are live.
_wcFull = window.wcLiveSim.fullStream()
_wcGroups = window.wcLiveSim.groupsStream()
// The 72 known group fixtures — also the whitelist that keeps knockout
// rematches out of the live group ledger below.
_wcGroupFixtures = {
try { return await window.fetchParquet(window.DATA_BASE_URL + "football/wc2026_predictions.parquet") }
catch (e) { console.error("[wc2026] group fixtures load failed:", e); return null }
}
// Real results from the shared WC fixture feed (wcMaps fetches Worker-first,
// pre-normalizes team names onto _h/_a). NULL means the feed itself is
// unavailable (outage — the live view says so explicitly); [] means the
// feed loaded but carries no WC rows yet ("no results yet" is true copy).
// Rows with a score count — finished + in-play both feed the live ledger.
_wcFixtureResults = {
const rows = await window.wcMaps.fetchWcFixtures()
if (rows == null) return null
return rows.filter(m => m.homeScore != null && m.awayScore != null &&
(m.status === "FINISHED" || window.wcMaps.LIVE_STATUSES.has(m.status)))
}Show code
wcFmtDate = d => {
const m = String(d || "").slice(0, 10).match(/^(\d{4})-(\d{2})-(\d{2})$/)
if (!m) return String(d || "")
const months = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"]
return `${months[+m[2] - 1]} ${+m[3]}`
}
// Live group ledger from real WC results. Keyed by canonical team name —
// feed rows arrive with _h/_a already normalized by fetchWcFixtures, and the
// parquet side runs through wcMaps.normalizeWcTeam so "USA" joins
// "United States". Group games only — a feed row counts only when its UTC
// day + team pair matches one of the 72 known group fixtures in the
// predictions parquet (same `${day}|${home}|${away}` key as the matches
// page). A same-group check alone is NOT enough: from the QF onward a
// knockout tie (and the 3rd-place game) can be an exact rematch of a group
// pairing, which would land in the ledger as a phantom 4th group game. If
// the predictions parquet failed to load, fall back to the weaker same-group
// check rather than zeroing the ledger. Empty Map until results flow.
wcLiveStandings = {
const byTeam = new Map()
if (!_wcFixtureResults || !_wcFixtureResults.length || !_wcGroups || !window.wcMaps) return byTeam
const norm = window.wcMaps.normalizeWcTeam
const teamGroup = new Map(_wcGroups.map(r => [norm(r.team), r.group]))
const fxWhitelist = _wcGroupFixtures == null ? null : new Set(_wcGroupFixtures.map(p =>
`${String(p.match_date || "").replace("Z", "").slice(0, 10)}|${norm(p.home_team)}|${norm(p.away_team)}`))
const ensure = (t) => {
if (!byTeam.has(t)) byTeam.set(t, { p: 0, w: 0, d: 0, l: 0, gf: 0, ga: 0, pts: 0 })
return byTeam.get(t)
}
let matched = 0, unknownTeam = 0, whitelistMiss = 0
for (const f of (_wcFixtureResults ?? [])) {
const h = f._h, a = f._a
const gh = teamGroup.get(h), ga = teamGroup.get(a)
if (!gh || !ga || gh !== ga) { unknownTeam++; continue } // not a recognised group pairing
if (fxWhitelist && !fxWhitelist.has(`${String(f.date || "").slice(0, 10)}|${h}|${a}`)) { whitelistMiss++; continue } // not one of the 72 group fixtures (e.g. a knockout rematch)
matched++
const H = ensure(h), A = ensure(a)
const hs = +f.homeScore, as = +f.awayScore
H.p++; A.p++; H.gf += hs; H.ga += as; A.gf += as; A.ga += hs
if (hs > as) { H.w++; A.l++; H.pts += 3 }
else if (hs < as) { A.w++; H.l++; A.pts += 3 }
else { H.d++; A.d++; H.pts++; A.pts++ }
}
// Individual skips are expected (knockout rows, rematches); ALL rows
// failing to join means name/date drift quietly emptying the ledger —
// which would render the actively-false "No results in yet" copy.
const total = (_wcFixtureResults ?? []).length
const logFn = total > 0 && matched === 0 ? console.warn : console.info
logFn(`[wc-groups] ledger: matched ${matched} of ${total} feed rows (${unknownTeam} unrecognised pairing, ${whitelistMiss} whitelist miss)`)
return byTeam
}
// Played/in-play results keyed by the same day|home|away whitelist key, so
// fixture rows can ink the real score over the model's prediction.
wcResultByKey = {
const m = new Map()
for (const f of (_wcFixtureResults ?? [])) {
m.set(`${String(f.date || "").slice(0, 10)}|${f._h}|${f._a}`, f)
}
return m
}Show code
// Toggle: Projection (model finish odds) vs Live tables (real results ledger)
viewof wcgView = {
const opts = [["predicted", "Projection"], ["results", "Live tables"]]
const wrap = document.createElement("div")
wrap.className = "wcgs-pills"
wrap.value = "predicted"
for (const [val, label] of opts) {
const btn = document.createElement("button")
btn.className = "wcgs-pill" + (val === "predicted" ? " active" : "")
btn.textContent = label
btn.addEventListener("click", () => {
wrap.querySelectorAll(".wcgs-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
}Show code
{
if (_wcGroups == null) return html`<p class="text-muted">Data failed to load — try refreshing (see console for details).</p>`
if (_wcGroups.length === 0) return html`<p class="text-muted">No group data available.</p>`
const maps = window.wcMaps
const norm = maps.normalizeWcTeam
// Feed outage (null — distinct from no-results-yet []): the live ledger is
// unknowable, so fall back to the projection rather than show false zeros.
const outage = wcgView === "results" && _wcFixtureResults === null
const view = outage ? "predicted" : wcgView
const hasResults = wcLiveStandings.size > 0
const SEGS = [
{ key: "win_group", label: "1st", cls: "p1" },
{ key: "runner_up", label: "2nd", cls: "p2" },
{ key: "third", label: "3rd", cls: "p3" },
{ key: "fourth", label: "4th", cls: "p4" }
]
// Index this tournament's fixtures by group (6 round-robin games each).
const fxByGroup = new Map()
if (_wcGroupFixtures) {
for (const f of _wcGroupFixtures) {
if (!fxByGroup.has(f.group)) fxByGroup.set(f.group, [])
fxByGroup.get(f.group).push(f)
}
for (const arr of fxByGroup.values())
arr.sort((a, b) => String(a.match_date || "").localeCompare(String(b.match_date || "")))
}
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 sortedGroups = [...byGroup.keys()].sort()
const wrap = document.createElement("div")
const note = document.createElement("p")
note.className = "wcgs-cap"
if (outage) {
note.textContent = "Live results feed unavailable right now — showing the model's projections instead. Try again in a few minutes."
} else if (view === "results" && !hasResults) {
note.textContent = "No results in yet — tables fill in as final scores arrive (in-play scores count). Each group's six fixtures stay listed below its table."
} else if (view === "results") {
note.textContent = "Real group tables from the live results feed — in-play scores count. Played fixtures below show the real score in place of the model's."
} else {
note.innerHTML = `<span class="wcgs-legend"><span><i class="wcgs-sw p1"></i>1st</span><span><i class="wcgs-sw p2"></i>2nd</span><span><i class="wcgs-sw p3"></i>3rd</span><span><i class="wcgs-sw p4"></i>4th</span></span> Each bar stacks a team's finish-position chances from the 10,000-tournament sim; the number on the right is its chance of a top-two finish (the eight best third-placed teams also reach the round of 32). Group headers open the deep dive.`
}
wrap.appendChild(note)
const grid = document.createElement("div")
grid.className = "wc-group-grid"
for (const g of sortedGroups) {
const card = document.createElement("div")
card.className = "wc-group-card"
let inner = `<a class="wc-group-header" href="world-cup-group.html#group=${statsEsc(g)}" title="Group ${statsEsc(g)} deep dive">Group ${statsEsc(g)} <span class="wc-group-header-arrow">→</span></a>`
if (view === "predicted") {
// Sorted by advance% — the page's headline metric (top 2 auto-qualify).
const teams = byGroup.get(g).sort((a, b) => b.advance - a.advance)
inner += `<div class="wc-group-teams">`
for (let i = 0; i < teams.length; i++) {
const t = teams[i]
const segs = SEGS.map(s => {
const v = Math.max(0, t[s.key] || 0)
const lbl = v >= 22 ? v.toFixed(0) : ""
return `<div class="wcgs-seg ${s.cls}" style="flex:${v}" title="${s.label}: ${v.toFixed(0)}%">${lbl}</div>`
}).join("")
const qual = i < 2 ? " is-qual" : "" // top 2 reach the round of 32
inner += `<div class="wc-group-row${qual}" title="${statsEsc(t.team)} — win group ${t.win_group.toFixed(0)}% · runner-up ${t.runner_up.toFixed(0)}% · third ${t.third.toFixed(0)}% · fourth ${t.fourth.toFixed(0)}% · advance ${t.advance.toFixed(0)}%">
<span class="wc-group-team">${maps.teamLinkHtml(t.team)}</span>
<div class="wcgs-stack">${segs}</div>
<span class="wc-group-pct">${t.advance.toFixed(0)}%</span>
</div>`
}
inner += `</div>`
} else {
// Live view: attach the ledger (zeros for unplayed teams) and sort by
// points, then GD, GF, name — the real group-table order.
const teams = [...byGroup.get(g)].map(t => ({
...t,
_s: wcLiveStandings.get(norm(t.team)) || { p: 0, w: 0, d: 0, l: 0, gf: 0, ga: 0, pts: 0 }
}))
teams.sort((a, b) =>
b._s.pts - a._s.pts ||
(b._s.gf - b._s.ga) - (a._s.gf - a._s.ga) ||
b._s.gf - a._s.gf ||
a.team.localeCompare(b.team))
inner += `<table class="wcgs-live"><thead><tr><th>Team</th><th>P</th><th>W</th><th>D</th><th>L</th><th>GD</th><th>Pts</th></tr></thead><tbody>`
for (let i = 0; i < teams.length; i++) {
const s = teams[i]._s
const gd = s.gf - s.ga
inner += `<tr class="${i < 2 ? "is-qual" : ""}">
<td class="wcgs-live-team">${maps.teamLinkHtml(teams[i].team)}</td>
<td>${s.p}</td><td>${s.w}</td><td>${s.d}</td><td>${s.l}</td>
<td>${gd > 0 ? "+" : ""}${gd}</td><td class="wcgs-live-pts">${s.pts}</td>
</tr>`
}
inner += `</tbody></table>`
}
const fx = fxByGroup.get(g) || []
if (fx.length) {
inner += `<div class="wc-group-fxlabel">Fixtures · model scorelines, real scores once played</div><div class="wc-group-fx">`
for (const f of fx) {
// same null-hardening as the predicted-goals line below — a missing
// prob renders "?" in the title, not "NaN"
const pct = (p) => Number.isFinite(p) ? (p * 100).toFixed(0) : "?"
const pH = pct(f.prob_home), pD = pct(f.prob_draw), pA = pct(f.prob_away)
const sc = `${f.pred_home_goals?.toFixed(1) ?? "?"}–${f.pred_away_goals?.toFixed(1) ?? "?"}`
const key = `${String(f.match_date || "").replace("Z", "").slice(0, 10)}|${norm(f.home_team)}|${norm(f.away_team)}`
const res = wcResultByKey.get(key)
const live = res && maps.LIVE_STATUSES.has(res.status)
const scoreHtml = res
? `<span class="wc-fx-final" title="Model predicted ${sc}">${res.homeScore}–${res.awayScore}${live ? `<i class="wc-fx-livetag">live</i>` : ""}</span>`
: `<span class="wc-fx-score" title="Model's predicted goals">${sc}</span>`
inner += `<div class="wc-fx-row">
<span class="wc-fx-date">${wcFmtDate(f.match_date)}</span>
<span class="wc-fx-match">${maps.teamLinkHtml(f.home_team, { flag: false })} <i>v</i> ${maps.teamLinkHtml(f.away_team, { flag: false })}</span>
${scoreHtml}
<span class="wc-fx-bar">${maps.probBarHtml(f.prob_home, f.prob_draw, f.prob_away, { title: `${f.home_team} ${pH}% · Draw ${pD}% · ${f.away_team} ${pA}%` })}</span>
</div>`
}
inner += `</div>`
}
card.innerHTML = inner
grid.appendChild(card)
}
wrap.appendChild(grid)
return wrap
}Show code
// ── The third-place race ──────────────────────────────────────────
// Eight of the twelve third-placed teams advance. From the live sim, per team:
// P(finish 3rd), P(qualify AS a best-8 third) = advance − P(top two), and the
// conditional P(advance | finish 3rd). Sortable; real third-place candidates only.
{
if (_wcGroups == null || !_wcGroups.length) return html``
const wc = window.wcMaps
const rows = _wcGroups.map(t => {
const p3 = t.third ?? 0
const qual3 = Math.max(0, (t.advance ?? 0) - (t.win_group ?? 0) - (t.runner_up ?? 0))
return { team: t.team, group: t.group, p3, qual3, cond: p3 > 0 ? qual3 / p3 * 100 : 0 }
}).filter(r => r.p3 >= 5).sort((a, b) => b.qual3 - a.qual3)
if (!rows.length) return html``
const wrap = document.createElement("div")
wrap.style.marginTop = "0.5rem"
const head = document.createElement("div")
head.style.cssText = "display:flex;align-items:center;gap:0.6rem;flex-wrap:wrap"
const h = document.createElement("h2"); h.textContent = "The third-place race"
head.append(h, 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.84rem;margin:0 0 0.6rem;max-width:110ch"
note.innerHTML = `The <b>eight best</b> of the twelve third-placed teams also reach the Round of 32. <b>3rd%</b> is the chance of finishing third; <b>Qualify as 3rd</b> is the chance of grabbing one of those eight best-third spots; <b>If 3rd</b> is the conditional — how often a third-place finish is good enough. Teams with at least a 5% chance of finishing third, most likely qualifier first.`
wrap.appendChild(note)
const table = window.statsTable(rows, {
columns: ["team", "group", "p3", "qual3", "cond"],
header: { team: "Team", group: "Grp", p3: "3rd%", qual3: "Qualify as 3rd", cond: "If 3rd" },
tooltip: {
p3: "Probability the team finishes third in its group",
qual3: "Probability of finishing third AND being one of the eight best thirds that advance",
cond: "If they finish third, how often that is good enough to advance"
},
format: { p3: x => x.toFixed(0) + "%", qual3: x => x.toFixed(0) + "%", cond: x => x.toFixed(0) + "%" },
render: { team: (v) => wc.teamLinkHtml(v) },
heatmap: { p3: "high-good", qual3: "high-good", cond: "high-good" },
sort: "qual3", reverse: true, rows: 16
})
wrap.appendChild(table)
return wrap
}Show code
{
const inner = document.createElement("div")
inner.className = "side-rail-inner"
const { railBlock, btnTile, tableSource } = window.editorial
if (_wcGroups && _wcGroups.length > 0) {
const byAdv = [..._wcGroups].sort((a, b) => b.advance - a.advance)
const btn = railBlock("By the numbers")
btn.appendChild(btnTile(`${byAdv[0].advance.toFixed(0)}%`, [
{ text: "Most likely to advance · " }, { text: byAdv[0].team, bold: true }
]))
const tightest = [..._wcGroups]
.reduce((acc, r) => {
if (!acc[r.group]) acc[r.group] = []
acc[r.group].push(r.advance)
return acc
}, {})
let tightestG = "", tightestSpread = 100
for (const [g, advs] of Object.entries(tightest)) {
const spread = Math.max(...advs) - Math.min(...advs)
if (spread < tightestSpread) { tightestSpread = spread; tightestG = g }
}
btn.appendChild(btnTile("Group " + tightestG, [
{ text: "Tightest group · " }, { text: `${tightestSpread.toFixed(0)}pp spread`, bold: true }
]))
btn.appendChild(btnTile("12", [{ text: "Groups · 4 teams each" }]))
inner.appendChild(btn)
}
const links = railBlock("Read next")
const l1 = document.createElement("div"); 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-probability leaderboard</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">Match cards for all 72 fixtures</span>`
links.appendChild(l2)
const l3 = document.createElement("div"); l3.style.marginTop = "0.7rem"
l3.innerHTML = `<a href="world-cup-wallchart.html"><strong>Wall Chart</strong></a><br><span class="text-muted" style="font-size:0.78rem">The whole tournament on one poster</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
}