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

Football Player Game Logs

Skip to content

Football > Player Game Logs

Football · Player Game Logs · One Row Per Match

Which single-game performances should we be talking about?

Every player, every match across 15 leagues — sortable. Use this when season totals hide the story: who had the hat-trick, who terrorised a back four for 90, who quietly hit the woodwork three times and still got the man-of-match.

Show code
// ── Byline strip ─────────────────────────────────────────────
html`<div class="byline">
  <span>By <strong>Pete Owen</strong></span>
  <span>Updated · <strong>After every matchday</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; 5 min read</span>
</div>`
Show code
// ── Sidebar collapse toggle ─────────────────────────────────
window.editorial.sidebarToggle()
Show code
statsEsc = window.statsEsc
statsTable = window.statsTable
base = window.DATA_BASE_URL

leagueCodes = window.footballMaps.clubLeagues
leagueNames = window.footballMaps.leagueNames
posToGroup = window.footballMaps.posToGroup
footballPosColors = window.footballMaps.posGroupColors

statDefs = window.footballStatDefs || {}

// Categories: exclude ratings and custom
catKeys = Object.keys(statDefs).filter(k => !statDefs[k].page && k !== "custom")
Show code
// ── Category toggle ──────────────────────────────────────────
viewof category = {
  const _key = "_statCategory_" + window.location.pathname.replace(/[^a-z0-9]/gi, "_")
  const _saved = window[_key] || "scoring"
  const _default = catKeys.includes(_saved) ? _saved : "scoring"
  const container = html`<div class="stats-category-toggle football"></div>`
  for (const key of catKeys) {
    const btn = document.createElement("button")
    btn.className = "stats-cat-btn" + (key === _default ? " active" : "")
    btn.textContent = statDefs[key].label
    btn.dataset.cat = key
    btn.addEventListener("click", () => {
      container.querySelectorAll(".stats-cat-btn").forEach(b => b.classList.remove("active"))
      btn.classList.add("active")
      container.value = key
      window[_key] = key
      container.dispatchEvent(new Event("input", { bubbles: true }))
    })
    container.appendChild(btn)
  }
  container.value = _default
  return container
}
Show code
_ratingsRaw = {
  try { return await window.fetchParquet(base + "football/ratings.parquet") } catch (e) { console.warn("[game-logs] ratings load failed:", e); return null }
}
_ratingsPositions = {
  if (!_ratingsRaw) return {}
  const m = {}
  for (const r of _ratingsRaw) { if (r.player_name && r.position) m[r.player_name] = r.position }
  return m
}
Show code
viewof filters = {
  function makeSelect(options, defaultVal, label) {
    const wrap = document.createElement("div")
    wrap.className = "filter-select-wrap"
    const lbl = document.createElement("span")
    lbl.className = "filter-label"
    lbl.textContent = label
    const sel = document.createElement("select")
    sel.className = "filter-select"
    for (const opt of options) {
      const o = document.createElement("option")
      o.value = opt; o.textContent = opt
      if (opt === defaultVal) o.selected = true
      sel.appendChild(o)
    }
    wrap.appendChild(lbl); wrap.appendChild(sel)
    return { wrap, sel }
  }

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

  const leagueOpts = ["All Leagues", ...leagueCodes.map(c => `${leagueNames[c] || c} (${c})`)]
  const league = makeSelect(leagueOpts, leagueOpts.find(l => l.includes("ENG")) || leagueOpts[0], "League")

  const season = makeSelect(["All Seasons"], "All Seasons", "Season")

  row.appendChild(league.wrap)
  row.appendChild(season.wrap)
  container.appendChild(row)

  container.value = { league: "ENG", season: "All Seasons" }

  function emit() { container.dispatchEvent(new Event("input", { bubbles: true })) }

  league.sel.addEventListener("change", () => {
    const raw = league.sel.value
    const code = raw === "All Leagues" ? "All Leagues" : raw.match(/\((\w+)\)$/)?.[1] || raw
    container.value = { ...container.value, league: code, season: "All Seasons" }
    emit()
  })
  season.sel.addEventListener("change", () => {
    container.value = { ...container.value, season: season.sel.value }
    emit()
  })

  container._updateSeasons = function (opts, defaultVal) {
    const oldVal = container.value.season
    while (season.sel.firstChild) season.sel.removeChild(season.sel.firstChild)
    for (const opt of opts) {
      const o = document.createElement("option")
      o.value = opt; o.textContent = opt
      if (opt === defaultVal) o.selected = true
      season.sel.appendChild(o)
    }
    if (oldVal !== defaultVal) {
      container.value = { ...container.value, season: defaultVal }
      emit()
    }
  }

  return container
}

