In The Game
  • Home
  • Blog
  • AFL
    • Overview

    • Player Stats
    • Player Ratings
    • Player Game Logs
    • Player Comparison

    • Team Stats
    • Team Game Logs
    • Team Ratings

    • Matches
    • Ladder
    • Definitions
  • Football
    • Overview

    • Player Stats
    • Player Ratings
    • Player Game Logs
    • Player Comparison

    • Team Stats
    • Team Game Logs
    • Team Ratings

    • Leagues
    • Matches
    • Definitions
  • About
Skip to content

AFL Ladder

Current ladder and Monte Carlo season projections — premiership odds, finals probabilities, and ladder position distributions

AFL > Ladder

Show code
// Dynamic page legend based on ladder view
{
  const desc = ladderView === "Current"
    ? `<span class="legend-tag legend-neutral">TORP</span> = best 21 team rating. <span class="legend-tag legend-good">Pts</span> = premiership points. <span class="legend-tag legend-neutral">%</span> = percentage (PF÷PA). Form shows last 5 results.`
    : `<span class="legend-tag legend-good">Higher %</span> = more likely. <span class="legend-tag legend-neutral">TORP</span> = best 21 team rating. Finals columns show probability of reaching each stage. Position table shows full ladder distribution.`
  return html`<div class="page-legend">${desc}</div>`
}
Show code
statsEsc = window.statsEsc
statsTable = window.statsTable

rawSimData = window.fetchParquet(window.DATA_BASE_URL + "afl/simulations.parquet")
playerRatings = window.fetchParquet(window.DATA_BASE_URL + "afl/ratings.parquet")
predictions = window.fetchParquet(window.DATA_BASE_URL + "afl/predictions.parquet")

// Aggregate player ratings → team TORP (top-21 avg, zero-centered) via shared helper
teamRatings = {
  if (!playerRatings) return null
  return window.aflTeamMaps?.aggregateTeamRatings?.(playerRatings) || null
}

aflFixtures = {
  const data = await window.fetchFixtures("afl")
  return data ? (data.games || []) : []
}

predToFull = window.aflTeamMaps?.predToFull || {}
fullToPred = window.aflTeamMaps?.fullToPred || {}
squiggleToPred = window.aflTeamMaps?.squiggleToPred || {}
predToAbbr = window.aflTeamMaps?.predToAbbr || {}
teamLogo = window.aflTeamMaps?.teamLogo || (() => null)

// Enrich sim data with games played and team TORP rating
simData = {
  if (!rawSimData) return null
  const torpMap = {}
  if (teamRatings) {
    for (const t of teamRatings) torpMap[t.team] = t.team_torp
  }
  return rawSimData.map(d => {
    const predName = fullToPred[d.team] || d.team
    const torp = torpMap[d.team] ?? torpMap[predName] ?? null
    return {
      ...d,
      games_played: (d.current_wins ?? 0) + (d.current_losses ?? 0),
      team_torp: torp
    }
  })
}
Show code
// Context bar — adapts to Current vs Projected view
{
  if (!simData || simData.length === 0) return html``
  const d = simData[0]
  const season = d.season ?? ""

  if (ladderView === "Current") {
    const completedGames = aflFixtures.filter(g => g.complete === 100)
    const latestRound = completedGames.length > 0 ? Math.max(...completedGames.map(g => g.round)) : (d.round ?? "")
    const gamesInRound = aflFixtures.filter(g => g.round === latestRound)
    const doneInRound = gamesInRound.filter(g => g.complete === 100).length
    const totalInRound = gamesInRound.length
    const roundLabel = doneInRound >= totalInRound
      ? `After Round ${latestRound}, ${season}`
      : `After ${doneInRound} game${doneInRound !== 1 ? "s" : ""} of Round ${latestRound}, ${season}`
    return html`<p class="text-muted" style="font-size:0.92em; margin-bottom:0.5rem;">
      ${roundLabel}
    </p>`
  }

  const round = d.round ?? ""
  const nSims = d.n_sims ? d.n_sims.toLocaleString() : ""
  return html`<p class="text-muted" style="font-size:0.92em; margin-bottom:0.5rem;">
    ${season} Season · As of Round ${round} · ${nSims} simulations
  </p>`
}
Show code
// Current / Projected toggle
viewof ladderView = {
  const wrap = document.createElement("div")
  wrap.className = "epv-toggle"
  wrap.value = "Current"
  for (const label of ["Current", "Projected"]) {
    const btn = document.createElement("button")
    btn.className = "epv-toggle-btn" + (label === "Current" ? " active" : "")
    btn.textContent = label
    btn.addEventListener("click", () => {
      wrap.querySelectorAll(".epv-toggle-btn").forEach(b => b.classList.remove("active"))
      btn.classList.add("active")
      wrap.value = label
      wrap.dispatchEvent(new Event("input", { bubbles: true }))
    })
    wrap.appendChild(btn)
  }
  return wrap
}

