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

AFL Team Game Logs

Skip to content

AFL > Team Game Logs

AFL · Team Game Logs · One Row Per Team Per Match

Which team performances are worth remembering?

Every team, every game, every box-score column — sortable. Use this when you want the most disposals, the highest team EPV, the worst inside-50 efficiency of the season. The season-long aggregations sit on the team-stats page.

Show code
// ── Byline strip ─────────────────────────────────────────────
html`<div class="byline">
  <span>By <strong>Pete Owen</strong></span>
  <span>Updated · <strong>After every round</strong></span>
  <span><a href="../blog/2026-04-24-understanding-torp/">Methodology &darr;</a></span>
  <span><a href="definitions.html">Definitions &nearr;</a></span>
  <span>&approx; 4 min read</span>
</div>`
Show code
// ── Sidebar collapse toggle ─────────────────────────────────
window.editorial.sidebarToggle()
Show code
statsEsc = window.statsEsc
statsTable = window.statsTable
defs = window.aflStatDefs
predToFull = window.aflTeamMaps?.predToFull || {}
fullToPred = window.aflTeamMaps?.fullToPred || {}
shortName = window.aflTeamMaps?.canonicalToShort || {}

base = window.DATA_BASE_URL
Show code
gameLogs = {
  try {
    const rs = await window.fetchParquet(base + "afl/game-logs.parquet")
    return window.aflTeamMaps?.renameGameLogsColumns?.(rs) || rs
  } catch (e) {
    console.error("[team-game-logs] game-logs load failed:", e)
    return null
  }
}

// game-stats (2MB) is needed by any non-gameLogs category (source "gameStats" or
// "mixed"); the default "Value" tab is gameLogs-sourced. Defer the fetch as a
// shared window promise (same key as match.qmd / player.qmd) and only await it in
// the cell that aggregates a non-gameLogs category, so the default tab renders
// without blocking on it.
{ window._gameStatsPromise = window.fetchParquet(base + "afl/game-stats.parquet").catch(e => { console.warn("[team-game-logs] game-stats load failed:", e); return null }) }
Show code
predictions = {
  try {
    return await window.fetchParquet(base + "afl/predictions.parquet")
  } catch (e) {
    console.warn("[team-game-logs] predictions load failed:", e)
    return null
  }
}
Show code
homeAwayMap = {
  const m = new Map()
  if (predictions) {
    for (const p of predictions) {
      const h = predToFull[p.home_team] || p.home_team
      const a = predToFull[p.away_team] || p.away_team
      m.set(`${p.season}-${p.round}-${h}`, "home")
      m.set(`${p.season}-${p.round}-${a}`, "away")
    }
  }
  return m
}

dateMap = {
  const m = new Map()
  if (predictions) {
    for (const p of predictions) {
      if (!p.start_time) continue
      const d = p.start_time.slice(0, 10)
      const h = predToFull[p.home_team] || p.home_team
      const a = predToFull[p.away_team] || p.away_team
      // Key by team+opponent to handle double-headers (same round, different games)
      m.set(`${p.season}-${p.round}-${h}-${a}`, d)
      m.set(`${p.season}-${p.round}-${a}-${h}`, d)
    }
  }
  return m
}
Show code
categories = Object.entries(defs)
  .filter(([k, v]) => v.source !== "ratings" && k !== "custom")
  .map(([k, v]) => ({ key: k, label: v.label }))

viewof category = {
  const _key = "_teamGameLogCat_" + window.location.pathname.replace(/[^a-z0-9]/gi, "_")
  const _saved = window[_key] || categories[0].key
  const _default = categories.some(c => c.key === _saved) ? _saved : categories[0].key
  const wrap = document.createElement("div")
  wrap.className = "epv-toggle"
  wrap.value = _default
  for (const cat of categories) {
    const btn = document.createElement("button")
    btn.className = "epv-toggle-btn" + (cat.key === _default ? " active" : "")
    btn.textContent = cat.label
    btn.addEventListener("click", () => {
      wrap.querySelectorAll(".epv-toggle-btn").forEach(b => b.classList.remove("active"))
      btn.classList.add("active")
      wrap.value = cat.key
      window[_key] = cat.key
      wrap.dispatchEvent(new Event("input", { bubbles: true }))
    })
    wrap.appendChild(btn)
  }
  return wrap
}
Show code
seasonOptions = {
  // Filter metadata comes from gameLogs (always loaded, same season/team/round
  // coverage as game-stats) so the filter bar doesn't block on the deferred
  // game-stats fetch.
  const src = gameLogs
  if (!src) return ["All Seasons"]
  const seasons = [...new Set(src.map(d => d.season))].sort((a, b) => b - a)
  return ["All Seasons", ...seasons.map(String)]
}

