Rocket League still has no official tier list. Psyonix has never shipped one, and it has never opened a public API for car usage or hitbox data either. Every “best cars” ranking you see online comes from someone manually watching replays, reading patch notes, and guessing at pro usage rates. That gap is exactly why a personal, code-driven tracker is worth building: instead of trusting a stranger’s snapshot from six months ago, you feed in your own weighted signals and regenerate the list whenever the meta moves. This tutorial walks through building that tool from scratch in Python, covering the six official hitbox classes, a scoring engine you control, a small web view, and a diff command that shows how the meta shifted between patches.
By the end you will have a working command-line tracker backed by SQLite, a Flask page that renders the current tier list as a sortable table, and a repeatable process for updating rankings as Rocket League Championship Series (RLCS) results and ranked-ladder data change. The target audience is players and hobbyist developers who want a real project, not just a spreadsheet, so every step includes runnable code.
Rocket League has been free-to-play on Steam since 2020, and that shift is part of why hitbox debates never really settle. A larger, more casual player base means more people picking cars on looks alone, while the competitive scene keeps converging on a narrower set of physically optimal choices. A tracker that separates those two audiences, rather than blending them into one vague ranking, is more useful than either group’s opinion alone.
What You’ll Need: Prerequisites and Versions
Nothing here is exotic. You need a machine that can run Python and a text editor. Install the following before starting:
- Python 3.11 or newer (3.12 recommended) with pip
- Flask 3.0 or newer for the web view
- requests 2.32 or newer, only needed if you later pull data from a stats page you maintain yourself
- SQLite 3.40+, bundled with Python’s standard library, so no separate install is required
- A code editor (VS Code, PyCharm, or similar)
- About 30 minutes for the base build, plus extra time if you add the automation step
You do not need a Rocket League account, in-game access, or Epic Games credentials for this build. Everything here runs against a local dataset you control.
Step 1: Set Up the Project and Environment
Create a project folder, isolate it with a virtual environment, and install the two runtime dependencies. Keeping the environment isolated matters: Flask’s version drift between 2.x and 3.x changes a few defaults you touch later in the web view step.
mkdir rl-tier-tracker
cd rl-tier-tracker
python3 -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
pip install flask==3.0.3 requests==2.32.3
mkdir data src templates
touch src/__init__.py
The data folder holds the JSON reference files and the SQLite database. The src folder holds every Python module you write in the steps below. Keep them separate; it makes the diff tool in step 11 easier to reason about.
Step 2: Map All Six Hitbox Classes to Cars
Rocket League still uses six default hitboxes, officially called body types: Octane, Dominus, Plank, Breakout, Hybrid, and Merc, according to Epic Games’ own help documentation and the Rocket League Wiki. Every car skin in the game inherits the physical collision box of one of these six classes, regardless of how different the model looks. A Fennec and a stock Octane behave identically in a 50/50 because they share the Octane hitbox.
Four of the six hitboxes have well-documented physical dimensions from community measurement projects. Use these numbers as the seed values for your tracker’s reference table:
| Hitbox | Length | Width | Height | Best For |
|---|---|---|---|---|
| Octane | 118.01 | 84.20 | 36.16 | All-around play: dribbling, aerials, challenges |
| Dominus | 127.93 | 83.28 | 31.30 | Flicks, power shots, ground play |
| Breakout | 131.49 | 80.53 | 30.30 | Pinches, power shots, precision |
| Hybrid | 127.02 | 82.19 | 34.16 | Flip resets, balanced play |
| Plank | unmeasured, flattest of the six | — | lowest profile | Ground-based flicks, low-to-ball dribbling |
| Merc | unmeasured, shortest length | — | tallest, narrowest | Off-meta, front-flip-heavy playstyles |
Plank and Merc dimensions vary more across community measurement tools, so treat the qualitative description as the reliable part and re-measure locally in Freeplay if you want exact figures. Populate a JSON seed file with this table now, since every later module reads from it.
{
"hitboxes": {
"octane": {"length": 118.01, "width": 84.20, "height": 36.16},
"dominus": {"length": 127.93, "width": 83.28, "height": 31.30},
"breakout": {"length": 131.49, "width": 80.53, "height": 30.30},
"hybrid": {"length": 127.02, "width": 82.19, "height": 34.16},
"plank": {"length": null, "width": null, "height": null},
"merc": {"length": null, "width": null, "height": null}
}
}
Save this as data/hitboxes.json. Leaving the unverified dimensions as null instead of guessing keeps your dataset honest and stops the scoring engine from silently trusting a made-up number.
Step 3: Design the Car Database Schema
Next, define the shape of a single car record. Keep it minimal at first: a name, the hitbox it inherits, and a placeholder for the meta score you calculate later. A dataclass catches typos at import time instead of at runtime three steps later.
# src/models.py
from dataclasses import dataclass
@dataclass
class Car:
name: str
hitbox: str
meta_score: float = 0.0
tier: str = "unranked"
notes: str = ""
# Seed roster grouped by confirmed hitbox class
ROSTER = [
Car("Octane", "octane"),
Car("Fennec", "octane"),
Car("Dingo", "octane"),
Car("Takumi", "octane"),
Car("Honda Civic Type R", "octane"),
Car("Dominus", "dominus"),
Car("Dominus GT", "dominus"),
Car("Nissan Skyline GT-R R34", "dominus"),
Car("Breakout", "breakout"),
Car("Breakout Type-S", "breakout"),
Car("Jager 619", "hybrid"),
Car("Endo", "hybrid"),
Car("Twinzer", "hybrid"),
Car("Nimbus", "hybrid"),
Car("Imperator DT5", "hybrid"),
Car("Merc", "merc"),
Car("Road Hog", "merc"),
Car("Backfire", "merc"),
]
This roster is deliberately incomplete. Rocket League has released well over a hundred car bodies since 2015, and new ones ship every season. Add rows as you confirm hitbox assignments rather than bulk-importing an unverified list, since a wrong hitbox mapping quietly wrecks every score that depends on it.
Step 4: Why Rocket League Has No Public API
Before writing the scoring engine, it helps to understand why this tracker pulls from curated inputs instead of a live feed. Psyonix has never published an official public API for match, car, or hitbox usage data. The closest official resource is Epic Games’ help-center hitbox page, which documents the six body types but exposes no queryable endpoint.
Two community tools fill part of the gap, and both come with caveats worth knowing before building around them. Ballchasing.com still parses individual replay files for match statistics, but automatic replay uploads were removed after Rocket League’s anti-cheat changes, so every replay now has to be uploaded manually. BakkesMod remains a popular client-side plugin for in-game overlays and stat tracking on PC, but it is a local plugin, not a hosted web API, so it cannot feed a server-side tracker directly.
The practical takeaway: treat car usage numbers as curated inputs you update by hand or by scraping a source you trust, not as a live feed you can poll every hour. That constraint is exactly what step 5 designs around.
Step 5: Define Weighted Meta-Signal Inputs
A useful tier list blends more than one signal. Pro play tells you what elite teams pick under tournament pressure. Ranked prevalence tells you what most of the player base actually drives. Community sentiment, gathered from patch-note reactions and forum polls, catches shifts before hard usage data exists. Give each signal a weight that sums to 1.0 so the final score stays on a predictable 0 to 100 scale.
# src/signals.py
SIGNAL_WEIGHTS = {
"pro_usage": 0.45, # RLCS and Major usage share, 0-100
"ranked_usage": 0.35, # estimated ladder pick rate, 0-100
"community_sentiment": 0.20, # forum/poll sentiment, 0-100
}
# Manually updated after each RLCS event or patch
RAW_SIGNALS = {
"octane": {"pro_usage": 55, "ranked_usage": 60, "community_sentiment": 70},
"dominus": {"pro_usage": 8, "ranked_usage": 15, "community_sentiment": 55},
"breakout": {"pro_usage": 1, "ranked_usage": 5, "community_sentiment": 40},
"hybrid": {"pro_usage": 1, "ranked_usage": 6, "community_sentiment": 42},
"plank": {"pro_usage": 0, "ranked_usage": 3, "community_sentiment": 30},
"merc": {"pro_usage": 0, "ranked_usage": 2, "community_sentiment": 25},
}
The pro_usage starting values above reflect a 2026 competitive usage breakdown that put Octane-hitbox cars, meaning Octane and Fennec combined, above 90% of professional loadouts, with Octane itself near 55% and Dominus a distant second at roughly 8%. Update these numbers after every Major and World Championship instead of leaving them static; the whole point of the tracker is that it moves when the game does.
Step 6: Write the Scoring Engine
With weights and raw signals defined, the scoring function is a weighted sum, rolled up from the hitbox level to every car that shares that hitbox. Cars inherit their hitbox’s meta score directly, since Rocket League gives every car in a hitbox class identical physics.
# src/scoring.py
from src.signals import SIGNAL_WEIGHTS, RAW_SIGNALS
def score_hitbox(hitbox: str) -> float:
signals = RAW_SIGNALS.get(hitbox)
if not signals:
raise ValueError(f"No signal data for hitbox: {hitbox}")
total = sum(signals[key] * weight for key, weight in SIGNAL_WEIGHTS.items())
return round(total, 2)
def score_all_hitboxes() -> dict:
return {hb: score_hitbox(hb) for hb in RAW_SIGNALS}
if __name__ == "__main__":
for hitbox, score in sorted(score_all_hitboxes().items(), key=lambda x: -x[1]):
print(f"{hitbox:>10}: {score}")
Running that file directly prints a quick sanity check, worth doing before wiring anything else together:
$ python -m src.scoring
octane: 60.0
dominus: 11.65
hybrid: 2.85
breakout: 1.7
plank: 1.2
merc: 0.65
That output already tells a story: one hitbox class dominates by a wide margin, matching what pro-play observers have been saying about the current season. Your tracker just turned a qualitative claim into a reproducible number.
Step 7: Turn Scores Into Tiers
Raw scores are useful for sorting, but most readers want tier letters. Pick thresholds that fit the spread of your scored data rather than copying someone else’s cutoffs verbatim, since your weighting scheme in step 5 changes where the natural breakpoints fall.
# src/tiers.py
TIER_THRESHOLDS = [
(40.0, "S"),
(15.0, "A"),
(5.0, "B"),
(1.5, "C"),
(0.0, "D"),
]
def assign_tier(score: float) -> str:
for threshold, tier in TIER_THRESHOLDS:
if score >= threshold:
return tier
return "D"
With the sample scores from step 6, Octane lands in S tier alone, Dominus lands in A, Hybrid and Breakout land in C, and Plank and Merc land in D. Adjust the thresholds after plugging in a full car roster, since six hitbox-level scores compress into a much wider spread once dozens of individual cars are involved.
Step 8: Build the Command-Line Interface
A CLI turns the modules above into something you actually run day to day. argparse from the standard library is enough here; a three-command tool does not need a third-party dependency.
# src/cli.py
import argparse
from src.models import ROSTER
from src.scoring import score_hitbox
from src.tiers import assign_tier
def cmd_rebuild(args):
for car in ROSTER:
car.meta_score = score_hitbox(car.hitbox)
car.tier = assign_tier(car.meta_score)
ROSTER.sort(key=lambda c: -c.meta_score)
for car in ROSTER:
print(f"[{car.tier}] {car.name:<26} {car.meta_score}")
def main():
parser = argparse.ArgumentParser(prog="tracker")
sub = parser.add_subparsers(dest="command", required=True)
sub.add_parser("rebuild").set_defaults(func=cmd_rebuild)
args = parser.parse_args()
args.func(args)
if __name__ == "__main__":
main()
Run python -m src.cli rebuild and you get a ranked, tiered list of every car in your roster, sorted from highest score to lowest, printed straight to the terminal. That single command is the core loop you call again after every roster or signal update.
Step 9: Persist Data With SQLite and Version Snapshots
Printing to the terminal is fine for testing, but a tracker only becomes useful once you can compare today's list against last month's. SQLite, bundled with Python, is the simplest way to store dated snapshots without standing up a separate database server.
-- data/schema.sql
CREATE TABLE IF NOT EXISTS snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
captured_at TEXT NOT NULL,
car_name TEXT NOT NULL,
hitbox TEXT NOT NULL,
meta_score REAL NOT NULL,
tier TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_snapshot_date ON snapshots (captured_at);
Load that schema once with sqlite3 data/tracker.db < data/schema.sql, then write a small helper that inserts one row per car every time you rebuild. Tag each batch with the same ISO date string so a single query can pull one full snapshot back out later. This piece is what makes the diff tool in step 11 possible, so do not skip it even if you only care about the current list right now.
Step 10: Render the Tier List as a Web Page
A terminal table works for you, but a browser view makes the tracker shareable. Flask, installed back in step 1, needs only one route and one template to turn the latest snapshot into an HTML page.
# src/webapp.py
import sqlite3
from flask import Flask, render_template
app = Flask(__name__)
DB_PATH = "data/tracker.db"
@app.route("/")
def tier_list():
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
latest_date = conn.execute(
"SELECT MAX(captured_at) AS d FROM snapshots"
).fetchone()["d"]
rows = conn.execute(
"SELECT * FROM snapshots WHERE captured_at = ? ORDER BY meta_score DESC",
(latest_date,),
).fetchall()
conn.close()
return render_template("tier_list.html", rows=rows, date=latest_date)
if __name__ == "__main__":
app.run(debug=True, port=5000)
Add a matching templates/tier_list.html with a basic Jinja2 loop over rows, then run python -m src.webapp and open http://127.0.0.1:5000. You now have a locally hosted, auto-updating tier list page instead of a static screenshot that goes stale the day after you post it.
Step 11: Add a Patch-Over-Patch Diff Tool
The most interesting output of a tracker is not the current list, it is what changed. A diff command that compares two snapshot dates turns your SQLite table into a movement report: which cars climbed a tier, which dropped, and by how much their score shifted.
# src/diff.py
import sqlite3
def diff_snapshots(db_path, date_a, date_b):
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
def snapshot(date):
rows = conn.execute(
"SELECT car_name, meta_score, tier FROM snapshots WHERE captured_at = ?",
(date,),
).fetchall()
return {r["car_name"]: r for r in rows}
a, b = snapshot(date_a), snapshot(date_b)
for name in sorted(set(a) | set(b)):
old, new = a.get(name), b.get(name)
if old and new and old["tier"] != new["tier"]:
print(f"{name}: {old['tier']} -> {new['tier']} "
f"({old['meta_score']} -> {new['meta_score']})")
conn.close()
Wire this into the CLI from step 8 as a diff subcommand that takes two dates as arguments. Run it after every RLCS Major or World Championship, since those events are when raw pro-usage numbers actually move and the rest of the meta tends to follow within a few weeks.
Step 12: Automate Refreshes and Deploy the Tracker
Because there is no live API to poll, automation here means scheduling a reminder to update the signal file, not scheduling a scraper that runs unattended. A cron entry that runs the rebuild command weekly, paired with a manual checklist to review RLCS results, keeps the tracker honest without pretending to be more automated than the data source allows.
# crontab -e
# Rebuild and snapshot every Monday at 09:00
0 9 * * 1 cd /path/to/rl-tier-tracker && venv/bin/python -m src.cli rebuild >> logs/rebuild.log 2>&1
To deploy the Flask view beyond your own machine, run it behind gunicorn and a reverse proxy such as nginx or Caddy, or host it as a small container on any low-cost VPS. The complete project, once you have worked through all twelve steps, consists of six files under src/, two data files under data/, one Jinja2 template, one crontab entry, and roughly 220 lines of Python. That is the whole tracker: no external API keys, no paid services, and no dependency on a data source that could disappear overnight.
The Rocket League Car Meta Right Now: September 2026
Feed your tracker real 2026 inputs and the results line up with what the competitive scene has been saying since the season started. Octane and Fennec, both riding the Octane hitbox, remain the default picks at every level above casual ranked. One 2026 competitive usage breakdown put Octane at roughly 55% of professional loadouts and Fennec at close to 35%, with Dominus a distant third near 8% and every other hitbox combined splitting the remaining 2%. Put differently, Octane-hitbox cars covered more than 90% of pro play.
Rocket League's Steam player base has stayed active enough to keep that meta data flowing. SteamCharts recorded an average of roughly 16,200 concurrent players in August 2026, up 1.43% month over month, with a peak of 28,399 players during the month. That is a mature, still-growing base nearly six years after the game went free-to-play, which is part of why hitbox meta discussions keep circulating on forums and stat sites.
How This Tracker Differs From a Static Fan-Site Tier List
Most car tier lists you find through a search engine are hand-written once and updated whenever the author remembers to revisit the page, sometimes months after a meaningful patch. That lag is not laziness so much as a structural problem: without a scoring pipeline behind the words, every update means rewriting prose from scratch and re-arguing the same placements. The tracker built in this tutorial sidesteps that by separating data from presentation entirely. The moment you edit one number in RAW_SIGNALS, every downstream output, the CLI print-out, the SQLite snapshot, and the Flask page, reflects it automatically.
That separation also makes disagreements productive instead of circular. If a friend insists Dominus deserves S tier, you don't have to argue about vibes. You can point to the exact signal, pro_usage, ranked_usage, or community_sentiment, that would need to change, and by how much, to move it there. That is a small thing, but it is the difference between a tier list you can defend and one you just posted.
| Tier | Cars | Hitbox |
|---|---|---|
| S | Octane, Fennec | Octane |
| A | Dominus, Dominus GT, Nissan Skyline GT-R R34 | Dominus |
| B | Jager 619, Endo, Twinzer, Nimbus, Imperator DT5 | Hybrid |
| C | Breakout, Breakout Type-S | Breakout |
| D | Merc, Road Hog, Backfire | Merc |
Ranked play tells a similar, if slightly less extreme, story. The current ranked ladder still runs seven rank families from Bronze through Grand Champion, each split into three tiers and four divisions, capped by the open-ended Supersonic Legend rank with no divisions at all. Climbing that ladder does not require an S-tier car, but the higher you go, the more consistently you will see Octane and Fennec across the scoreboard, simply because muscle memory built around one hitbox stops transferring cleanly once you switch.
RLCS 2026 Season Data Worth Tracking
If you extend the tracker's pro_usage signal beyond hitboxes into team-level trends, the Rocket League Championship Series gives you a full season of reference points. The RLCS 2026 season roadmap was announced to begin on November 14, 2025, with a total prize pool of more than $6.1 million USD across the year's events, according to Rocket League's own announcement.
| Event | Dates | Result | Prize Pool |
|---|---|---|---|
| Major 1 (Boston) | Feb 19-22, 2026 | Gentle Mates def. Team Vitality 4-2 | — |
| Major 2 (Paris) | May 20-24, 2026 | Karmine Corp def. Twisted Minds 4-1 | $354,000 |
| World Championship | Sep 15-20, 2026 | Fort Worth, 20 teams by RLCS points | $1,200,000 |
The World Championship in Fort Worth also carries separate 2v2 and 1v1 brackets, with prize pools of $170,000 and $85,000 respectively, running the same week as the main 3v3 event. NRG entered as the defending World Champion, and Progressive Corporation renewed its full-season sponsorship of the RLCS for 2026. Earlier in the season, Major 1 in Boston gave the first real read on which hitboxes top rosters were committing to for the year. If you log which hitboxes the winning rosters run at each of these events, your tracker's pro_usage signal stops being an estimate and becomes a running dataset you can cite with confidence.
Common Pitfalls to Avoid
- Guessing hitbox assignments instead of verifying them. A single wrong car-to-hitbox mapping silently corrupts every score that touches it. Cross-check new cars against Epic's help page or the Rocket League Wiki before adding them to the roster.
- Treating pro usage and ranked usage as the same signal. Pros and average ranked players do not pick cars for the same reasons. Collapsing them into one number erases exactly the nuance a tier list is supposed to capture.
- Never revisiting the signal weights. Weights in step 5 are a starting point, not a law. If your tracker's output stops matching what you see in your own matches, the weighting, not the data, is usually the problem.
- Skipping the SQLite snapshot step because "the CLI output is enough." Without dated snapshots, you cannot answer the single most interesting question a tracker exists to answer: what changed since last patch.
- Hardcoding a small roster and forgetting to expand it. Rocket League ships new car bodies almost every season. A tracker that only knows eighteen cars from 2026 will look stale by early 2027 if nobody updates
models.py. - Assuming a public API will appear. Building your automation around the hope that Psyonix opens an API someday wastes effort now. Design for manual curation from the start.
Troubleshooting Guide
| Problem | Likely Cause | Fix |
|---|---|---|
ModuleNotFoundError: No module named 'src' | Running the script from outside the project root | Run commands from the rl-tier-tracker folder, or add an __init__.py to src/ |
| Flask route returns an empty table | No snapshot rows exist in SQLite yet | Run python -m src.cli rebuild and insert a snapshot batch before starting webapp.py |
sqlite3.OperationalError: no such table: snapshots | Schema file was never loaded into the database | Run sqlite3 data/tracker.db < data/schema.sql once before any inserts |
| All cars show the same tier | Tier thresholds in step 7 do not match your score spread | Print the raw scores first, then set thresholds around the actual gaps you see |
| Diff tool prints nothing between two dates | Both snapshots used identical signal data | Confirm you updated RAW_SIGNALS and re-ran rebuild before the second snapshot |
| Flask app won't start, port already in use | A previous python -m src.webapp process is still running | Kill the old process or change the port in app.run(port=5001) |
| Virtual environment not activating on Windows | PowerShell execution policy blocks the activate script | Run PowerShell as admin once and execute Set-ExecutionPolicy RemoteSigned |
| New car added to roster never shows a score | Its hitbox string doesn't exactly match a key in RAW_SIGNALS | Check for typos or capitalization mismatches between models.py and signals.py |
Jinja2 template throws UndefinedError | Column name in the SQL query doesn't match the template variable | Confirm conn.row_factory = sqlite3.Row is set so rows behave like dictionaries in the template |
Advanced Tips for Power Users
Once the base tracker runs reliably, a few extensions make it genuinely useful for a wider audience instead of just your own terminal. Export the Flask route as JSON alongside the HTML view, so other tools can consume your tier list programmatically without scraping your rendered page. Add a confidence field to each signal, since a number sourced from a single RLCS Major carries less weight than one aggregated across a full season, and your scoring engine can down-weight low-confidence entries automatically.
Consider splitting the pro_usage signal by region (North America, Europe, APAC, and so on) if you want to catch regional meta divergence before it shows up globally. Regions sometimes favor different secondary picks for weeks before a shared consensus forms. Finally, wire the diff tool from step 11 into a simple webhook or email alert that fires only when a car crosses a tier boundary, rather than on every minor score fluctuation, so you notice real meta shifts without drowning in noise from rounding.
Frequently Asked Questions
Does the car I pick actually affect gameplay in Rocket League?
Yes, but only through the hitbox it inherits. Two cars sharing a hitbox, like Octane and Fennec, play identically in every physical interaction. Cosmetic differences such as decals or wheels never affect collision or ball physics.
Is there an official Rocket League API for stats or car data?
No. Psyonix has not published a public API for match, car, or hitbox usage data. Epic Games' help center documents the six hitbox classes, but it is reference text, not a queryable endpoint.
How many hitbox classes does Rocket League have in 2026?
Six: Octane, Dominus, Plank, Breakout, Hybrid, and Merc. This has been stable for years and every new car body released is assigned to one of these six on launch.
Why is Octane the most popular hitbox in competitive play?
Its balanced dimensions make it forgiving across dribbling, aerials, and challenges without over-specializing in one mechanic. That versatility is why one 2026 usage breakdown found Octane-hitbox cars covering more than 90% of professional loadouts.
Can I use Ballchasing.com to feed this tracker automatically?
Not fully automatically. Ballchasing still parses replays for detailed match statistics, but automatic replay uploads were removed after Rocket League's anti-cheat changes, so replays now require manual upload before Ballchasing can analyze them.
How often should I update the meta-signal data?
Weekly is reasonable for ranked and community sentiment signals. Pro usage data is best updated right after each RLCS Major or the World Championship, since those events are when the competitive meta actually moves.
What is the current Rocket League ranked tier system?
Seven rank families run from Bronze through Grand Champion, each split into three tiers and four divisions, topped by the open-ended Supersonic Legend rank, which has no divisions.
Do I need to know Flask before starting this tutorial?
No. The web view in step 10 uses one route and one template. If you only want the command-line tracker, you can skip steps 10 and 11 entirely and still end up with a fully working scoring and tiering system.