Ladder

Show code
// Build current ladder from predictions + live Squiggle fixture data
currentLadder = {
  if (!predictions || !simData) return null
  const season = simData[0]?.season
  if (!season) return null

  // Normalize any team name format to canonical (e.g. "Sydney" → "Sydney Swans")
  const norm = (name) => {
    const result = predToFull[squiggleToPred[name] || name] || predToFull[name]
    if (!result) console.warn(`[ladder] Unknown team name: ${name}`)
    return result || name
  }
  const toAbbr = (name) => predToAbbr[norm(name)] || name

  // Track which games we've already counted (by "round|home|away" key)
  const counted = new Set()

  const teams = {}
  const ensureTeam = (t) => {
    if (!teams[t]) teams[t] = { team: t, W: 0, L: 0, D: 0, PF: 0, PA: 0, form: [] }
  }

  // Seed all 18 teams from simulations so every team appears (even with GP=0)
  for (const d of simData) ensureTeam(d.team)

  // Pre-compute finished Squiggle fixtures (needed for score tooltips in both loops)
  const squiggleFinished = (aflFixtures || []).filter(g => g.complete === 100 && g.hscore != null)
    .sort((a, b) => (a.round || 0) - (b.round || 0))

  // 1) Count games from predictions parquet (where actual_margin is set)
  const seasonPreds = predictions.filter(d => d.season === season && d.actual_margin != null && !isNaN(d.actual_margin))
  for (const m of seasonPreds) {
    const h = norm(m.home_team), a = norm(m.away_team)
    ensureTeam(h); ensureTeam(a)
    counted.add(`${m.round}|${h}|${a}`)

    teams[h].PF += m.pred_total != null ? Math.round((m.pred_total + m.actual_margin) / 2) : 0
    teams[h].PA += m.pred_total != null ? Math.round((m.pred_total - m.actual_margin) / 2) : 0
    teams[a].PF += m.pred_total != null ? Math.round((m.pred_total - m.actual_margin) / 2) : 0
    teams[a].PA += m.pred_total != null ? Math.round((m.pred_total + m.actual_margin) / 2) : 0

    const url = `match#season=${season}&round=${m.round}&home=${toAbbr(h)}&away=${toAbbr(a)}`
    const hAb = toAbbr(h), aAb = toAbbr(a)
    // Best available scores for tooltip — use squiggleFinished (declared before loop)
    const hScore = squiggleFinished.find(g => g.round === m.round && norm(g.hteam) === h)?.hscore
      ?? (m.pred_total != null ? Math.round((m.pred_total + m.actual_margin) / 2) : null)
    const aScore = squiggleFinished.find(g => g.round === m.round && norm(g.hteam) === h)?.ascore
      ?? (m.pred_total != null ? Math.round((m.pred_total - m.actual_margin) / 2) : null)
    const scoreStr = hScore != null ? ` (${hScore}–${aScore})` : ""
    if (m.actual_margin > 0) {
      teams[h].W++; teams[a].L++
      teams[h].form.push({ result: "W", url, round: m.round, tip: `${hAb} v ${aAb}${scoreStr} W` })
      teams[a].form.push({ result: "L", url, round: m.round, tip: `${hAb} v ${aAb}${scoreStr} L` })
    } else if (m.actual_margin < 0) {
      teams[h].L++; teams[a].W++
      teams[h].form.push({ result: "L", url, round: m.round, tip: `${hAb} v ${aAb}${scoreStr} L` })
      teams[a].form.push({ result: "W", url, round: m.round, tip: `${hAb} v ${aAb}${scoreStr} W` })
    } else {
      teams[h].D++; teams[a].D++
      teams[h].form.push({ result: "D", url, round: m.round, tip: `${hAb} v ${aAb}${scoreStr} D` })
      teams[a].form.push({ result: "D", url, round: m.round, tip: `${hAb} v ${aAb}${scoreStr} D` })
    }
  }

  // 2) Add completed Squiggle fixtures NOT already counted (live results)
  for (const g of squiggleFinished) {
    const h = norm(g.hteam), a = norm(g.ateam)
    const key = `${g.round}|${h}|${a}`
    if (counted.has(key)) continue
    counted.add(key)

    ensureTeam(h); ensureTeam(a)
    const url = `match#season=${season}&round=${g.round}&home=${toAbbr(g.hteam)}&away=${toAbbr(g.ateam)}`
    const hAb = toAbbr(h), aAb = toAbbr(a)
    const scoreStr = ` (${g.hscore}–${g.ascore})`

    teams[h].PF += g.hscore; teams[h].PA += g.ascore
    teams[a].PF += g.ascore; teams[a].PA += g.hscore

    if (g.hscore > g.ascore) {
      teams[h].W++; teams[a].L++
      teams[h].form.push({ result: "W", url, round: g.round, tip: `${hAb} v ${aAb}${scoreStr} W` })
      teams[a].form.push({ result: "L", url, round: g.round, tip: `${hAb} v ${aAb}${scoreStr} L` })
    } else if (g.hscore < g.ascore) {
      teams[h].L++; teams[a].W++
      teams[h].form.push({ result: "L", url, round: g.round, tip: `${hAb} v ${aAb}${scoreStr} L` })
      teams[a].form.push({ result: "W", url, round: g.round, tip: `${hAb} v ${aAb}${scoreStr} W` })
    } else {
      teams[h].D++; teams[a].D++
      teams[h].form.push({ result: "D", url, round: g.round, tip: `${hAb} v ${aAb}${scoreStr} D` })
      teams[a].form.push({ result: "D", url, round: g.round, tip: `${hAb} v ${aAb}${scoreStr} D` })
    }
  }

  // 3) Override PF/PA from Squiggle scores (more accurate than derived)
  if (squiggleFinished.length > 0) {
    const sqPF = {}, sqPA = {}
    for (const g of squiggleFinished) {
      const h = norm(g.hteam), a = norm(g.ateam)
      sqPF[h] = (sqPF[h] || 0) + g.hscore; sqPA[h] = (sqPA[h] || 0) + g.ascore
      sqPF[a] = (sqPF[a] || 0) + g.ascore; sqPA[a] = (sqPA[a] || 0) + g.hscore
    }
    for (const key of Object.keys(teams)) {
      if (sqPF[key] != null) { teams[key].PF = sqPF[key]; teams[key].PA = sqPA[key] }
    }
  }

  // 4) Compute ladder columns
  const torpMap = {}
  if (teamRatings) for (const t of teamRatings) torpMap[t.team] = t.team_torp

  return Object.values(teams).map(t => {
    const GP = t.W + t.L + t.D
    const pts = t.W * 4 + t.D * 2
    const pct = t.PA > 0 ? (t.PF / t.PA * 100) : 0
    const torp = torpMap[t.team] ?? torpMap[predToFull[t.team] || t.team] ?? null
    const displayName = predToFull[t.team] || t.team
    // Sort form by round, take last 5
    const sortedForm = t.form.sort((a, b) => a.round - b.round).slice(-5)
    return {
      team: displayName,
      _predName: t.team,
      GP, W: t.W, L: t.L, D: t.D, pts,
      PF: t.PF, PA: t.PA,
      PD: t.PF - t.PA,
      pct,
      team_torp: torp,
      form: sortedForm
    }
  }).sort((a, b) => b.pts - a.pts || b.pct - a.pct)
}
Show code
// Render Current Ladder
{
  if (ladderView !== "Current") return html``
  if (!currentLadder || currentLadder.length === 0) {
    return html`<p class="text-muted">No results available yet.</p>`
  }

  // Add rank
  const data = currentLadder.map((t, i) => ({ ...t, rank: i + 1 }))

  const formDots = (form) => {
    if (!form || form.length === 0) return ""
    return `<span class="form-dots">${form.map(r =>
      `<span class="form-dot form-${r.result.toLowerCase()}" data-tip="${statsEsc(r.tip || "")}" role="link" tabindex="0" onclick="window.location.href='${r.url}'"></span>`
    ).join("")}</span>`
  }

  return statsTable(data, {
    columns: ["rank", "team", "GP", "W", "L", "D", "pts", "PF", "PA", "PD", "pct", "team_torp", "form"],
    mobileCols: ["rank", "team", "W", "L", "pts", "pct", "form"],
    header: {
      rank: "#", team: "Team", GP: "GP", W: "W", L: "L", D: "D", pts: "Pts",
      PF: "PF", PA: "PA", PD: "PD", pct: "%", team_torp: "TORP", form: "Form"
    },
    groups: [
      { label: "", span: 2 },
      { label: "Record", span: 5 },
      { label: "Points", span: 4 },
      { label: "", span: 2 }
    ],
    format: {
      pct: x => x?.toFixed(1) ?? "",
      team_torp: x => x?.toFixed(1) ?? "—"
    },
    render: {
      team: window.aflTeamMaps?.renderTeamCell || ((v) => `<strong>${statsEsc(v)}</strong>`),
      form: (v) => formDots(v)
    },
    tooltip: {
      GP: "Games played", W: "Wins", L: "Losses", D: "Draws",
      pts: "Premiership points (W=4, D=2)", PF: "Points for", PA: "Points against",
      PD: "Points differential (PF − PA)", pct: "Percentage (PF ÷ PA × 100)",
      team_torp: "Average TORP rating of the team's players", form: "Recent match results"
    },
    heatmap: {
      pts: "high-good",
      pct: "high-good",
      PD: "high-good",
      team_torp: "high-good"
    },
    sort: "rank",
    rows: 20
  })
}
Show code
// Render Projected Ladder
{
  if (ladderView !== "Projected") return html``
  if (simData == null || simData.length === 0) {
    return html`<p class="text-muted">Simulation data could not be loaded. Try refreshing the page.</p>`
  }

  const pctFmt = x => x != null ? (x * 100).toFixed(1) + "%" : ""

  return statsTable(simData, {
    columns: [
      "team", "games_played", "current_wins", "current_pct", "team_torp",
      "avg_wins", "avg_percentage", "avg_rank",
      "premiers_pct", "runner_up_pct", "lose_prelim_pct", "lose_semi_pct", "lose_elim_pct",
      "top_1_pct", "top_4_pct", "top_8_pct", "last_pct"
    ],
    mobileCols: ["team", "avg_wins", "avg_rank", "premiers_pct", "top_4_pct", "top_8_pct"],
    header: {
      team: "Team",
      games_played: "GP",
      current_wins: "W",
      current_pct: "%",
      team_torp: "TORP",
      avg_wins: "Wins",
      avg_percentage: "%",
      avg_rank: "Rank",
      premiers_pct: "Flag",
      runner_up_pct: "Runner Up",
      lose_prelim_pct: "Prelim",
      lose_semi_pct: "Semi",
      lose_elim_pct: "Elim",
      top_1_pct: "Minor Prem",
      top_4_pct: "Top 4",
      top_8_pct: "Top 8",
      last_pct: "Spoon"
    },
    groups: [
      { label: "", span: 1 },
      { label: "Current", span: 4 },
      { label: "Projected Ladder", span: 3 },
      { label: "Finals Outcome", span: 5 },
      { label: "Ladder Odds", span: 4 }
    ],
    format: {
      current_pct: x => x != null ? x.toFixed(1) : "—",
      team_torp: x => x?.toFixed(1) ?? "—",
      avg_wins: x => x?.toFixed(1) ?? "",
      avg_percentage: x => x?.toFixed(1) ?? "",
      avg_rank: x => x?.toFixed(1) ?? "",
      premiers_pct: pctFmt,
      runner_up_pct: pctFmt,
      lose_prelim_pct: pctFmt,
      lose_semi_pct: pctFmt,
      lose_elim_pct: pctFmt,
      top_1_pct: pctFmt,
      top_4_pct: pctFmt,
      top_8_pct: pctFmt,
      last_pct: pctFmt
    },
    render: {
      team: window.aflTeamMaps?.renderTeamCell || ((v) => `<strong>${statsEsc(v)}</strong>`)
    },
    heatmap: {
      team_torp: "high-good",
      avg_wins: "high-good",
      avg_percentage: "high-good",
      avg_rank: "low-good",
      premiers_pct: "high-good",
      runner_up_pct: "high-good",
      lose_prelim_pct: "high-good",
      lose_semi_pct: "high-good",
      lose_elim_pct: "high-good",
      top_1_pct: "high-good",
      top_4_pct: "high-good",
      top_8_pct: "high-good",
      last_pct: "low-good"
    },
    tooltip: {
      games_played: "Games played so far", current_wins: "Current wins", current_pct: "Current percentage",
      team_torp: "Average TORP rating of the team's players",
      avg_wins: "Average projected wins across simulations",
      avg_percentage: "Average projected percentage", avg_rank: "Average projected ladder position",
      premiers_pct: "Probability of winning the premiership",
      runner_up_pct: "Probability of finishing runner-up",
      lose_prelim_pct: "Probability of losing in a preliminary final",
      lose_semi_pct: "Probability of losing in a semi-final",
      lose_elim_pct: "Probability of losing in an elimination final",
      top_1_pct: "Probability of finishing 1st (minor premiership)",
      top_4_pct: "Probability of finishing in the top 4",
      top_8_pct: "Probability of finishing in the top 8 (finals)",
      last_pct: "Probability of finishing last (wooden spoon)"
    },
    sort: "avg_wins",
    reverse: true,
    rows: 20
  })
}