teamOptions = {
  const src = gameLogs
  if (!src) return ["All Teams"]
  const teams = [...new Set(src.map(d => predToFull[d.team] || d.team))].filter(Boolean).sort()
  return ["All Teams", ...teams]
}
Show code
viewof filters = {
  const defaultSeason = seasonOptions[1] || "All Seasons"

  function getRoundRange(season) {
    const src = gameLogs
    if (!src) return { min: 0, max: 30 }
    const allGames = season === "All Seasons" ? src : src.filter(d => String(d.season) === season)
    let rMin = Infinity, rMax = -Infinity
    for (const d of allGames) { if (d.round != null) { if (d.round < rMin) rMin = d.round; if (d.round > rMax) rMax = d.round } }
    return { min: rMin === Infinity ? 0 : rMin, max: rMax === -Infinity ? 30 : rMax }
  }

  let roundBounds = getRoundRange(defaultSeason)

  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 season = makeSelect(seasonOptions, defaultSeason, "Season")
  const team = makeSelect(teamOptions, "All Teams", "Team")
  const haSelect = makeSelect(["All", "Home", "Away"], "All", "H/A")
  const seasonType = makeSelect(["All", "Regular", "Finals"], "All", "Type")

  row.appendChild(season.wrap)
  row.appendChild(team.wrap)
  row.appendChild(haSelect.wrap)
  row.appendChild(seasonType.wrap)

  // Round range
  const roundWrap = document.createElement("div")
  roundWrap.className = "filter-round-wrap"
  const rLbl = document.createElement("span")
  rLbl.className = "filter-label"
  rLbl.textContent = "Rounds"
  const rMin = document.createElement("input")
  rMin.type = "number"; rMin.className = "round-input"
  rMin.min = roundBounds.min; rMin.max = roundBounds.max; rMin.value = roundBounds.min
  const rSep = document.createElement("span")
  rSep.className = "round-sep"; rSep.textContent = "–"
  const rMax = document.createElement("input")
  rMax.type = "number"; rMax.className = "round-input"
  rMax.min = roundBounds.min; rMax.max = roundBounds.max; rMax.value = roundBounds.max
  roundWrap.appendChild(rLbl); roundWrap.appendChild(rMin); roundWrap.appendChild(rSep); roundWrap.appendChild(rMax)
  row.appendChild(roundWrap)

  container.appendChild(row)

  container.value = {
    season: defaultSeason,
    team: "All Teams",
    homeAway: "All",
    seasonType: "All",
    roundMin: +rMin.value,
    roundMax: +rMax.value
  }

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

  season.sel.addEventListener("change", () => {
    roundBounds = getRoundRange(season.sel.value)
    rMin.min = roundBounds.min; rMin.max = roundBounds.max; rMin.value = roundBounds.min
    rMax.min = roundBounds.min; rMax.max = roundBounds.max; rMax.value = roundBounds.max
    container.value = { ...container.value, season: season.sel.value, roundMin: roundBounds.min, roundMax: roundBounds.max }
    emit()
  })
  team.sel.addEventListener("change", () => { container.value = { ...container.value, team: team.sel.value }; emit() })
  haSelect.sel.addEventListener("change", () => { container.value = { ...container.value, homeAway: haSelect.sel.value }; emit() })
  seasonType.sel.addEventListener("change", () => { container.value = { ...container.value, seasonType: seasonType.sel.value }; emit() })

  let roundTimer
  function clampRound(input) {
    if (input.value === "") return
    const v = +input.value
    if (v < roundBounds.min) input.value = roundBounds.min
    if (v > roundBounds.max) input.value = roundBounds.max
  }
  function updateRound() {
    clearTimeout(roundTimer)
    roundTimer = setTimeout(() => {
      clampRound(rMin); clampRound(rMax)
      container.value = { ...container.value, roundMin: rMin.value === "" ? null : +rMin.value, roundMax: rMax.value === "" ? null : +rMax.value }
      emit()
    }, 500)
  }
  rMin.addEventListener("input", updateRound); rMax.addEventListener("input", updateRound)
  rMin.addEventListener("blur", () => { clampRound(rMin); updateRound() })
  rMax.addEventListener("blur", () => { clampRound(rMax); updateRound() })

  return container
}

