---
title: "TOPV Leaderboard"
description: "Season value for every AFL player — total, per game, by channel, and cut by zone, quarter and chain origin."
---

<script src="/data-loader.js"></script>
<script src="/stats-table.js"></script>

<!-- UNLISTED until the All-Australian stage-2 piece ships (issue #539):
     no navbar entry yet, not part of the Mon 2026-08-17 release commit. -->

Season **TOPV** — Total On-field Player Value, the sum of what every disposal,
mark and contest did to a team's expected score (EPV), blended 50/50 with
the box-score value model (PSV). Totals reward durability on purpose; per-game
sits beside them. The [All-Australian squad](/blog/2026-08-25-all-australian-topv-squad/)
was picked off this board, and the
[EPV](/blog/2026-08-25-how-epv-works/) / [PSV](/blog/2026-08-25-how-psv-works/) /
[TORP & TOPV](/blog/2026-08-25-torp-and-topv/) explainers cover the machinery.

```{ojs}
//| echo: false
tlSeasonData = {
  if (!window._tlGlPromise) {
    window._tlGlPromise = window.fetchParquet(window.DATA_BASE_URL + "afl/game-logs.parquet", {
      // MAINTENANCE: projected — a new column read here must be added.
      columns: ["player_id", "player_name", "team", "season", "net_points", "psv",
        "epv_recv", "epv_disp", "epv_spoil", "epv_hitout", "np_own", "np_won", "np_team"]
    }).catch((e) => { console.warn("[topv-leaderboard] game-logs load failed:", e); return null })
  }
  const rows = await window._tlGlPromise
  if (!rows || !rows.length) return null
  const seasons = [...new Set(rows.map((r) => Number(r.season)))].sort((a, b) => b - a)
  return { rows, seasons }
}
```

```{ojs}
//| echo: false
viewof tlSeason = Inputs.select(tlSeasonData ? tlSeasonData.seasons : [2026], { label: "Season", format: (d) => String(d) })
```

```{ojs}
//| echo: false
tlPlayers = {
  if (!tlSeasonData) return null
  const agg = new Map()
  for (const r of tlSeasonData.rows) {
    if (Number(r.season) !== Number(tlSeason)) continue
    const pid = String(r.player_id)
    let a = agg.get(pid)
    if (!a) { a = { pid, p: r.player_name, t: r.team, gm: 0, epv: 0, psv: 0, dv: 0, rv: 0, sv: 0, hv: 0 }; agg.set(pid, a) }
    a.gm++
    // net_points: the raw ledger share of the margin, the same EPV the match
    // page and every Value tab show (#679). Its own parts (np_*, torp 1.9.6)
    // add up to it; without them the centred rating channels are the fallback.
    a.epv += Number(r.net_points) || 0
    a.psv += Number(r.psv) || 0
    const np = r.np_own != null
    a.dv += Number(np ? r.np_own : r.epv_disp) || 0
    a.rv += Number(np ? r.np_won : r.epv_recv) || 0
    a.sv += Number(np ? r.np_team : r.epv_spoil) || 0
    a.hv += np ? 0 : (Number(r.epv_hitout) || 0)
    a.p = r.player_name; a.t = r.team
  }
  const out = [...agg.values()].map((a) => ({
    ...a, topv: 0.5 * a.epv + 0.5 * a.psv,
    pg: a.gm ? (0.5 * a.epv + 0.5 * a.psv) / a.gm : 0,
    cv: a.sv + a.hv
  }))
  out.sort((a, b) => b.topv - a.topv)
  return out
}
```

```{ojs}
//| echo: false
viewof tlSearch = Inputs.search(tlPlayers ?? [], { placeholder: "Search players…", columns: ["p", "t"] })
```

```{ojs}
//| echo: false
{
  if (!tlPlayers || !tlPlayers.length) return html`<p class="text-muted">Season value data is loading — if this persists, check the console.</p>`
  return window.statsTable(tlSearch, {
    columns: ["p", "t", "gm", "topv", "pg", "epv", "dv", "rv", "cv", "psv"],
    header: { p: "Player", t: "Team", gm: "GP", topv: "TOPV", pg: "TOPV/G", epv: "EPV", dv: "OWN ACTS", rv: "WON BACK", cv: "TEAM SHARE", psv: "PSV" },
    tooltip: {
      topv: "Total On-field Player Value — season EPV + PSV, blended 50/50",
      pg: "TOPV per game played",
      epv: "Season EPV — the sum of the player's per-match share of the margin from the play-by-play ledger. Own acts, Won back and Team share split it and add up to it.",
      dv: "Own acts — the player's decisions and the surprises they created, net of what their losses ceded. Part of EPV",
      rv: "Won back — turnovers, contests and stoppages won against the opposition. Part of EPV",
      cv: "Team share — the player's slice of the team pools plus the per-match anchor to the real margin. Usually negative, because each team's pool carries what the ledger could not pin on one player. Part of EPV",
      psv: "Season value from the box-score regression",
      gm: "Games played (home-and-away)"
    },
    format: Object.fromEntries(["topv", "pg", "epv", "dv", "rv", "cv", "psv"].map((c) => [c, (v) => (Number(v) || 0).toFixed(1)])),
    heatmap: { topv: "high-good", pg: "high-good", epv: "high-good", dv: "high-good", rv: "high-good", cv: "diverging", psv: "high-good" },
    filters: { gm: "range" },
    rows: 25
  })
}
```

## Cut the season, 2026

Where and when the value happened, from per-event chain credit — pick a zone,
quarter, or chain origin. These are the *where/when* view of the same season:
per-event credit is the chain model's public approximation, so cut totals are
labelled in their own column rather than pretending to be TOPV. "Wins added" is
season win-probability credit (WPA) — value weighted by how much the game
situation moved.

```{ojs}
//| echo: false
tlChains = {
  // Season-templated like every other page that reads a per-season chains file
  // (afl/match-chains.qmd, afl/match.qmd, afl/player.qmd all do this) — review
  // finding, 2026-08-16. Hardcoding 2026 meant that the moment 2027 rounds made
  // 2027 the default season, these boards would silently go blank: no error, no
  // render failure, nothing in the deploy pipeline to catch it.
  //
  // The cache is keyed BY SEASON. It used to be a single global promise, which
  // was harmless only while one season was reachable; with the season dynamic,
  // a shared promise would hand the previous season's chains to the new one.
  const season = Number(tlSeason)
  if (!Number.isFinite(season)) return { unavailable: true }
  window._tlChainsBySeason = window._tlChainsBySeason || {}
  if (!window._tlChainsBySeason[season]) {
    window._tlChainsBySeason[season] = window.fetchAflChainRows(season, {
      // MAINTENANCE: projected — a new column read here must be added.
      columns: ["match_id", "player_id", "lead_player_id", "x", "period",
        "venue_length", "player_credit", "wpa_disp", "wpa_recv", "initial_state"]
    }).catch((e) => { console.warn("[topv-leaderboard] chains load failed for", season, e); return null })
  }
  const rows = await window._tlChainsBySeason[season]
  // A season with no chains file is UNAVAILABLE, not "still loading". Returning
  // null left the two boards below sitting on "Loading…" forever, which reads
  // as a hang rather than as an answer.
  if (!rows || !rows.length) return { unavailable: true, season }
  const X_HALF = 82.5
  const events = []
  const wpa = new Map()
  const games = new Map()
  for (const r of rows) {
    const pid = r.player_id != null ? String(r.player_id) : null
    if (pid) {
      let g = games.get(pid)
      if (!g) { g = new Set(); games.set(pid, g) }
      g.add(r.match_id)
      const wd = Number(r.wpa_disp)
      if (!Number.isNaN(wd)) wpa.set(pid, (wpa.get(pid) || 0) + wd)
    }
    if (r.lead_player_id != null) {
      const lp = String(r.lead_player_id)
      const wr = Number(r.wpa_recv)
      if (!Number.isNaN(wr)) wpa.set(lp, (wpa.get(lp) || 0) + wr)
    }
    if (pid == null || r.player_credit == null || r.x == null) continue
    const nx = r.venue_length ? Number(r.x) * (X_HALF * 2 / Number(r.venue_length)) : Number(r.x)
    events.push({
      pid,
      credit: Number(r.player_credit),
      zone: nx < -32.5 ? "D50" : nx < -11 ? "Def mid" : nx < 11 ? "Centre" : nx < 32.5 ? "Fwd mid" : "F50",
      q: Number(r.period),
      origin: r.initial_state == null ? "other" : String(r.initial_state)
    })
  }
  return { events, wpa, games }
}
```

```{ojs}
//| echo: false
viewof tlZone = Inputs.radio(["All", "D50", "Def mid", "Centre", "Fwd mid", "F50"], { value: "All", label: "Zone" })
```

```{ojs}
//| echo: false
viewof tlQuarter = Inputs.radio(["All", "Q1", "Q2", "Q3", "Q4"], { value: "All", label: "Quarter" })
```

```{ojs}
//| echo: false
viewof tlOrigin = Inputs.radio(["All", "centreBounce", "ballUp", "throwIn", "kickIn", "possGain"], { value: "All", label: "Chain origin" })
```

```{ojs}
//| echo: false
{
  if (tlChains && tlChains.unavailable) return html`<p class="text-muted">Chain-credit cuts are not available for this season.</p>`
  if (!tlChains || !tlChains.events || !tlChains.events.length) return html`<p class="text-muted">Chain data is loading — if this persists, check the console.</p>`
  if (!tlPlayers) return html``
  const nameOf = new Map(tlPlayers.map((d) => [d.pid, { p: d.p, t: d.t }]))
  const q = tlQuarter === "All" ? null : Number(tlQuarter.slice(1))
  const agg = new Map()
  for (const e of tlChains.events) {
    if (tlZone !== "All" && e.zone !== tlZone) continue
    if (q !== null && e.q !== q) continue
    if (tlOrigin !== "All" && e.origin !== tlOrigin) continue
    let a = agg.get(e.pid)
    if (!a) { a = { credit: 0, n: 0 }; agg.set(e.pid, a) }
    a.credit += e.credit
    a.n++
  }
  const rows = []
  for (const [pid, a] of agg) {
    const g = tlChains.games.get(pid)
    if (!g || g.size < 12) continue
    const who = nameOf.get(pid)
    if (!who) continue
    rows.push({
      p: who.p, t: who.t, gm: g.size, n: a.n,
      credit: a.credit,
      wins: (tlChains.wpa.get(pid) || 0)
    })
  }
  rows.sort((a, b) => b.credit - a.credit)
  const scopeBits = [tlZone !== "All" ? tlZone : null, q !== null ? tlQuarter : null,
    tlOrigin !== "All" ? tlOrigin + " chains" : null].filter(Boolean)
  const scope = scopeBits.length ? scopeBits.join(" · ") : "whole ground, all quarters"
  const note = html`<p class="text-muted" style="font-size:0.85rem">Scope: <strong>${scope}</strong> — players with 12+ games; value is per-event chain credit inside the scope. Small scopes mean small samples: check the Events column before arguing with a number.</p>`
  const tbl = window.statsTable(rows, {
    columns: ["p", "t", "gm", "n", "credit", "wins"],
    header: { p: "Player", t: "Team", gm: "GP", n: "Events", credit: "Value", wins: "Wins added" },
    tooltip: {
      n: "Chain events by this player inside the selected scope",
      credit: "Sum of per-event chain credit inside the selected scope (expected-points scale)",
      wins: "Season win-probability credit across ALL events (not scoped) — value weighted by how much the game situation moved",
      gm: "Games played (2026 home-and-away)"
    },
    format: { credit: (v) => (Number(v) || 0).toFixed(1), wins: (v) => (Number(v) || 0).toFixed(2) },
    heatmap: { credit: "high-good", wins: "high-good" },
    rows: 15,
    sortId: "cuts"
  })
  const wrap = html`<div></div>`
  wrap.appendChild(note)
  wrap.appendChild(tbl)
  return wrap
}
```

## Two boards worth naming

The cuts above can build these, but they deserve their own tables. **Wins
added** is season win-probability credit — the same events, weighted by how
much each one moved the chance of winning, so scoreboard-swinging acts in
close games count for more than junk-time accumulation. **Kick-in launch
value** is chain credit inside chains that started from a kick-in — the
rebound-defender stat: who turns the opposition's miss into real field position.

```{ojs}
//| echo: false
{
  if (tlChains && tlChains.unavailable) return html`<p class="text-muted">Chain-credit data is not available for this season.</p>`
  if (!tlChains || !tlChains.wpa || !tlPlayers) return html`<p class="text-muted">Loading…</p>`
  const nameOf = new Map(tlPlayers.map((d) => [d.pid, { p: d.p, t: d.t }]))
  const rows = []
  for (const [pid, w] of tlChains.wpa) {
    const g = tlChains.games.get(pid)
    if (!g || g.size < 12) continue
    const who = nameOf.get(pid)
    if (!who) continue
    rows.push({ p: who.p, t: who.t, gm: g.size, wins: w, wpg: w / g.size })
  }
  rows.sort((a, b) => b.wins - a.wins)
  return window.statsTable(rows, {
    columns: ["p", "t", "gm", "wins", "wpg"],
    header: { p: "Player", t: "Team", gm: "GP", wins: "Wins added", wpg: "Per game" },
    tooltip: {
      wins: "Season win-probability credit (WPA): every event's value weighted by how much it moved the win chance — the situation-aware version of value",
      wpg: "Wins added per game played",
      gm: "Games played (2026 home-and-away)"
    },
    format: { wins: (v) => (Number(v) || 0).toFixed(2), wpg: (v) => (Number(v) || 0).toFixed(3) },
    heatmap: { wins: "high-good", wpg: "high-good" },
    rows: 10,
    sortId: "wpa"
  })
}
```

```{ojs}
//| echo: false
{
  if (tlChains && tlChains.unavailable) return html`<p class="text-muted">Chain-credit data is not available for this season.</p>`
  if (!tlChains || !tlChains.events || !tlPlayers) return html`<p class="text-muted">Loading…</p>`
  const nameOf = new Map(tlPlayers.map((d) => [d.pid, { p: d.p, t: d.t }]))
  const agg = new Map()
  for (const e of tlChains.events) {
    if (e.origin !== "kickIn") continue
    let a = agg.get(e.pid)
    if (!a) { a = { credit: 0, n: 0 }; agg.set(e.pid, a) }
    a.credit += e.credit
    a.n++
  }
  const rows = []
  for (const [pid, a] of agg) {
    const g = tlChains.games.get(pid)
    if (!g || g.size < 12) continue
    const who = nameOf.get(pid)
    if (!who) continue
    rows.push({ p: who.p, t: who.t, gm: g.size, n: a.n, credit: a.credit })
  }
  rows.sort((a, b) => b.credit - a.credit)
  return window.statsTable(rows, {
    columns: ["p", "t", "gm", "n", "credit"],
    header: { p: "Player", t: "Team", gm: "GP", n: "Events", credit: "Kick-in launch value" },
    tooltip: {
      credit: "Chain credit earned inside chains that started with a kick-in — value created launching from a defensive restart",
      n: "Chain events by this player inside kick-in chains",
      gm: "Games played (2026 home-and-away)"
    },
    format: { credit: (v) => (Number(v) || 0).toFixed(1) },
    heatmap: { credit: "high-good" },
    rows: 10,
    sortId: "kickin"
  })
}
```

The season table reads live from the same per-game value file as every match
page's Value tab. The [All-Australian article](/blog/2026-08-25-all-australian-topv-squad/)
froze this board at Round 22, so tiny differences after later rounds are the
board moving, not a disagreement. The cuts table reads per-event credit from
the public chain file — the same events at display precision — which is why its
column says "Value", not "TOPV".
