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 Player Ratings

Skip to content

Football > World Cup 2026 > Player Ratings

Football · World Cup 2026 · Player Ratings

The best players at the World Cup

All 48 squads in one sortable table, ranked by Piero — a single composite rating blending each player’s career Panna, EPR and PSR (the player-level cousin of the team Tiento) — with the component ratings and per-90 skill profiles alongside. This is the going-in view: who the numbers rate before a ball is kicked. For what they’re actually doing at the tournament, see Player Stats.

Show code
statsEsc = window.statsEsc
statsTable = window.statsTable
base = window.DATA_BASE_URL

// Squad rows: one per player per nation, with career panna/offense/defense/
// epr/psr + expected_minutes_norm + is_starter_pred + group.
_wcprSquads = {
  try { return await window.fetchParquet(base + "football/wc2026_squads.parquet") }
  catch (e) { console.error("[wc-player-ratings] squads load failed:", e); return null }
}
// Bio (dob → age) + headshot credits, small JSONs keyed by player_id
_wcprBio = {
  try {
    const r = await fetch(base + "football/player-bio.json")
    if (!r.ok) { console.warn("[wc-player-ratings] bio fetch HTTP " + r.status + " — ages/fallback clubs degraded"); return {} }
    return await r.json()
  } catch (e) { console.warn("[wc-player-ratings] bio load failed:", e); return {} }
}
_wcprHsCredits = {
  try {
    const r = await fetch(base + "football/headshot-credits.json")
    if (!r.ok) { console.warn("[wc-player-ratings] headshot credits fetch HTTP " + r.status); return {} }
    return await r.json()
  } catch (e) { console.warn("[wc-player-ratings] headshot credits load failed:", e); return {} }
}
// Club ratings parquet — one row per rated player (career values); used for
// each player's club name. Stored promise (OJS perf pattern) so only the
// rows cell awaits it. Players at clubs outside panna's ten tracked leagues
// have no row → club shows a dash.
_wcprClubPromise = {
  if (!window._wcprClubP) {
    window._wcprClubP = window.fetchParquet(base + "football/ratings.parquet")
      .catch(e => { console.warn("[wc-player-ratings] club ratings load failed:", e); return null })
  }
  return window._wcprClubP
}
// Skills parquet (16MB) — fetched in the background, awaited ONLY by the
// skill-category tabs so the default Ratings tab renders instantly.
{ window._wcprSkillsPromise = window.fetchParquet(base + "football/player-skills.parquet")
    .catch(e => { console.warn("[wc-player-ratings] skills load failed:", e); return null }) }