seasonFilter = filters.season
teamFilter = filters.team
homeAwayFilter = filters.homeAway
seasonTypeFilter = filters.seasonType
roundRange = ({ min: filters.roundMin, max: filters.roundMax })
Show code
// ── Build team game log table data ──────────────────────────
tableData = {
  const catDef = defs[category]
  if (!catDef) return null

  const source = catDef.source
  // Any non-gameLogs category (gameStats or mixed) needs the deferred 2MB
  // game-stats parquet; await it here rather than at top level so gameLogs tabs
  // render without blocking on it.
  const rawData = source === "gameLogs" ? gameLogs : await window._gameStatsPromise
  if (!rawData) return null

  const effectiveSeason = seasonFilter === "All Seasons" ? null : Number(seasonFilter)
  const norm = n => predToFull[n] || n

  let games = rawData

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

  // Round range filter
  if (roundRange.min != null) games = games.filter(d => d.round >= roundRange.min)
  if (roundRange.max != null) games = games.filter(d => d.round <= roundRange.max)

  // Season type filter
  const isFinals = window.aflTeamMaps?.isFinals || (() => false)
  if (seasonTypeFilter === "Regular") games = games.filter(d => !isFinals(d))
  if (seasonTypeFilter === "Finals") games = games.filter(d => isFinals(d))

  // Aggregate player rows into team-game rows (exclude computed cols from sum)
  const computedCols = new Set(["score"])
  const statCols = catDef.columns
  const sumCols = statCols.filter(c => !computedCols.has(c))
  const teamGameMap = new Map()

  for (const row of games) {
    const t = norm(row.team)
    if (!t) continue
    const round = Number(row.round)
    const season = Number(row.season)
    const opp = norm(row.opponent || row.opp || "")
    // Key by opponent too — handles double-headers (e.g., rescheduled games in same round)
    const key = `${season}|||${round}|||${t}|||${opp}`

    if (!teamGameMap.has(key)) {
      teamGameMap.set(key, { team: t, season, round, opponent: opp, vals: {} })
      for (const c of sumCols) teamGameMap.get(key).vals[c] = 0
    }
    const entry = teamGameMap.get(key)
    for (const c of sumCols) {
      entry.vals[c] = (entry.vals[c] || 0) + (Number(row[c]) || 0)
    }
  }

  // Apply team filter
  let entries = [...teamGameMap.values()]
  if (teamFilter !== "All Teams") {
    entries = entries.filter(e => e.team === teamFilter)
  }

  // Home/Away filter
  if (homeAwayFilter !== "All") {
    const target = homeAwayFilter.toLowerCase()
    entries = entries.filter(e => {
      return homeAwayMap.get(`${e.season}-${e.round}-${e.team}`) === target
    })
  }

  // Build output rows
  const dp = catDef.format ? 3 : 1
  return entries.map(e => {
    const row = {
      team: shortName[e.team] || e.team,
      team_full: e.team,
      season: e.season,
      round: e.round,
      date: dateMap.get(`${e.season}-${e.round}-${e.team}-${e.opponent}`) || "",
      opponent: shortName[e.opponent] || e.opponent
    }
    for (const col of sumCols) {
      const v = e.vals[col]
      row[col] = isNaN(v) ? null : +(v.toFixed(dp))
    }
    // Computed columns
    if (row.goals != null || row.behinds != null) {
      row.score = (row.goals || 0) * 6 + (row.behinds || 0)
    }
    return row
  })
}
Show code
viewof search = tableData == null
  ? html``
  : Inputs.search(tableData, { placeholder: "Search teams..." })
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 = defs[category]
  const statCols = catDef.columns
  const renderTeamCell = window.aflTeamMaps?.renderTeamCell
  const showSeason = seasonFilter === "All Seasons"

  const idCols = showSeason
    ? ["team", "season", "round", "date", "opponent"]
    : ["team", "round", "date", "opponent"]
  const columns = [...idCols, ...statCols]

  const header = {
    team: "Team", season: "Season", round: "Rnd", date: "Date", opponent: "Vs",
    ...catDef.header
  }

  const format = {}
  format.date = v => {
    if (!v) return ""
    const d = new Date(v + "T00:00:00")
    return d.toLocaleDateString("en-AU", { day: "numeric", month: "short", year: "numeric" })
  }
  for (const col of statCols) {
    const dp = catDef.format?.[col] ?? 1
    format[col] = v => v != null ? Number(v).toFixed(dp) : ""
  }

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

  const render = {}
  if (renderTeamCell) {
    render.team = (val, row) => renderTeamCell(row.team_full || val)
    // Opponent always compact: logo + abbreviation
    render.opponent = (val) => {
      const esc = window.statsEsc || (s => s)
      const full = predToFull[val] || val
      const abbr = window.aflTeamMaps?.predToAbbr?.[full] || window.aflTeamMaps?.teamAbbrevMap?.[full] || val
      const logo = window.aflTeamMaps?.teamLogo?.(val)
      const logoHtml = logo ? '<img src="' + esc(logo) + '" alt="" style="width:18px;height:18px;object-fit:contain;vertical-align:middle">' : ""
      return '<span style="display:inline-flex;align-items:center;gap:0.3rem">' + logoHtml + esc(abbr) + '</span>'
    }
  }

  const groups = [
    { label: "", span: idCols.length },
    ...(catDef.groups || [{ 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,
    filters: { round: "range" }
  })
}
Show code
// ── Source attribution row ──────────────────────────────────
{
  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/torpdata"; a.target = "_blank"; a.rel = "noopener"
  a.textContent = "torpdata"
  left.appendChild(a)
  left.appendChild(document.createTextNode(" · Pete Owen · CC BY 4.0"))
  const right = document.createElement("span")
  right.textContent = "Refreshed after every round · One row per (team, match)"
  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

  const upd = railBlock("Last Updated")
  const stamp = document.createElement("div"); stamp.className = "update-stamp"
  stamp.textContent = "After every round"
  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("Team box scores refresh after every round via the "))
  const code = document.createElement("code")
  code.style.cssText = "font-family: 'JetBrains Mono', monospace; font-size: 0.85em; color: var(--site-body-color)"
  code.textContent = "torpdata"
  updP.appendChild(code)
  updP.appendChild(document.createTextNode(" pipeline."))
  upd.appendChild(updP); inner.appendChild(upd)

  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 team in one match"; p1.appendChild(s1)
  p1.appendChild(document.createTextNode(". Use this to find single-game extremes — biggest score, most pressure acts, lowest disposal count."))
  how.appendChild(p1)
  const p2 = document.createElement("p")
  p2.appendChild(document.createTextNode("Switch to "))
  const a = document.createElement("a"); a.href = "team-stats.html"; a.textContent = "Team Stats"
  p2.appendChild(a)
  p2.appendChild(document.createTextNode(" for season-long aggregates."))
  how.appendChild(p2)
  inner.appendChild(how)

  const read = railBlock("Read Next")
  const ul = document.createElement("ul"); ul.className = "rail-list"
  const links = [
    { href: "team-stats.html", title: "Team Stats", meta: "Season aggregates" },
    { href: "team-ratings.html", title: "Team Ratings", meta: "Opponent-adjusted strength" },
    { href: "game-logs.html", title: "Player Game Logs", meta: "Player-level single games" },
    { href: "ladder.html", title: "Ladder & Sims", meta: "Where each team finishes" }
  ]
  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