Recent Results

Show code
{
  const finished = aflFixtures
    .filter(g => g.complete === 100 && g.hscore != null)
    .sort((a, b) => (b.date || "").localeCompare(a.date || ""))
    .slice(0, 15)

  if (finished.length === 0) return html`<p class="text-muted">No recent results available.</p>`

  const formatDate = (iso) => {
    if (!iso) return ""
    const d = new Date(iso)
    return d.toLocaleDateString("en-GB", { weekday: "short", day: "numeric", month: "short" })
  }

  const norm = (name) => {
    const result = predToFull[squiggleToPred[name] || name] || predToFull[name]
    if (!result) console.warn(`[ladder] Unknown team name: ${name}`)
    return result || name
  }
  const toAbbr = (name) => predToAbbr[norm(name)] || name

  const groups = new Map()
  for (const g of finished) {
    const dateKey = (g.date || "").slice(0, 10)
    if (!groups.has(dateKey)) groups.set(dateKey, [])
    groups.get(dateKey).push(g)
  }

  const el = document.createElement("div")
  el.className = "recent-results"

  for (const [dateKey, matches] of groups) {
    const dateHeader = document.createElement("div")
    dateHeader.className = "recent-results-date"
    dateHeader.textContent = formatDate(dateKey)
    el.appendChild(dateHeader)

    for (const g of matches) {
      const hName = norm(g.hteam)
      const aName = norm(g.ateam)
      const homeWin = g.hscore > g.ascore
      const season = simData?.[0]?.season || new Date().getFullYear()

      // Make the result row a clickable link to match page
      const link = document.createElement("a")
      link.href = `match#season=${season}&round=${g.round}&home=${toAbbr(g.hteam)}&away=${toAbbr(g.ateam)}`
      link.className = "recent-result-row"
      link.style.textDecoration = "none"
      link.style.color = "inherit"

      const homeName = document.createElement("span")
      homeName.className = homeWin ? "winner" : ""
      homeName.textContent = hName
      link.appendChild(homeName)

      const score = document.createElement("span")
      score.className = "result-score"
      score.textContent = ` ${g.hscore} – ${g.ascore} `
      link.appendChild(score)

      const awayName = document.createElement("span")
      awayName.className = homeWin ? "" : "winner"
      awayName.textContent = aName
      link.appendChild(awayName)

      if (g.venue) {
        const venue = document.createElement("span")
        venue.className = "result-venue"
        venue.textContent = ` · ${g.venue}`
        link.appendChild(venue)
      }

      el.appendChild(link)
    }
  }

  return el
}

