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

Skip to content

Football > World Cup 2026 > Simulator

Football · World Cup 2026 · Interactive Simulator

What if? Run the tournament yourself

Every click re-runs 10,000 tournaments in your browser (5,000 on slower devices). Lock a group result (home / draw / away) or force a team through a knockout round, and every probability on the page — group standings, stage odds, likely matchups — recomputes with your results fixed.

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-title-race.html">Title Race &rarr;</a></span>
</div>`

Group stage: click 1 / X / 2 next to a fixture to lock home win / draw / away win — click again to unlock. Finished games arrive already locked to their real result; unlock or override them to explore what-ifs. Knockout: click a team in the bracket to force them through that round. The simulator re-runs the full tournament with your results fixed and updates every number. Scorelines are drawn from the model's predicted goals; knockout ties use the published simulation's own pairwise probabilities (all 1,128 possible ties, with Tiento as the fallback), and teams run slightly hot — beating the model's expected scoreline in the groups warms a side's knockout strength (underperforming cools it), and knockout winners keep heating. Baselines track the published Title Race simulation to within a couple of points.

Show code
statsEsc = window.statsEsc

_wcsPreds = {
  try { return await window.fetchParquet(window.DATA_BASE_URL + "football/wc2026_predictions.parquet") }
  catch (e) { console.error("[wc-sim] predictions load failed:", e); return null }
}
_wcsStrength = {
  try { return await window.fetchParquet(window.DATA_BASE_URL + "football/wc2026_team_strength.parquet") }
  catch (e) { console.error("[wc-sim] team_strength load failed:", e); return null }
}
// Official pipeline's full-model pairwise knockout probabilities (1,128
// ties, ~30KB). Optional: null falls back to the Tiento logistic in-engine.
_wcsKoProbs = {
  try { return await window.fetchParquet(window.DATA_BASE_URL + "football/wc2026_knockout_probs.parquet") }
  catch (e) { console.warn("[wc-sim] knockout-probs load failed — Tiento fallback:", e); return null }
}
// Live feed for auto-locking finished results into the sim. Contract from
// wc-maps: null = feed outage (simulator still works, just without reality
// baked in — warned in-console by fetchWcFixtures), [] = no WC rows yet.
_wcsFeed = {
  if (!window.wcMaps) { console.error("[wc-sim] wcMaps missing — no real-result locks"); return null }
  return await window.wcMaps.fetchWcFixtures()
}
Show code
wcsModel = {
  if (_wcsPreds == null || _wcsStrength == null) return null
  const fixtures = [..._wcsPreds]
    .sort((a, b) => String(a.match_date).localeCompare(String(b.match_date)))
    .map(p => ({
      group: p.group, home: p.home_team, away: p.away_team,
      pH: p.prob_home, pD: p.prob_draw, pA: p.prob_away,
      gH: p.pred_home_goals, gA: p.pred_away_goals,
      date: String(p.match_date || "").replace("Z", "").slice(0, 10)
    }))
  // Knockout ties use the official pipeline's pairwise lookup when it loads
  // (passed as koProbs below); Tiento strengths remain the per-tie fallback:
  // Tiento (goals above average, z-blend of panna/EPR/PSR/Elo) scaled by the
  // calibrated win-prob slope, so the engine's logistic reproduces the match
  // model's own win probabilities at this rating gap.
  const agg = window.wcMaps.computeTeamRating(_wcsStrength, _wcsPreds)
  if (!agg) console.warn("[wc-sim] Tiento rating unavailable — knockout fallback uses raw BT strengths")
  const strengths = agg
    ? new Map([...agg.ratings].map(([t, r]) => [t, r * agg.winSlope]))
    : new Map(_wcsStrength.map(t => [t.team, t.bt]))
  return window.wcSim.buildModel({ fixtures, strengths, seeds: window.wcMaps.r32Seeds, koProbs: _wcsKoProbs })
}
Show code
// ── The simulator app ─────────────────────────────────────────
{
  // wcsModel is null ONLY after a loader failed (OJS doesn't run this cell
  // while the parquet fetches are still pending)
  if (wcsModel == null) return html`<p class="text-muted">Data failed to load — try refreshing (see console for details).</p>`
  const maps = window.wcMaps
  const esc = maps.esc
  const KEY = "ig-wc-sim-locks-v1"
  const ROUND_NAMES = ["Round of 32", "Round of 16", "Quarter-finals", "Semi-finals", "Final"]
  const GROUPS = [...wcsModel.groups.keys()].sort()
  const fmtDay = maps.fmtDay

  // ── Real results (auto-locks) ──
  // FINISHED games lock to their actual outcome by default, so the baseline
  // is "reality so far + simulation of the rest". Clicking a finished game's
  // active outcome unlocks it (stored as the "OPEN" sentinel) for
  // counterfactuals; clicking another outcome rewrites history outright.
  // LIVE games show their score but never auto-lock (still in progress).
  const realResults = new Map() // fixture key -> {outcome, hs, as, finished, live}
  if (_wcsFeed) {
    const byKey = new Map(_wcsFeed
      .filter(m => m.homeScore != null && m.awayScore != null)
      .map(m => [`${String(m.date || "").slice(0, 10)}|${m._h}|${m._a}`, m]))
    for (const f of wcsModel.fixtures) {
      const m = byKey.get(`${f.date}|${wcsModel.teams[f.h]}|${wcsModel.teams[f.a]}`)
      if (!m) continue
      const finished = m.status === "FINISHED"
      const live = maps.LIVE_STATUSES.has(m.status)
      if (!finished && !live) continue
      realResults.set(f.key, {
        outcome: m.homeScore > m.awayScore ? "H" : m.homeScore < m.awayScore ? "A" : "D",
        hs: m.homeScore, as: m.awayScore, finished, live, date: m.date, status: m.status,
        minute: m.minute
      })
    }
  }
  const realLockFor = (key) => {
    const r = realResults.get(key)
    return r && r.finished ? r.outcome : null
  }

  // Live in-game odds: for IN-PROGRESS games, override the model's pre-match
  // H/D/A with a score+time-aware estimate (quality-aware footballWinProb), so
  // the simulator's baseline matches the live title-race / group pages. PREFER
  // the real Opta match minute from the feed (r.minute) — the wall-clock estimate
  // from kickoff is unreliable (variable halftime, stoppage, kickoff delays): it
  // over-runs, clamps to 95', and footballWinProb then treats any live game as
  // decided (100% to whoever leads). The Opta minute is the live truth — the same
  // source wc-live-sim.js prefers (its kickoff estimate is the fallback too). Only
  // the probabilities are overridden — pred goals (gH/gA) stay pre-match so the
  // displayed "predicted" scoreline is unchanged. No auto-tick here: the simulator
  // is a what-if tool, so it refreshes on the next interaction.
  for (const f of wcsModel.fixtures) {
    const r = realResults.get(f.key)
    if (!r || !r.live || !window.footballWinProb) continue
    let min
    if (typeof r.minute === "number" && r.minute > 0) min = r.minute
    else if (r.status === "PAUSED") min = 47
    else {
      const t = Date.parse(r.date)
      const e = Number.isFinite(t) ? (Date.now() - t) / 60000 : 1
      min = Math.max(1, Math.min(95, e < 47 ? e : e - 15))
    }
    const wp = window.footballWinProb(min, r.hs, r.as, f.gH, f.gA)
    f.pH = wp.home; f.pD = wp.draw; f.pA = wp.away
  }

  // ── State ──
  // locks.match values: "H"/"D"/"A" = user lock (a what-if when it differs
  // from reality), "OPEN" = user explicitly unlocked a real result.
  let locks = { match: {}, ko: {} }
  try {
    const s = JSON.parse(localStorage.getItem(KEY) || "null")
    if (s && s.match && s.ko) {
      const validKeys = new Set(wcsModel.fixtures.map(f => f.key))
      for (const [k, v] of Object.entries(s.match)) if (validKeys.has(k) && ["H","D","A","OPEN"].includes(v)) locks.match[k] = v
      for (const [t, r] of Object.entries(s.ko)) if (wcsModel.teamIdx.has(t) && r >= 0 && r <= 4) locks.ko[t] = r
    }
  } catch (e) { /* fresh start */ }
  const save = () => { try { localStorage.setItem(KEY, JSON.stringify(locks)) } catch (e) {} }

  // Effective lock per fixture: user override wins; OPEN suppresses reality.
  const effLock = (key) => {
    const u = locks.match[key]
    if (u === "OPEN") return null
    return u ?? realLockFor(key)
  }
  const effectiveLocks = () => {
    const match = {}
    for (const f of wcsModel.fixtures) { const v = effLock(f.key); if (v) match[f.key] = v }
    return { match, ko: locks.ko }
  }
  const realOnlyLocks = () => {
    const match = {}
    for (const [k, r] of realResults) if (r.finished) match[k] = r.outcome
    return { match, ko: {} }
  }
  const whatIfCount = () => Object.keys(locks.match).length + Object.keys(locks.ko).length
  const realCount = () => [...realResults.values()].filter(r => r.finished).length

  let nSims = 10000
  let selGroup = "A"
  let baseline = null, results = null, lastMs = 0, simming = true

  // ── Skeleton ──
  const wrap = document.createElement("div"); wrap.className = "wcs-wrap"
  const bar = document.createElement("div"); bar.className = "wcs-bar"
  const groupSec = document.createElement("div"); groupSec.className = "wcs-section"
  const koSec = document.createElement("div"); koSec.className = "wcs-section"
  const tableSec = document.createElement("div"); tableSec.className = "wcs-section"
  const muSec = document.createElement("div"); muSec.className = "wcs-section"
  const ptsSec = document.createElement("div"); ptsSec.className = "wcs-section"
  // ptsSec sits right under the groups: qualification-by-points reads as
  // part of the group story (it follows the group pills), not an appendix
  wrap.append(bar, groupSec, ptsSec, koSec, tableSec, muSec)

  // ── Sim loop (debounced) ──
  let timer = null
  function queueSim() {
    simming = true
    renderBar()
    clearTimeout(timer)
    timer = setTimeout(run, 90)
  }
  function run() {
    // Baseline = reality only (finished results locked, no what-ifs) — so
    // the Δ Champ column reads "your what-ifs vs how things actually stand".
    if (!baseline) baseline = window.wcSim.simulate(wcsModel, realOnlyLocks(), { nSims })
    // Time ONLY the conditional pass — measuring the baseline pass too would
    // double-count on first load (e.g. locks restored from localStorage) and
    // spuriously trip the slow-device fallback on capable phones.
    const t0 = performance.now()
    results = whatIfCount() === 0 ? baseline : window.wcSim.simulate(wcsModel, effectiveLocks(), { nSims })
    lastMs = performance.now() - t0
    // Slow-device fallback: halve the sim count once if a run crawls, then
    // IMMEDIATELY recompute baseline + results at the new count — rendering
    // with a cleared baseline would zero the Δ Champ column.
    if (lastMs > 1600 && nSims > 5000) {
      nSims = 5000
      baseline = null
      return run()
    }
    simming = false
    render()
  }

  // ── Interactions ──
  function toggleMatchLock(key, outcome) {
    const real = realLockFor(key)
    const cur = effLock(key)
    if (cur === outcome) {
      // unlock: real results need the explicit OPEN sentinel, user locks
      // just clear
      if (real) locks.match[key] = "OPEN"
      else delete locks.match[key]
    } else if (real && outcome === real) {
      // picking reality's own outcome = drop the override, back to real
      delete locks.match[key]
    } else {
      locks.match[key] = outcome
    }
    save(); renderGroups(); queueSim()
  }
  function toggleKoLock(team, r) {
    const cur = locks.ko[team]
    if (cur != null && cur >= r) {
      if (r === 0) delete locks.ko[team]
      else locks.ko[team] = r - 1
    } else locks.ko[team] = r
    save(); queueSim()
  }
  function resetAll() {
    locks = { match: {}, ko: {} }
    save(); renderGroups(); queueSim()
  }

  // ── Renders ──
  function renderBar() {
    bar.replaceChildren()
    const status = document.createElement("div"); status.className = "wcs-status"
    const w = whatIfCount(), rc = realCount()
    const realBit = rc > 0 ? `<b>${rc}</b> real result${rc === 1 ? "" : "s"} · ` : ""
    status.innerHTML = simming
      ? `<span class="wcs-spin"></span> Re-simulating ${nSims.toLocaleString()} tournaments…`
      : `${realBit}<b>${w}</b> what-if${w === 1 ? "" : "s"} · ${nSims.toLocaleString()} tournaments in <b>${lastMs.toFixed(0)}ms</b>`
    const reset = document.createElement("button")
    reset.className = "wcs-btn"
    reset.textContent = rc > 0 ? "Clear what-ifs" : "Clear all locks"
    reset.disabled = w === 0
    reset.onclick = resetAll
    bar.append(status, reset)
  }

  function renderGroups() {
    groupSec.replaceChildren()
    const h = document.createElement("h2"); h.textContent = "Group stage — call the results"
    groupSec.appendChild(h)
    if (realCount() > 0) {
      const cap = document.createElement("p"); cap.className = "wcs-cap"
      cap.textContent = "Finished games arrive locked to their real result. Click the active outcome to unlock it, or another outcome to rewrite history — the whole tournament re-simulates either way."
      groupSec.appendChild(cap)
    }

    const pills = document.createElement("div"); pills.className = "wcs-pills"
    for (const g of GROUPS) {
      const nLocked = wcsModel.fixtures.filter(f => f.group === g && effLock(f.key)).length
      const b = document.createElement("button")
      b.className = "wcs-pill" + (g === selGroup ? " active" : "")
      b.innerHTML = `Group ${g}` + (nLocked ? `<span class="wcs-pill-n">${nLocked}</span>` : "")
      b.onclick = () => { selGroup = g; renderGroups(); renderPoints() }
      pills.appendChild(b)
    }
    groupSec.appendChild(pills)

    const row = document.createElement("div"); row.className = "wcs-group-row"
    const list = document.createElement("div"); list.className = "wcs-fx-list"
    for (const f of wcsModel.fixtures.filter(f => f.group === selGroup)) {
      const home = wcsModel.teams[f.h], away = wcsModel.teams[f.a]
      const real = realResults.get(f.key)
      const lock = effLock(f.key)
      // active source = reality (no user override) → styled differently
      const isReal = !!(real && real.finished && locks.match[f.key] == null)
      const row = document.createElement("div")
      row.className = "wcs-fx" + (lock ? " locked" : "") + (isReal ? " real" : "")
      const pct = (x) => (x * 100).toFixed(0)
      const predTxt = `${f.gH?.toFixed(1) ?? "?"}–${f.gA?.toFixed(1) ?? "?"}`
      const scoreHtml = real
        ? `<span class="wcs-fx-score wcs-fx-final" title="Model predicted ${predTxt}">${real.hs}–${real.as}${real.live ? `<i class="wcs-fx-tag live">live</i>` : `<i class="wcs-fx-tag">FT</i>`}</span>`
        : `<span class="wcs-fx-score" title="Predicted goals">${predTxt}</span>`
      const btnTitle = (o, label) => {
        const rl = realLockFor(f.key)
        if (rl === o) return lock === o && isReal ? `Final result — click to unlock for what-ifs` : `Restore the real result`
        if (rl) return `What-if: override the real result — ${label}`
        return `Lock: ${label}`
      }
      row.innerHTML = `
        <span class="wcs-fx-date">${fmtDay(f.date)}</span>
        <span class="wcs-fx-team wcs-fx-home">${maps.teamLinkHtml(home)}</span>
        ${scoreHtml}
        <span class="wcs-fx-team wcs-fx-away">${maps.teamLinkHtml(away)}</span>
        <span class="wcs-fx-bar">${maps.probBarHtml(f.pH, f.pD, f.pA, { title: `${home} ${pct(f.pH)}% · Draw ${pct(f.pD)}% · ${away} ${pct(f.pA)}%` })}</span>
        <span class="wcs-fx-locks">
          <button class="wcs-lock-btn${lock === "H" ? " on" : ""}" data-o="H" title="${esc(btnTitle("H", `${home} win`))}">1</button>
          <button class="wcs-lock-btn${lock === "D" ? " on" : ""}" data-o="D" title="${esc(btnTitle("D", "draw"))}">X</button>
          <button class="wcs-lock-btn${lock === "A" ? " on" : ""}" data-o="A" title="${esc(btnTitle("A", `${away} win`))}">2</button>
        </span>`
      row.querySelectorAll(".wcs-lock-btn").forEach(btn => {
        btn.onclick = () => toggleMatchLock(f.key, btn.dataset.o)
      })
      list.appendChild(row)
    }
    row.appendChild(list)

    // Projected group table — expected finishing order under the current
    // locks, from the same sim that drives everything else.
    if (results) {
      const ptsBy = new Map(results.pointsTable.filter(t => t.group === selGroup).map(t => [t.team, t.rows]))
      const teams = results.teams
        .filter(t => t.group === selGroup)
        .map(t => ({
          ...t,
          // a missing pointsTable key would otherwise print a confident 0.0
          xPts: (ptsBy.get(t.team) ?? (console.warn("[wc-sim] no pointsTable rows for", t.team), []))
            .reduce((s, r) => s + r.points * r.n, 0) / results.nSims,
          expPos: (1 * t.p_win_group + 2 * t.p_runner_up + 3 * t.p_third + 4 * t.p_fourth) / 100
        }))
        .sort((a, b) => a.expPos - b.expPos)
      const tbl = document.createElement("div"); tbl.className = "wcs-group-table"
      tbl.innerHTML = `
        <div class="wcs-gt-cap">Projected table — given your locked results</div>
        <table>
          <thead><tr><th></th><th>Team</th><th>xPts</th><th>1st</th><th>Adv</th></tr></thead>
          <tbody>${teams.map((t, i) => `
            <tr>
              <td class="wcs-gt-pos">${i + 1}</td>
              <td class="wcs-gt-team">${maps.teamLinkHtml(t.team)}</td>
              <td>${t.xPts.toFixed(1)}</td>
              <td>${t.p_win_group.toFixed(0)}%</td>
              <td class="wcs-gt-adv">${t.p_R32.toFixed(0)}%</td>
            </tr>`).join("")}
          </tbody>
        </table>`
      row.appendChild(tbl)
    }
    groupSec.appendChild(row)
  }

  function renderKo() {
    koSec.replaceChildren()
    if (!results) return
    const h = document.createElement("h2"); h.textContent = "Knockout — force a team through"
    const cap = document.createElement("p"); cap.className = "wcs-cap"
    cap.textContent = "Each slot shows its most likely team and how often they land there — one appearance per round (hover for the full shortlist). Click a team to lock them through that round; a lock only applies when the team actually reaches the tie, so locked teams can still show under 100%. If both sides are locked, the deeper lock wins."
    koSec.append(h, cap)

    // FIFA match metadata per bracket slot: r32Seeds is in bracket order, so
    // tie i of round r+1 is fed by ties 2i/2i+1 of round r — match numbers
    // resolve by walking wcMaps.koSchedule's "W74 v W77" pair strings up the
    // tree. Gives every tie its locked date + venue (teams TBD, timing not).
    const koMeta = (() => {
      const ks = maps.koSchedule || []
      const r32 = maps.r32Seeds.map(([a, b]) => {
        return ks.find(k => k.round === "Round of 32" &&
          (k.pair === `${a} v ${b}` || (b.includes("/") && k.pair === `${a} v 3rd`))) || null
      })
      const rounds = [r32]
      let prev = r32
      for (const n of [8, 4, 2, 1]) {
        const cur = []
        for (let i = 0; i < n; i++) {
          const a = prev[2 * i]?.m, b = prev[2 * i + 1]?.m
          cur.push(ks.find(k => k.pair === `W${a} v W${b}` || k.pair === `W${b} v W${a}`) || null)
        }
        rounds.push(cur); prev = cur
      }
      // Both inputs are static in-repo data, so any miss is hand-edit drift —
      // and one R32 mismatch nulls its whole subtree up to the final. Today
      // this resolves 31/31; a warn here is always a true positive.
      const missing = rounds.flat().filter(x => !x).length
      if (missing) console.warn("[wc-sim] koMeta:", missing, "of 31 ties failed to resolve against wcMaps.koSchedule — pair-string drift?")
      return rounds
    })()
    const fmtKoDay = (day) => {
      const [y, mo, da] = day.split("-").map(Number)
      return new Date(Date.UTC(y, mo - 1, da, 12)).toLocaleDateString(undefined, { month: "short", day: "numeric" })
    }

    const board = document.createElement("div"); board.className = "wcs-board"
    for (let r = 0; r < 5; r++) {
      const col = document.createElement("div"); col.className = "wcs-col"
      const head = document.createElement("div"); head.className = "wcs-col-head"; head.textContent = ROUND_NAMES[r]
      col.appendChild(head)
      const ties = document.createElement("div"); ties.className = "wcs-ties"
      // Per-round dedupe: each slot carries its top-4 occupants (wc-sim.js);
      // a team that tops several same-round slots via different qualification
      // routes is shown only in its strongest slot, and the others fall to
      // their next-most-likely team. Every % shown is that team's TRUE
      // marginal reach probability — dedupe changes which team is shown,
      // never the number next to it. Hover shows the slot's real shortlist.
      const flat = []
      results.bracket[r].forEach((tie, ti) => tie.forEach((slot, si) => {
        if (slot && slot.length) flat.push({ ti, si, tops: slot })
      }))
      flat.sort((a, b) => b.tops[0].pct - a.tops[0].pct)
      const used = new Set()
      const chosen = results.bracket[r].map(tie => tie.map(() => null))
      for (const s of flat) {
        // all four candidates already shown (rare, but heavy locking makes it
        // likelier): show the favourite anyway rather than invent a fifth —
        // and log it, since the caption's one-per-round promise breaks here
        const avail = s.tops.find(o => !used.has(o.team))
        if (!avail) console.warn("[wc-sim] bracket dedupe exhausted candidates for round", r, "tie", s.ti, "— showing duplicate", s.tops[0].team)
        const pick = avail || s.tops[0]
        used.add(pick.team)
        chosen[s.ti][s.si] = { pick, tops: s.tops }
      }
      results.bracket[r].forEach((tie, i) => {
        const box = document.createElement("div"); box.className = "wcs-tie"
        const meta = koMeta[r] && koMeta[r][i]
        if (meta) {
          const cap2 = document.createElement("div")
          cap2.className = "wcs-tie-meta"
          const stadium = meta.key.split(",")[0]
          cap2.textContent = `${fmtKoDay(meta.day)} · ${stadium}`
          cap2.title = `FIFA match ${meta.m} · ${meta.key}`
          box.appendChild(cap2)
        }
        tie.forEach((slot, si) => {
          const b = document.createElement("button")
          const c = chosen[i][si]
          if (!c) { b.className = "wcs-slot tbd"; b.disabled = true; b.textContent = "—" }
          else {
            const team = c.pick.team, pct = c.pick.pct
            const lockedHere = (locks.ko[team] ?? -1) >= r
            b.className = "wcs-slot" + (lockedHere ? " locked" : "")
            b.innerHTML = maps.flagImg(team) +
              `<span class="wcs-slot-name">${esc(team)}</span>` +
              `<span class="wcs-slot-pct">${pct.toFixed(0)}%</span>` +
              (lockedHere ? `<span class="wcs-slot-lock">locked</span>` : "")
            const shortlist = c.tops.slice(0, 3).map(o => `${o.team} ${o.pct.toFixed(0)}%`).join(" · ")
            b.title = (lockedHere
              ? `${team}: locked to win this round — click to unlock`
              : `${team} is here in ${pct.toFixed(0)}% of sims — click to lock them through this round`) +
              `\nMost likely here: ${shortlist}`
            b.onclick = () => toggleKoLock(team, r)
          }
          box.appendChild(b)
        })
        ties.appendChild(box)
      })
      col.appendChild(ties)
      board.appendChild(col)
    }
    // Champion column (display only)
    const champCol = document.createElement("div"); champCol.className = "wcs-col wcs-champ-col"
    const ch = document.createElement("div"); ch.className = "wcs-col-head"; ch.textContent = "Champion"
    champCol.appendChild(ch)
    const top = [...results.teams].sort((a, b) => b.p_champ - a.p_champ)[0]
    const tile = document.createElement("div"); tile.className = "wcs-champ"
    tile.innerHTML = maps.flagImg(top.team) +
      `<div class="wcs-champ-name">${esc(top.team)}</div>` +
      `<div class="wcs-champ-pct">${top.p_champ.toFixed(1)}%</div>`
    champCol.appendChild(tile)
    board.appendChild(champCol)
    koSec.appendChild(board)
  }

  function renderTable() {
    tableSec.replaceChildren()
    if (!results) return
    const h = document.createElement("h2"); h.textContent = "Every team's path, given your results"
    const cap = document.createElement("p"); cap.className = "wcs-cap"
    cap.textContent = "Probability of reaching each stage under your locked results. The last column is the swing in title odds versus the real-results baseline (no what-ifs)."
    tableSec.append(h, cap)

    // Δ vs baseline derived directly from the baseline run — no separate
    // state to keep synchronized when nSims or baseline changes.
    const baseChamp = new Map((baseline?.teams ?? []).map(t => [t.team, t.p_champ]))
    const rows = results.teams.map(t => ({
      team: t.team, group: t.group,
      p_win_group: t.p_win_group, p_R32: t.p_R32, p_R16: t.p_R16,
      p_QF: t.p_QF, p_SF: t.p_SF, p_final: t.p_final, p_champ: t.p_champ,
      d_champ: t.p_champ - (baseChamp.get(t.team) ?? t.p_champ)
    }))
    const pct1 = x => x == null ? "" : x.toFixed(1) + "%"
    const signed = x => x == null ? "" : (x > 0 ? "+" : x < 0 ? "−" : "") + Math.abs(x).toFixed(1)
    const table = window.statsTable(rows, {
      columns: ["team", "group", "p_win_group", "p_R32", "p_R16", "p_QF", "p_SF", "p_final", "p_champ", "d_champ"],
      header: {
        team: "Team", group: "Grp", p_win_group: "Win grp",
        p_R32: "R32", p_R16: "R16", p_QF: "QF", p_SF: "SF",
        p_final: "Final", p_champ: "Champ", d_champ: "Δ Champ"
      },
      groups: [
        { label: "", span: 2 },
        { label: "Reaches stage (%)", span: 7 },
        { label: "vs baseline", span: 1 }
      ],
      format: {
        p_win_group: pct1, p_R32: pct1, p_R16: pct1, p_QF: pct1,
        p_SF: pct1, p_final: pct1, p_champ: pct1, d_champ: signed
      },
      render: { team: (v) => maps.teamLinkHtml(v) },
      heatmap: {
        p_win_group: "high-good", p_R32: "high-good", p_R16: "high-good",
        p_QF: "high-good", p_SF: "high-good", p_final: "high-good",
        p_champ: "high-good", d_champ: "diverging"
      },
      sort: "p_champ", reverse: true, rows: 48
    })
    tableSec.appendChild(table)
  }

  function renderMatchups() {
    muSec.replaceChildren()
    if (!results) return
    const h = document.createElement("h2"); h.textContent = "Most likely matchups"
    const cap = document.createElement("p"); cap.className = "wcs-cap"
    cap.textContent = "The pairings each knockout round serves up most often, given your locked results."
    muSec.append(h, cap)
    const grid = document.createElement("div"); grid.className = "wcs-mu-grid"
    for (let r = 0; r < 5; r++) {
      const col = document.createElement("div"); col.className = "wcs-mu-col"
      const head = document.createElement("div"); head.className = "wcs-col-head"; head.textContent = ROUND_NAMES[r]
      col.appendChild(head)
      for (const m of results.matchups[r].slice(0, 6)) {
        const row = document.createElement("div"); row.className = "wcs-mu-row"
        row.innerHTML = `<span class="wcs-mu-pair">${maps.flagImg(m.a)} ${esc(m.a)} <i>v</i> ${maps.flagImg(m.b)} ${esc(m.b)}</span><span class="wcs-mu-pct">${m.pct.toFixed(1)}%</span>`
        col.appendChild(row)
      }
      grid.appendChild(col)
    }
    muSec.appendChild(grid)
  }

  function renderPoints() {
    ptsSec.replaceChildren()
    if (!results) return
    const h = document.createElement("h2"); h.textContent = `Qualification by points — Group ${selGroup}`
    const cap = document.createElement("p"); cap.className = "wcs-cap"

    // Metric toggle: P(qualify | points) or P(finish 1st..4th | points)
    const METRICS = [
      { key: "qualify", label: "Qualify", get: r => r.p_advance,
        cap: "How often a team qualifies from each points total — top two automatically, plus the eight best thirds." },
      { key: "pos0", label: "1st", get: r => r.p_pos?.[0] ?? 0, cap: "How often a team wins the group from each points total." },
      { key: "pos1", label: "2nd", get: r => r.p_pos?.[1] ?? 0, cap: "How often a team finishes second from each points total." },
      { key: "pos2", label: "3rd", get: r => r.p_pos?.[2] ?? 0, cap: "How often a team finishes third from each points total — thirds enter the best-8 race." },
      { key: "pos3", label: "4th", get: r => r.p_pos?.[3] ?? 0, cap: "How often a team finishes last from each points total." }
    ]
    const metric = METRICS.find(m => m.key === window._wcsPtsMetric) || METRICS[0]
    cap.textContent = metric.cap
    const pills = document.createElement("div"); pills.className = "wcs-pills"
    for (const m of METRICS) {
      const b = document.createElement("button")
      b.className = "wcs-pill" + (m.key === metric.key ? " active" : "")
      b.textContent = m.label
      b.onclick = () => { window._wcsPtsMetric = m.key; renderPoints() }
      pills.appendChild(b)
    }
    ptsSec.append(h, cap, pills)

    const POINTS = [0, 1, 2, 3, 4, 5, 6, 7, 9] // 8 points is unreachable from 3 games
    const teams = results.pointsTable
      .filter(t => t.group === selGroup)
      .sort((x, y) => {
        const tx = results.teams.find(t => t.team === x.team), ty = results.teams.find(t => t.team === y.team)
        return (ty?.p_R32 ?? 0) - (tx?.p_R32 ?? 0)
      })
    const tbl = document.createElement("table"); tbl.className = "wcs-pts-table"
    tbl.innerHTML = `<thead><tr><th>Team</th>${POINTS.map(p => `<th>${p} pts</th>`).join("")}</tr></thead>`
    const tb = document.createElement("tbody")
    for (const t of teams) {
      const byPts = new Map(t.rows.map(r => [r.points, r]))
      const cells = POINTS.map(p => {
        const r = byPts.get(p)
        if (!r || r.n < 30) return `<td class="dim">–</td>`
        const v = metric.get(r)
        const alpha = (v / 100) * 0.45
        return `<td style="background:rgba(90,160,125,${alpha.toFixed(3)})" title="${r.n.toLocaleString()} of ${results.nSims.toLocaleString()} sims">${v.toFixed(0)}%</td>`
      }).join("")
      const tr = document.createElement("tr")
      tr.innerHTML = `<td class="wcs-pts-team">${maps.teamLinkHtml(t.team)}</td>${cells}`
      tb.appendChild(tr)
    }
    tbl.appendChild(tb)
    // Wrap in a horizontal-scroll container — the 10-column points grid
    // overflows narrow phones, and a bare <table> won't pan (statsTable wraps
    // its own tables in .stats-table-scroll; this hand-rolled one needs it too).
    const scroll = document.createElement("div"); scroll.className = "wcs-scroll-x"
    scroll.appendChild(tbl)
    ptsSec.appendChild(scroll)
  }

  function render() {
    // Everything, including the group fixtures — selGroup couples groups and
    // points structurally, so no interaction needs to remember a pairing.
    renderBar(); renderGroups(); renderKo(); renderTable(); renderMatchups(); renderPoints()
  }

  // First paint: fixtures are interactive immediately; sim results land ~0.5s later.
  renderBar(); renderGroups()
  setTimeout(run, 30)

  wrap.appendChild(window.editorial.tableSource({
    source: "pannadata",
    sourceUrl: "https://github.com/peteowen1/pannadata",
    license: "CC BY 4.0",
    asAt: "Live during play",
    hint: "10K-simulation Monte Carlo · re-runs while games are live"
  }))

  return wrap
}
 

Pete Owen · Sydney · © 2026 · Source

My Teams | Settings | Photo Credits | Privacy | Disclaimer