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 — Groups

Skip to content

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
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><a href="world-cup-group.html#group=A">Group deep dives &rarr;</a></span>
</div>`
Show code
window.editorial.sidebarToggle()
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">&rarr;</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
}
 

Pete Owen · Sydney · © 2026 · Source

My Teams | Settings | Photo Credits | Privacy | Disclaimer