leagueFilter = filters.league
seasonFilter = filters.season
Show code
_matchStatsResult = {
  const codes = leagueFilter === "All Leagues" ? leagueCodes : [leagueFilter]
  const failed = []
  // Fetch all per-league files concurrently (browser caps ~6/origin) instead of
  // serially — on "All Leagues" this turns up to ~15 sequential awaits into one
  // parallel batch. Per-league error accounting (failed.push) and the input
  // order are preserved by mapping in `codes` order and concatenating in order.
  const perLeague = await Promise.all(codes.map(async (code) => {
    try {
      const data = await window.fetchParquet(base + `football/match-stats-${code}.parquet`)
      if (data) return data
      failed.push(code)
      return null
    } catch (e) {
      console.error(`[game-logs] match-stats-${code} load failed:`, e)
      failed.push(code)
      return null
    }
  }))
  let results = []
  // .concat() avoids the spread argument-count limit (~32K) on big league files.
  for (const data of perLeague) { if (data) results = results.concat(data) }
  return { rows: results.length > 0 ? results : null, failed, requestedCodes: codes }
}
matchStats = _matchStatsResult.rows

// Load game-logs for Value tab
_gameLogs = {
  try { return await window.fetchParquet(base + "football/game-logs.parquet") } catch (e) { console.warn("[game-logs] game-logs load failed:", e); return null }
}

seasonOptions = {
  const src = matchStats || _gameLogs
  if (!src) return ["All Seasons"]
  const seasons = [...new Set(src.map(d => String(d.season)))].sort().reverse()
  return ["All Seasons", ...seasons]
}

// Update season dropdown
{
  const el = document.querySelector(".player-filter-bar")
  if (el && el._updateSeasons) {
    const defaultSeason = seasonOptions[1] || "All Seasons"
    el._updateSeasons(seasonOptions, defaultSeason)
  }
}
Show code
// ── Build game log table data ────────────────────────────────
tableData = {
  const catDef = statDefs[category]
  if (!catDef) return null

  const source = catDef.source
  let rawData
  if (source === "gameLogs") {
    rawData = _gameLogs
  } else {
    rawData = matchStats
  }
  if (!rawData) return null

  const effectiveSeason = seasonFilter === "All Seasons" ? null : seasonFilter

  let games = rawData

  // League filter (for game-logs which may span leagues)
  if (leagueFilter !== "All Leagues" && source === "gameLogs") {
    games = games.filter(d => d.league === leagueFilter)
  }

  // Season filter
  if (effectiveSeason) games = games.filter(d => String(d.season) === effectiveSeason)

  // Build rows — no aggregation. Strip computed % cols so they aren't read
  // off the raw row (they're derived from base counts in the compute block).
  const statCols = catDef.columns.filter(c => !c.endsWith("_pct"))

  return games.map(g => {
    const pos = (g.position === "Substitute" || g.position === "Sub")
      ? (_ratingsPositions[g.player_name] || g.position)
      : g.position
    const posGroup = posToGroup[pos] || ""
    const matchDate = g.match_date ? String(g.match_date).replace("Z", "").slice(0, 10) : ""

    const row = {
      player_name: g.player_name || "",
      pos_group: posGroup,
      team: g.team_name || "",
      opponent: g.opponent || "",
      date: matchDate,
      mins: g.minsPlayed || 0
    }

    for (const col of statCols) {
      const v = Number(g[col])
      row[col] = isNaN(v) ? null : +(v.toFixed(1))
    }

    // Computed % columns. Per-match rates — null when no attempts so the cell
    // is blank rather than misleadingly 0%.
    if (catDef.compute) {
      const cols = catDef.columns
      if (cols.includes("pass_pct"))
        row.pass_pct = row.passes > 0 ? Math.round(100 * row.passes_accurate / row.passes) : null
      if (cols.includes("tackles_won_pct"))
        row.tackles_won_pct = row.tackles > 0 ? Math.round(100 * row.tackles_won / row.tackles) : null
      if (cols.includes("aerials_won_pct")) {
        const aerTot = (row.aerials_won || 0) + (row.aerials_lost || 0)
        row.aerials_won_pct = aerTot > 0 ? Math.round(100 * row.aerials_won / aerTot) : null
      }
      if (cols.includes("duels_won_pct")) {
        const duelTot = (row.duels_won || 0) + (row.duels_lost || 0)
        row.duels_won_pct = duelTot > 0 ? Math.round(100 * row.duels_won / duelTot) : null
      }
      if (cols.includes("shots_on_target_pct"))
        row.shots_on_target_pct = row.shots > 0 ? Math.round(100 * row.shots_on_target / row.shots) : null
    }

    return row
  })
}
Show code
// Partial-failure banner — surfaces league fetches that 404'd / errored.
{
  const failed = _matchStatsResult && _matchStatsResult.failed
  if (!failed || failed.length === 0) return html``
  const leagueNames = window.footballMaps.leagueNames || {}
  const labels = failed.map(c => leagueNames[c] || c).join(", ")
  const isAllFailed = !_matchStatsResult.rows
  return html`<div class="data-load-warning" role="status">
    <span><strong>${isAllFailed ? "Couldn't load data:" : "Some data didn't load:"}</strong> ${labels}.${isAllFailed ? "" : " Showing partial results."}</span>
    <button class="data-load-warning-btn" onclick="window.location.reload()">Retry</button>
  </div>`
}
Show code
viewof search = tableData == null
  ? html``
  : Inputs.search(tableData, { placeholder: "Search players..." })