Show code
// ── Qualifying confederation per nation ──────────────────────
// Static map of the 48 qualified teams (UEFA 16 · CAF 10 · AFC 9 ·
// CONCACAF 6 · CONMEBOL 6 · OFC 1, playoff qualifiers included in their
// confederation's count). Names match the wc2026 parquets.
wcConfed = {
  const c = {}
  const add = (conf, teams) => { for (const t of teams) c[t] = conf }
  add("UEFA", ["Czechia", "Switzerland", "Bosnia-Herzegovina", "Scotland", "Türkiye",
    "Germany", "Netherlands", "Sweden", "Belgium", "Spain", "France", "Norway",
    "Austria", "Portugal", "England", "Croatia"])
  add("CONCACAF", ["Mexico", "Canada", "Haiti", "United States", "Curaçao", "Panama"])
  add("CONMEBOL", ["Brazil", "Paraguay", "Ecuador", "Uruguay", "Argentina", "Colombia"])
  add("CAF", ["South Africa", "Morocco", "Côte d'Ivoire", "Tunisia", "Egypt",
    "Cabo Verde", "Senegal", "Algeria", "Congo DR", "Ghana"])
  add("AFC", ["Korea Republic", "Qatar", "Australia", "Japan", "IR Iran",
    "Saudi Arabia", "Iraq", "Jordan", "Uzbekistan"])
  add("OFC", ["New Zealand"])
  return c
}
Show code
// ── Player rows: dedupe, per-team projected minutes, age + club joins ──
playerRows = {
  if (_wcprSquads == null) return null
  const fm = window.footballMaps
  // Defensive dedupe per (team, player_id) — the upstream cache once
  // shipped announced+derived duplicates of the same player
  const seen = new Set()
  const squads = _wcprSquads.filter(p => {
    const k = `${p.team}|${p.player_id}`
    return seen.has(k) ? false : (seen.add(k), true)
  })

  // Projected minutes per match: expected_minutes_norm is roughly per-match
  // minutes for announced squads but unnormalized for some derived ones —
  // rescale each TEAM to the 11x90 = 990-minute budget and cap at 90
  // (same convention as the team-page squad table).
  const teamSum = new Map()
  for (const p of squads) {
    teamSum.set(p.team, (teamSum.get(p.team) || 0) + (p.expected_minutes_norm ?? 0))
  }
  const predMins = (p) => {
    const sum = teamSum.get(p.team)
    return (p.expected_minutes_norm == null || !sum || sum <= 0)
      ? null : Math.min(90, p.expected_minutes_norm * 990 / sum)
  }

  const clubRatings = await _wcprClubPromise
  const clubBy = new Map()
  for (const r of clubRatings ?? []) {
    // one row per player in practice; keep the most-minutes row defensively
    const cur = clubBy.get(r.player_id)
    if (!cur || (r.total_minutes ?? 0) > (cur.total_minutes ?? 0)) clubBy.set(r.player_id, r)
  }
  // Don't misattribute an outage to data coverage: if the parquet failed,
  // the dashes mean "club column degraded", not "untracked leagues".
  if (clubRatings == null) {
    console.warn("[wc-player-ratings] club column degraded — ratings.parquet unavailable")
  } else {
    const missingClub = squads.filter(p => !clubBy.has(p.player_id)).length
    if (missingClub) console.info(`[wc-player-ratings] ${missingClub} players have no club row (leagues outside panna's coverage)`)
  }

  const ageOf = (pid) => {
    const dob = _wcprBio?.[pid]?.dob
    if (!dob) return null
    const age = Math.floor((Date.now() - new Date(dob).getTime()) / 31557600000)
    return Number.isFinite(age) ? age : null
  }

  const out = squads.map(p => {
    // squads parquet carries Opta long-form positions ("Attacking Midfield")
    // — map to panna short codes for badges + group filtering
    const code = fm.optaToPanna[p.position] || p.position
    const cr = clubBy.get(p.player_id)
    return {
      player: p.player_name,
      team: p.team,
      group: p.group ?? null,
      confed: wcConfed[p.team] || null,
      pos: code,
      pos_group: fm.panna_GROUP[code] || "",
      age: ageOf(p.player_id),
      // club precedence: squads parquet club_name (authoritative — panna's
      // latest club appearance across ALL scraped comps, 97% coverage,
      // shipped 2026-06-11) → live ratings join → Wikidata bio shim
      club: p.club_name ?? cr?.team ?? _wcprBio?.[p.player_id]?.club ?? null,
      mins: predMins(p),
      // Career-trait columns come from ratings.parquet (cr) — the SAME canonical
      // source the player profile + all-leagues ratings pages read — so a player
      // shows the identical panna/off/def/EPR/PSR everywhere. The squad parquet's
      // own panna/off/def are computed over a different window and DIVERGED (e.g.
      // Watkins 0.13 here vs 0.20 on his profile); use them only as a fallback for
      // untracked-league call-ups absent from ratings.parquet. Real fix is upstream
      // in pannadata (wc2026_squads should carry ratings.parquet's values + a piero
      // column) — see C:\dev\pannaverse\PIERO-POOL-INDEPENDENCE.md.
      panna: cr?.panna ?? p.panna,
      offense: cr?.offense ?? p.offense,
      defense: cr?.defense ?? p.defense,
      epr: cr?.epr ?? p.epr,
      psr: cr?.psr ?? p.psr,
      _xi: !!p.is_starter_pred, _pid: p.player_id
    }
  })
  // Piero MUST be pool-independent — a player's composite rating can't change
  // between pages. Compute it ONCE over the canonical ratings.parquet pool (the
  // reference population the profile + all-leagues pages use), then look each
  // squad player up by player_id, so Watkins reads the same Piero here as on his
  // profile. Untracked-league players (absent from ratings.parquet) have no
  // canonical row, so they fall back to a squad-pool computation.
  const pieroById = new Map()
  if (Array.isArray(clubRatings) && clubRatings.length) {
    const pa = window.pieroRating.computePlayerRating(clubRatings, { scaleTo: "panna", quiet: true })
    clubRatings.forEach((r, i) => { if (pa[i] != null) pieroById.set(r.player_id, pa[i]) })
  }
  const pieroSquad = window.pieroRating.computePlayerRating(out, { scaleTo: "panna", quiet: true })
  return out.map((r, i) => ({ ...r, piero: pieroById.get(r._pid) ?? pieroSquad[i] }))
}
Show code
// ── Category toggle: career Ratings + per-90 skill tabs ──────
viewof category = {
  const defs = window.footballStatDefs || {}
  // Skill categories whose columns have _p90 twins in player-skills.parquet
  // (same filter as the league-wide player-ratings page)
  const catKeys = Object.keys(defs).filter(k =>
    !defs[k].page && defs[k].source === "matchStats" && k !== "custom")
  const options = ["Ratings", ...catKeys.map(k => defs[k].label)]
  const _key = "_statCategory_" + window.location.pathname.replace(/[^a-z0-9]/gi, "_")
  const _saved = window[_key] || "Ratings"
  const _default = options.includes(_saved) ? _saved : "Ratings"
  const container = document.createElement("div")
  container.className = "pos-pills"
  for (const o of options) {
    const btn = document.createElement("button")
    btn.className = "pos-pill" + (o === _default ? " active" : "")
    btn.textContent = o
    btn.addEventListener("click", () => {
      container.querySelectorAll(".pos-pill").forEach(b => b.classList.remove("active"))
      btn.classList.add("active")
      container.value = o
      window[_key] = o
      container.dispatchEvent(new Event("input", { bubbles: true }))
    })
    container.appendChild(btn)
  }
  container.value = _default
  return container
}
Show code
// ── Listed-position filter (GK/DEF/MID/FWD group pills) ──────
viewof posFilter = {
  const positions = ["All", "GK", "DEF", "MID", "FWD"]
  const _key = "_posFilter_" + window.location.pathname.replace(/[^a-z0-9]/gi, "_")
  const _saved = positions.includes(window[_key]) ? window[_key] : "All"
  const wrap = document.createElement("div")
  wrap.className = "pos-filter-row"
  const label = document.createElement("span")
  label.className = "pos-filter-label"
  label.textContent = "Listed pos"
  label.title = "Filter by the player's squad position"
  wrap.appendChild(label)
  const container = document.createElement("div")
  container.className = "pos-pills"
  for (const p of positions) {
    const btn = document.createElement("button")
    btn.className = "pos-pill" + (p === _saved ? " active" : "")
    btn.dataset.pos = p
    btn.textContent = p
    btn.title = p === "All" ? "Show all positions" : `Show players listed as ${p}`
    btn.addEventListener("click", () => {
      container.querySelectorAll(".pos-pill").forEach(b => b.classList.remove("active"))
      btn.classList.add("active")
      wrap.value = p
      window[_key] = p
      wrap.dispatchEvent(new Event("input", { bubbles: true }))
    })
    container.appendChild(btn)
  }
  wrap.appendChild(container)
  wrap.value = _saved
  return wrap
}
Show code
viewof filters = {
  const makeSelect = window.footballMaps.makeSelect

  const container = document.createElement("div")
  container.className = "player-filter-bar"
  const row = document.createElement("div")
  row.className = "filter-row"

  const nations = playerRows
    ? ["All Nations", ...[...new Set(playerRows.map(d => d.team).filter(Boolean))].sort()]
    : ["All Nations"]
  const team = makeSelect(nations, "All Nations", "Nation")
  row.appendChild(team.wrap)

  const groups = playerRows
    ? ["All Groups", ...[...new Set(playerRows.map(d => d.group).filter(Boolean))].sort().map(g => "Group " + g)]
    : ["All Groups"]
  const grp = makeSelect(groups, "All Groups", "Group")
  row.appendChild(grp.wrap)

  const confeds = ["All Confederations", "UEFA", "CONMEBOL", "CAF", "AFC", "CONCACAF", "OFC"]
  const conf = makeSelect(confeds, "All Confederations", "Confed")
  row.appendChild(conf.wrap)

  // Projected-XI toggle
  const xiWrap = document.createElement("div")
  xiWrap.className = "filter-round-wrap"
  const xiLbl = document.createElement("span")
  xiLbl.className = "filter-label"
  xiLbl.textContent = "XI"
  const xiBtn = document.createElement("button")
  xiBtn.className = "pos-pill"
  xiBtn.textContent = "Projected XI only"
  xiBtn.title = "Show only the model's projected starting elevens"
  xiWrap.appendChild(xiLbl)
  xiWrap.appendChild(xiBtn)
  row.appendChild(xiWrap)

  const searchWrap = document.createElement("div")
  searchWrap.className = "filter-round-wrap"
  const sLbl = document.createElement("span")
  sLbl.className = "filter-label"
  sLbl.textContent = "Search"
  const sInput = document.createElement("input")
  sInput.type = "search"
  sInput.className = "round-input"
  sInput.style.minWidth = "11rem"
  sInput.placeholder = "Player, nation or club…"
  searchWrap.appendChild(sLbl)
  searchWrap.appendChild(sInput)
  row.appendChild(searchWrap)

  container.appendChild(row)
  container.value = { team: "All Nations", group: "All Groups", confed: "All Confederations", xi: false, search: "" }
  function emit() { container.dispatchEvent(new Event("input", { bubbles: true })) }

  team.sel.addEventListener("change", () => { container.value = { ...container.value, team: team.sel.value }; emit() })
  grp.sel.addEventListener("change", () => { container.value = { ...container.value, group: grp.sel.value }; emit() })
  conf.sel.addEventListener("change", () => { container.value = { ...container.value, confed: conf.sel.value }; emit() })
  xiBtn.addEventListener("click", () => {
    const on = !container.value.xi
    xiBtn.classList.toggle("active", on)
    container.value = { ...container.value, xi: on }
    emit()
  })
  let searchTimer
  sInput.addEventListener("input", () => {
    clearTimeout(searchTimer)
    searchTimer = setTimeout(() => {
      container.value = { ...container.value, search: sInput.value.trim() }
      emit()
    }, 250)
  })
  return container
}