Position Distribution

Show code
{
  if (simData == null || simData.length === 0) return html``

  const posCols = Array.from({length: 18}, (_, i) => `pos_${i + 1}_pct`)
  const posHeaders = {}
  for (let i = 1; i <= 18; i++) posHeaders[`pos_${i}_pct`] = String(i)

  const posHeatmap = {}
  for (const c of posCols) posHeatmap[c] = "high-good"

  const posFmt = {}
  for (const c of posCols) {
    posFmt[c] = x => x != null && x >= 0.005 ? (x * 100).toFixed(0) + "%" : ""
  }

  return statsTable(simData, {
    columns: ["team", ...posCols],
    mobileCols: ["team", "pos_1_pct", "pos_2_pct", "pos_4_pct", "pos_8_pct", "pos_18_pct"],
    header: { team: "Team", ...posHeaders },
    groups: [
      { label: "", span: 1 },
      { label: "Ladder Position", span: 18 }
    ],
    format: posFmt,
    render: {
      team: window.aflTeamMaps?.renderTeamCell || ((v) => `<strong>${statsEsc(v)}</strong>`)
    },
    tooltip: Object.fromEntries(Array.from({length: 18}, (_, i) => [`pos_${i+1}_pct`, `Probability of finishing ${i+1}${i===0?"st":i===1?"nd":i===2?"rd":"th"}`])),
    heatmap: posHeatmap,
    sort: "avg_wins",
    reverse: true,
    rows: 20
  })
}
 

Pete Owen · Sydney · © 2026 · Source

Privacy | Disclaimer