Show code
// ── Render table ─────────────────────────────────────────────
{
  if (tableData == null || tableData.length === 0)
    return html`<p class="text-muted">No data available for this category and filter combination.</p>`

  const catDef = statDefs[category]
  const statCols = catDef.columns

  const columns = ["player_name", "pos_group", "team", "opponent", "date", "mins", ...statCols]

  const header = {
    player_name: "Player", pos_group: "POS", team: "Team",
    opponent: "Vs", date: "Date", mins: "Mins",
    ...catDef.header
  }

  const format = {}
  for (const col of statCols) {
    format[col] = col.endsWith("_pct")
      ? (v => v != null ? Math.round(Number(v)) + "%" : "")
      : (v => v != null ? String(Math.round(Number(v))) : "")
  }

  const heatmap = {}
  for (const col of statCols) {
    if (catDef.heatmap?.[col]) heatmap[col] = catDef.heatmap[col]
  }

  const render = {}
  const renderTeamCell = window.footballMaps?.renderTeamCell
  if (renderTeamCell) {
    render.team = (val) => renderTeamCell(val)
    render.opponent = (val) => renderTeamCell(val)
  }
  render.pos_group = (val) => {
    if (!val) return ""
    const col = footballPosColors[val] || "#888"
    return `<span class="pos-badge" style="background:${col}">${statsEsc(val)}</span>`
  }

  const groups = [
    { label: "", span: 6 },
    { label: catDef.label, span: statCols.length }
  ]

  const sortCol = catDef.sortCol || statCols[0]

  return statsTable(search, {
    columns, header, groups, format, heatmap, render,
    sort: sortCol, reverse: true,
    pageSize: 25
  })
}
Show code
glAsAt = window.editorial.dataUpdated(base + "football/game-logs.parquet")
Show code
// ── Source attribution row ──────────────────────────────────
{
  const asAt = await glAsAt
  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, "One row per (player, match)"].filter(Boolean).join(" · ")
  src.appendChild(left); src.appendChild(right)
  return src
}
Show code
// ── Editorial side rail ─────────────────────────────────────
{
  const inner = document.createElement("div")
  inner.className = "side-rail-inner"

  const { railBlock } = window.editorial

  // Last Updated
  const upd = railBlock("Last Updated")
  const stamp = document.createElement("div"); stamp.className = "update-stamp"
  stamp.textContent = "After every matchday"
  upd.appendChild(stamp)
  const updP = document.createElement("p")
  updP.style.cssText = "font-family: 'Source Serif 4', Georgia, serif; font-size: 0.85rem; color: var(--site-muted-color); margin: 0.7rem 0 0; line-height: 1.55;"
  updP.appendChild(document.createTextNode("Game logs land in R2 via the daily "))
  const code = document.createElement("code")
  code.style.cssText = "font-family: 'JetBrains Mono', monospace; font-size: 0.85em; color: var(--site-body-color)"
  code.textContent = "pannadata"
  updP.appendChild(code)
  updP.appendChild(document.createTextNode(" pipeline, ~24h after each fixture."))
  upd.appendChild(updP); inner.appendChild(upd)

  // How to read this
  const how = railBlock("How to read this"); how.classList.add("about-block")
  const p1 = document.createElement("p")
  p1.appendChild(document.createTextNode("Each row is "))
  const s1 = document.createElement("strong"); s1.textContent = "one player in one match"; p1.appendChild(s1)
  p1.appendChild(document.createTextNode(". Sort any stat column to surface the most extreme single-game performances."))
  how.appendChild(p1)
  const p2 = document.createElement("p")
  p2.appendChild(document.createTextNode("Heatmap colours are "))
  const s2 = document.createElement("strong"); s2.textContent = "relative within-column"; p2.appendChild(s2)
  p2.appendChild(document.createTextNode(" — green = high for that stat, red = low. Filter by league or matchday to narrow further."))
  how.appendChild(p2)
  inner.appendChild(how)

  // Read Next
  const read = railBlock("Read Next")
  const ul = document.createElement("ul"); ul.className = "rail-list"
  const links = [
    { href: "player-stats.html", title: "Player Stats", meta: "Aggregated by season" },
    { href: "player-ratings.html", title: "Player Ratings", meta: "Predictive Panna" },
    { href: "team-game-logs.html", title: "Team Game Logs", meta: "Per-match team box scores" },
    { href: "compare.html", title: "Player Comparison", meta: "Side-by-side tool" }
  ]
  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)

  return inner
}
 

Pete Owen · Sydney · © 2026 · Source

My Teams | Settings | Photo Credits | Privacy | Disclaimer