teamFilter = filters.team
groupFilter = filters.group
confedFilter = filters.confed
xiOnly = filters.xi
searchQuery = filters.search
Show code
html`<div class="byline">
  <span>By <strong>Pete Owen</strong></span>
  <span>Updated · <strong>${statsEsc(playerRows ? `${playerRows.length.toLocaleString()} players · 48 squads` : "World Cup 2026")}</strong></span>
  <span><a href="../blog/2026-04-24-understanding-panna/">Methodology &darr;</a></span>
  <span><a href="definitions.html">Definitions &nearr;</a></span>
  <span>&approx; 3 min read</span>
</div>`
Show code
window.editorial.sidebarToggle()
Show code
// ── Skill join — only awaited when a skill tab is active ─────
// player-skills.parquet is keyed by player_name and carries _p90 twins of
// the box-score columns (same join as football/player-ratings.qmd).
skillRows = {
  if (category === "Ratings" || !playerRows) return null
  const skills = await window._wcprSkillsPromise
  if (!skills) return null
  const defs = window.footballStatDefs || {}
  const catKey = Object.keys(defs).find(k => defs[k].label === category)
  const catDef = catKey ? defs[catKey] : null
  // label-matched join to stat-defs.js — label drift there lands here, and
  // refreshing won't fix it, so name the real cause
  if (!catDef) { console.error("[wc-player-ratings] no stat-def found for category label:", category); return null }
  const skillsMap = new Map()
  for (const s of skills) skillsMap.set(s.player_name, s)
  let joined = 0
  const rows = playerRows.map(r => {
    const sk = skillsMap.get(r.player)
    const row = { ...r }
    if (sk) {
      joined++
      for (const col of catDef.columns) {
        const v = sk[col + "_p90"]
        if (v != null) row[col + "_r"] = v
      }
    }
    return row
  })
  console.info(`[wc-player-ratings] ${category}: skills joined for ${joined}/${playerRows.length} players`)
  return rows
}
Show code
// ── Render table ─────────────────────────────────────────────
{
  const maps = window.wcMaps
  const fm = window.footballMaps
  if (playerRows == null) {
    return html`<p class="text-muted">Data failed to load — try refreshing (see console for details).</p>`
  }
  if (playerRows.length === 0) {
    return html`<p class="text-muted">No squad data available yet — check back soon.</p>`
  }

  const isRatings = category === "Ratings"
  if (!isRatings && skillRows == null) {
    return html`<p class="text-muted">Skill data failed to load — try refreshing (see console for details).</p>`
  }

  let data = isRatings ? playerRows : skillRows
  if (teamFilter !== "All Nations") data = data.filter(d => d.team === teamFilter)
  if (groupFilter !== "All Groups") data = data.filter(d => "Group " + d.group === groupFilter)
  if (confedFilter !== "All Confederations") data = data.filter(d => d.confed === confedFilter)
  if (xiOnly) data = data.filter(d => d._xi)
  if (posFilter !== "All") data = data.filter(d => d.pos_group === posFilter)
  if (searchQuery) {
    const q = searchQuery.toLowerCase()
    data = data.filter(d =>
      (d.player || "").toLowerCase().includes(q) ||
      (d.team || "").toLowerCase().includes(q) ||
      (d.club || "").toLowerCase().includes(q))
  }
  if (data.length === 0) {
    return html`<p class="text-muted">No players match the current filters.</p>`
  }

  const hsBase = base + "football/headshots/"
  const playerCell = (v, r) => {
    const credit = _wcprHsCredits?.[r._pid]
    const tip = credit ? ` title="Photo: ${statsEsc(credit.a)} · ${statsEsc(credit.l)} · Wikimedia Commons"` : ""
    const initial = statsEsc((v || "?").trim().charAt(0).toUpperCase())
    // initial circle sits behind the headshot — covered when the photo
    // loads, revealed when it 404s (onerror hides the img)
    const img = r._pid
      ? `<img class="wcpr-hs-img" src="${hsBase}${statsEsc(r._pid)}.webp" alt="" loading="lazy"${tip} onerror="this.style.display='none'">`
      : ""
    const hs = `<span class="wcpr-hs-wrap"><span class="wcpr-hs-init">${initial}</span>${img}</span>`
    return `${hs}<span class="wcpr-name"><a href="player.html#name=${encodeURIComponent(v)}" class="player-link"><strong>${statsEsc(v)}</strong></a>${r._xi ? ` <span class="wcpr-xi">XI</span>` : ""}<span class="player-sub">${maps.teamLinkHtml(r.team)}</span></span>`
  }
  const posBadge = (val) => {
    const c = fm.pannaBadge[val] || "#9ca3af"
    return `<span class="pos-badge" style="background:${c}18;color:${c};border:1px solid ${c}35">${statsEsc(val || "?")}</span>`
  }

  const fmt2 = x => x == null ? "—" : x.toFixed(2)
  const idCols = ["player", "pos", "age", "club", "mins"]
  const idHeader = { player: "Player", pos: "Pos", age: "Age", club: "Club", mins: "Proj. mins" }
  const idFormat = {
    age: x => x == null ? "—" : String(x),
    mins: x => x == null ? "—" : Math.round(x) + "'"
  }

  let columns, header, groups, format, heatmap, tooltip, sortCol
  if (isRatings) {
    columns = [...idCols, "piero", "panna", "offense", "defense", "epr", "psr"]
    header = { ...idHeader, piero: "Piero", panna: "Panna", offense: "Off", defense: "Def", epr: "EPR", psr: "PSR" }
    groups = [{ label: "Player", span: 5 }, { label: "Career ratings (club form)", span: 6 }]
    format = { ...idFormat, piero: fmt2, panna: fmt2, offense: fmt2, defense: fmt2, epr: fmt2, psr: fmt2 }
    heatmap = { mins: "high-good", piero: "high-good", panna: "high-good", offense: "high-good",
      defense: "low-good", epr: "high-good", psr: "high-good" }
    tooltip = {
      mins: "Model-projected minutes per match (each squad scaled to the 990-minute team budget)",
      piero: "Piero — composite player rating: a panna-led blend of Panna, EPR and PSR (0.5/0.3/0.2), on the Panna scale",
      panna: "Career player rating from club football",
      offense: "Career attacking value", defense: "Career defensive value (lower = better)",
      epr: "Expected possession value added per 90 (career)",
      psr: "Box-score skill rating (career)"
    }
    sortCol = "piero"
  } else {
    const defs = window.footballStatDefs || {}
    const catKey = Object.keys(defs).find(k => defs[k].label === category)
    const catDef = defs[catKey]
    const skillCols = catDef.columns.map(c => c + "_r")
    columns = [...idCols, ...skillCols]
    header = { ...idHeader }
    format = { ...idFormat }
    heatmap = { mins: "high-good" }
    tooltip = { mins: "Model-projected minutes per match" }
    for (const c of catDef.columns) {
      header[c + "_r"] = catDef.header[c] || c
      format[c + "_r"] = fmt2
      if (catDef.heatmap?.[c]) heatmap[c + "_r"] = catDef.heatmap[c]
      if (catDef.tooltip?.[c]) tooltip[c + "_r"] = catDef.tooltip[c] + " (per 90, career club form)"
    }
    groups = [{ label: "Player", span: 5 }, { label: `${catDef.label} · per 90, career`, span: skillCols.length }]
    sortCol = skillCols[0]
  }

  return statsTable(data, {
    columns,
    mobileCols: isRatings ? ["player", "pos", "mins", "piero"] : ["player", "pos", columns[5]],
    header, groups, format, tooltip,
    render: { player: playerCell, pos: posBadge },
    heatmap,
    heatmapData: data,
    filters: isRatings ? { piero: "range", mins: "range", age: "range" } : { mins: "range", age: "range" },
    sort: sortCol, reverse: true, rows: 25
  })
}
Show code
wcprAsAt = window.editorial.dataUpdated(base + "football/wc2026_squads.parquet")
Show code
// ── Source attribution row ──────────────────────────────────
{
  const asAt = await wcprAsAt
  const src = document.createElement("div"); src.className = "table-source"
  const left = document.createElement("span")
  left.appendChild(document.createTextNode("Source: "))
  const a = document.createElement("a")
  a.href = "https://github.com/peteowen1/pannadata"; a.target = "_blank"; a.rel = "noopener"
  a.textContent = "pannadata"
  left.appendChild(a)
  left.appendChild(document.createTextNode(" · Opta scrape · CC BY 4.0"))
  const right = document.createElement("span")
  right.textContent = [asAt, "Career ratings from club football · a dash means too few rated minutes"].filter(Boolean).join(" · ")
  src.appendChild(left); src.appendChild(right)
  return src
}
Show code
{
  const inner = document.createElement("div")
  inner.className = "side-rail-inner"
  const { railBlock, btnTile, tableSource } = window.editorial

  if (playerRows && playerRows.length > 0) {
    const rated = playerRows.filter(p => p.panna != null)
    const top = [...rated].sort((a, b) => b.panna - a.panna)[0]
    const ages = playerRows.map(p => p.age).filter(a => a != null)
    const youngest = ages.length ? Math.min(...ages) : null

    const btn = railBlock("By the Numbers")
    const grid = document.createElement("div"); grid.className = "btn-block"
    grid.appendChild(btnTile(playerRows.length.toLocaleString(), [
      { text: "Players", bold: true }, { text: " · across 48 squads" }
    ]))
    if (top) {
      grid.appendChild(btnTile(top.panna.toFixed(2), [
        { text: "Top Panna rating", bold: true }, { text: " · " + top.player }
      ]))
    }
    grid.appendChild(btnTile(`${Math.round(rated.length / playerRows.length * 100)}%`, [
      { text: "Players with a club rating", bold: true },
      { text: " · the rest play in untracked leagues" }
    ]))
    if (youngest != null) {
      grid.appendChild(btnTile(String(youngest), [
        { text: "Youngest player", bold: true }, { text: " · years old" }
      ]))
    }
    btn.appendChild(grid); inner.appendChild(btn)
  }

  // About
  const about = railBlock("Form vs Performance"); about.classList.add("about-block")
  const p1 = document.createElement("p")
  p1.appendChild(document.createTextNode("These are "))
  const s1 = document.createElement("strong"); s1.textContent = "career"; p1.appendChild(s1)
  p1.appendChild(document.createTextNode(" ratings — each player's enduring trait values built from their club football, not tournament or single-season form."))
  about.appendChild(p1)
  const p2 = document.createElement("p")
  p2.appendChild(document.createTextNode("For what players are doing at the tournament itself, see "))
  const a2 = document.createElement("a")
  a2.href = "world-cup-player-stats.html"; a2.textContent = "Player Stats"
  p2.appendChild(a2); p2.appendChild(document.createTextNode("."))
  about.appendChild(p2)
  inner.appendChild(about)

  // Read Next
  const read = railBlock("Read Next")
  const ul = document.createElement("ul"); ul.className = "rail-list"
  const links = [
    { href: "world-cup-player-stats.html", title: "Player Stats", meta: "Tournament box scores, game by game" },
    { href: "world-cup-strength.html", title: "Team Strength", meta: "Tiento and seven rating systems" },
    { href: "world-cup-simulator.html", title: "Simulator", meta: "Lock in results, watch odds shift" },
    { href: "world-cup-2026.html", title: "World Cup Hub", meta: "Everything in one place" }
  ]
  for (const l of links) {
    const li = document.createElement("li")
    const ax = document.createElement("a")
    ax.href = l.href; ax.textContent = l.title
    const meta = document.createElement("span"); meta.className = "rail-meta"; meta.textContent = l.meta
    ax.appendChild(meta); li.appendChild(ax); ul.appendChild(li)
  }
  read.appendChild(ul); inner.appendChild(read)

  inner.appendChild(tableSource({
    source: "pannadata",
    sourceUrl: "https://github.com/peteowen1/pannadata",
    sourceNote: "Opta scrape",
    license: "CC BY 4.0",
    hint: "Squads + ratings refresh with the daily pipeline"
  }))

  return inner
}
 

Pete Owen · Sydney · © 2026 · Source

My Teams | Settings | Photo Credits | Privacy | Disclaimer