Marvel Rivals just rolled into Season 10: Butcher’s Blasphemy on September 11, 2026, and the tier list flipped overnight. A new Duelist, Gorr the God Butcher, landed in the S-tier within days. Scarlet Witch got rebuilt around a new Chaos Marks ability. NetEase also quietly nerfed the entire cast’s ultimate economy, cutting healing-to-energy conversion from 70% to 65% and damage-to-energy conversion from 55% to 50%. None of that shows up if you’re refreshing a static tier list page from last week.

Sites like Counterwatch.gg, Timesaver.gg, and LFCarry already publish tier lists, and they’re good ones. But they update on their own schedule, they don’t let you filter by your own rank bracket, and you can’t pipe their data into a Discord bot or a personal dashboard. This tutorial walks through building your own Marvel Rivals hero tier list tracker: a small Python and Flask project that ingests patch data, calculates tiers from win rate, stores historical snapshots, and serves a live front end. By the end you’ll have a working app you can point at any season, not just Season 10.

What a Marvel Rivals tier list tracker actually does

A tier list tracker is not a magic scraper that reads NetEase’s internal databases. There is no public official API for Marvel Rivals match data, which is exactly why third-party sites compute their own win rates from aggregated match samples and publish them as HTML pages. Your tracker’s job is to treat those published numbers, plus the official patch notes at marvelrivals.com/gameupdate, as structured inputs it can store, compare, and query over time.

Concretely, the app you’re building does four things. First, it pulls current hero win rate and role data, either from a public tier list page you have permission to parse or from a JSON file you update by hand each patch. Second, it applies a tier-assignment rule (S/A/B/C) based on a win rate threshold, the same “shrunk win rate” approach Counterwatch uses to avoid crowning a hero S-tier off a tiny sample size. Third, it snapshots every update into a small database so you can chart how a hero’s standing moved across patches, not just see the current picture. Fourth, it exposes that data through a simple API and front end so you (or a Discord bot, or a spreadsheet) can consume it.

The reason this matters right now: Season 10 reshuffled five of the eight S-tier slots in under two weeks, according to Counterwatch’s live tracker. A tool that only shows “current tier” throws that history away. A tool that snapshots by patch version lets you answer whether Gorr actually earned S-tier, or whether that’s still small-sample noise from launch week.

Prerequisites: what you need before you start

You don’t need a game studio’s budget for this. Here’s the full stack, with the versions this tutorial was written against:

  • Python 3.12 or newer — the scraping, parsing, and API layer all run on it
  • pip (ships with Python) to install dependencies
  • requests 2.32+ for HTTP calls
  • beautifulsoup4 4.12+ for HTML parsing
  • Flask 3.0+ for the lightweight API layer
  • SQLite (built into Python’s standard library, no separate install) for storage
  • A code editor — VS Code, PyCharm, or whatever you already use
  • A GitHub account if you want to automate updates with GitHub Actions (optional, covered in Step 11)
  • Basic comfort with the command line and reading HTML tags

Total setup time is around 15 minutes if your dependencies install cleanly. The full build, following all 12 steps, takes most readers 60 to 90 minutes.

Step 1: Scaffold the project

Start with a clean folder and a virtual environment, so your dependencies don’t collide with anything else on your machine.

mkdir mr-tier-tracker && cd mr-tier-tracker
python3 -m venv venv
source venv/bin/activate   # Windows: venv\Scripts\activate
pip install requests beautifulsoup4 flask

mkdir -p data src static templates
touch src/__init__.py src/models.py src/scraper.py src/tier_calculator.py src/api.py
touch data/seed_heroes.json

Your folder structure should look like this once you’re done scaffolding:

mr-tier-tracker/
├── data/
│   ├── seed_heroes.json
│   └── tracker.db          (created automatically in Step 3)
├── src/
│   ├── __init__.py
│   ├── models.py
│   ├── scraper.py
│   ├── tier_calculator.py
│   └── api.py
├── static/
├── templates/
└── venv/

Keeping the scraper, the tier math, and the API in separate files pays off the first time a patch changes the data source’s page layout. You’ll only need to touch scraper.py, not the whole app.

Step 2: Define the hero and tier data model

Before touching any live data, decide what a “hero record” looks like. Marvel Rivals organizes its roster into three roles: Vanguard (tank), Duelist (damage), and Strategist (support), a naming convention distinct from Overwatch 2’s tank/DPS/support or Valorant’s duelist/controller split. Your schema needs to track role, win rate, tier, and which patch produced that number.

# src/models.py
from dataclasses import dataclass
from enum import Enum

class Role(str, Enum):
    VANGUARD = "Vanguard"
    DUELIST = "Duelist"
    STRATEGIST = "Strategist"

class Tier(str, Enum):
    S = "S"
    A = "A"
    B = "B"
    C = "C"
    D = "D"

@dataclass
class HeroRecord:
    hero_name: str
    role: Role
    win_rate: float          # shrunk win rate, e.g. 56.7 for 56.7%
    pick_rate: float | None  # optional, not every source reports it
    tier: Tier
    season_name: str         # e.g. "Season 10: Butcher's Blasphemy"
    patch_id: str            # e.g. "20260911"
    last_updated: str        # ISO 8601 timestamp

Note the pick_rate field is optional. Not every tier list publishes it, but when it’s available (LFCarry’s tracker does, for instance) it’s useful for spotting the gap between “statistically strong” and “actually being played,” which is a distinction worth keeping in your advanced tips later in this guide.

Step 3: Seed Season 10 tier data

You need starting data before you can build anything that queries it. Rather than scrape on your very first run, seed the database with a known-good snapshot. Here’s Season 10’s published S-tier as of the current patch, sourced from Counterwatch’s live tier list:

HeroRoleWin rateTier
MantisStrategist56.7%S
MagikDuelist56.1%S
Peni ParkerVanguard55.3%S
StormDuelist54.8%S
Gorr the God ButcherDuelist54.3%S
UltronStrategist54.3%S
The HoodVanguard53.1%S
Rocket RaccoonStrategist53.1%S

Turn that into your seed file:

// data/seed_heroes.json
[
  {"hero_name": "Mantis", "role": "Strategist", "win_rate": 56.7, "pick_rate": null, "tier": "S", "season_name": "Season 10: Butcher's Blasphemy", "patch_id": "20260911"},
  {"hero_name": "Magik", "role": "Duelist", "win_rate": 56.1, "pick_rate": null, "tier": "S", "season_name": "Season 10: Butcher's Blasphemy", "patch_id": "20260911"},
  {"hero_name": "Peni Parker", "role": "Vanguard", "win_rate": 55.3, "pick_rate": null, "tier": "S", "season_name": "Season 10: Butcher's Blasphemy", "patch_id": "20260911"},
  {"hero_name": "Storm", "role": "Duelist", "win_rate": 54.8, "pick_rate": null, "tier": "S", "season_name": "Season 10: Butcher's Blasphemy", "patch_id": "20260911"},
  {"hero_name": "Gorr the God Butcher", "role": "Duelist", "win_rate": 54.3, "pick_rate": null, "tier": "S", "season_name": "Season 10: Butcher's Blasphemy", "patch_id": "20260911"},
  {"hero_name": "Ultron", "role": "Strategist", "win_rate": 54.3, "pick_rate": null, "tier": "S", "season_name": "Season 10: Butcher's Blasphemy", "patch_id": "20260911"},
  {"hero_name": "The Hood", "role": "Vanguard", "win_rate": 53.1, "pick_rate": null, "tier": "S", "season_name": "Season 10: Butcher's Blasphemy", "patch_id": "20260911"},
  {"hero_name": "Rocket Raccoon", "role": "Strategist", "win_rate": 53.1, "pick_rate": null, "tier": "S", "season_name": "Season 10: Butcher's Blasphemy", "patch_id": "20260911"}
]

You’ll expand this file with A, B, and C tier heroes too, but S-tier is enough to prove the pipeline works end to end before you scale it up to the full roster.

Marvel Rivals’ full roster runs well past 40 heroes, so the fastest way to fill out the rest of your seed file is to copy the same JSON structure for every hero on a public tier list page and adjust tier, win_rate, and role per entry. Keep the patch_id identical across the whole batch, since every hero in a single seed file should represent the same point-in-time snapshot. Mixing win rates from two different patches in one seed file is a common mistake that quietly corrupts your first historical comparison in Step 10, because the tracker has no way to know two “current” rows actually came from different balance states.

Step 4: Build the patch-notes ingestion script

Win rates tell you what’s strong. Patch notes tell you why. Marvel Rivals publishes patch notes directly at marvelrivals.com, and the September 11 update that opened Season 10 is a good example to parse: it introduced Gorr, reworked Scarlet Witch’s kit around a new Chaos Marks mechanic, and applied the global energy nerf mentioned earlier. A smaller patch on September 3 (version 20260903) shipped only cosmetic and bug fixes, like a Cyclops customization option and a Psylocke scarf-physics fix on the Hellfire Bay Beach map, with no balance changes at all. Your tracker needs to tell those two apart.

# src/scraper.py
import requests
from bs4 import BeautifulSoup
from datetime import datetime, timezone

HEADERS = {"User-Agent": "mr-tier-tracker/1.0 (personal project; contact [email protected])"}

def fetch_patch_page(url: str) -> BeautifulSoup:
    resp = requests.get(url, headers=HEADERS, timeout=10)
    resp.raise_for_status()
    return BeautifulSoup(resp.text, "html.parser")

def extract_patch_id(soup: BeautifulSoup) -> str:
    # Patch pages title/URL slugs typically embed the version, e.g. 20260911
    title = soup.title.string if soup.title else ""
    digits = "".join(c for c in title if c.isdigit())
    return digits[-8:] if len(digits) >= 8 else "unknown"

def is_balance_patch(soup: BeautifulSoup) -> bool:
    body_text = soup.get_text(separator=" ").lower()
    balance_signals = ["win rate", "energy conversion", "damage adjusted",
                        "health increased", "health reduced", "cooldown"]
    return any(signal in body_text for signal in balance_signals)

if __name__ == "__main__":
    page = fetch_patch_page("https://www.marvelrivals.com/gameupdate/")
    patch_id = extract_patch_id(page)
    print(f"Latest patch id: {patch_id}")
    print(f"Contains balance changes: {is_balance_patch(page)}")

Set a real, identifying User-Agent header and a request timeout. Sites that publish tier data are free content for your project, but hammering them with unthrottled requests is how personal projects get IP-blocked, and it’s simply bad practice regardless of what the target site’s terms allow.

Step 5: Parse tier list HTML into structured JSON

This is the step that will break the most often, because it depends entirely on another site’s markup. Treat it as a reference implementation you’ll need to adjust, not a permanent solution. The pattern below looks for a table with hero name, role, and win rate columns, which is how most tier list sites structure this data.

# src/scraper.py (continued)
def parse_tier_table(soup: BeautifulSoup) -> list[dict]:
    rows = soup.select("table tr")
    heroes = []
    for row in rows[1:]:  # skip header row
        cells = [c.get_text(strip=True) for c in row.find_all(["td", "th"])]
        if len(cells) < 3:
            continue
        tier, hero_name, role_and_rate = cells[0], cells[1], cells[2]
        win_rate = None
        for token in role_and_rate.replace("%", "").split():
            try:
                win_rate = float(token)
            except ValueError:
                continue
        if win_rate is None:
            continue
        heroes.append({
            "tier": tier.strip().upper(),
            "hero_name": hero_name,
            "win_rate": win_rate,
        })
    return heroes

If a source site changes its table structure (adds a pick-rate column, switches to a card layout instead of a table), this function will return an empty list rather than crash outright, which is exactly the failure mode you want. Silent, wrong data is worse than an obvious empty result you can catch in a test.

Handling multiple source formats

Different tier list sites format win rate differently. LFCarry reports it alongside pick rate in the same cell (for example, "56.5% WR at 23% pick"), while Counterwatch and Timesaver.gg keep win rate in its own column. Write one parser function per source and normalize the output to your HeroRecord shape before it touches your database. Don't try to write a single universal parser that handles every site's quirks; that function becomes unmaintainable within two patches.

Testing your parser before you trust it

Don't wait until production to find out your parser breaks on a real page. Save a static copy of a tier list page's HTML locally and write a quick test against it, so you catch a broken selector in seconds instead of during a scheduled run at 2 a.m.

# tests/test_scraper.py
from bs4 import BeautifulSoup
from src.scraper import parse_tier_table

def test_parse_tier_table_returns_heroes():
    with open("tests/fixtures/sample_tier_page.html") as f:
        soup = BeautifulSoup(f.read(), "html.parser")
    heroes = parse_tier_table(soup)
    assert len(heroes) > 0
    assert all("hero_name" in h and "win_rate" in h for h in heroes)

Run this with pytest tests/ after every change to the parser, and again any time a source site's layout shifts. A five-line test like this one is what catches a silent empty-list failure before it ships to your live tracker instead of after.

Step 6: Calculate tiers from win rate programmatically

Once you have win rate numbers, you need consistent rules for turning a number into a tier letter. Counterwatch's public methodology defines S-tier as a shrunk win rate of 53% or higher with enough match samples to trust the figure, which is the threshold this tutorial uses. Build your thresholds as a table so they're easy to tune per role, since roles don't distribute evenly. Six of Season 10's eight S-tier heroes are Duelists or split between Duelist and Strategist slots, reflecting how damage and support win rates tend to cluster differently than raw averages would suggest.

TierWin rate rangeWhat it means
S53.0% and aboveStatistically dominant, high enough sample to trust
A51.0% – 52.9%Strong, consistently above average
B49.0% – 50.9%Roughly balanced, viable in the right comp
C47.0% – 48.9%Below average, needs a specific matchup to shine
DBelow 47.0%Struggling, usually the target of the next balance patch
# src/tier_calculator.py
def assign_tier(win_rate: float, min_sample_size: int, sample_size: int) -> str:
    if sample_size < min_sample_size:
        return "unranked"  # not enough data to trust the number
    if win_rate >= 53.0:
        return "S"
    if win_rate >= 51.0:
        return "A"
    if win_rate >= 49.0:
        return "B"
    if win_rate >= 47.0:
        return "C"
    return "D"

The min_sample_size guard matters more than the thresholds themselves. A hero with a 60% win rate from 40 games isn't S-tier, it's noise. Most public trackers use several hundred games as a floor before trusting a win rate figure, which is the whole reason "shrunk win rate" exists as a concept instead of raw win rate.

Step 7: Store historical snapshots for trend tracking

A single current-state table overwrites yesterday's data with today's. Instead, insert a new row every time you ingest data, tagged with the patch ID, so you can query how a hero moved over time. SQLite is enough for a personal project; you don't need Postgres for this.

# src/models.py (continued)
import sqlite3

def init_db(db_path="data/tracker.db"):
    conn = sqlite3.connect(db_path)
    conn.execute("""
        CREATE TABLE IF NOT EXISTS hero_snapshots (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            hero_name TEXT NOT NULL,
            role TEXT NOT NULL,
            win_rate REAL NOT NULL,
            pick_rate REAL,
            tier TEXT NOT NULL,
            season_name TEXT NOT NULL,
            patch_id TEXT NOT NULL,
            last_updated TEXT NOT NULL,
            UNIQUE(hero_name, patch_id)
        )
    """)
    conn.commit()
    return conn

The UNIQUE(hero_name, patch_id) constraint is doing real work here. Without it, re-running your scraper twice on the same patch day creates duplicate rows and your trend chart doubles up every point. Pair the insert with INSERT OR REPLACE so re-running the ingestion script is always safe to do.

Step 8: Build a lightweight API layer

With data flowing into SQLite, wrap it in a small Flask API so anything, a browser, a Discord bot, a phone widget, can query the current tier list without touching the database directly.

# src/api.py
from flask import Flask, jsonify, request
import sqlite3

app = Flask(__name__)
DB_PATH = "data/tracker.db"

def query_db(sql, args=()):
    conn = sqlite3.connect(DB_PATH)
    conn.row_factory = sqlite3.Row
    rows = conn.execute(sql, args).fetchall()
    conn.close()
    return [dict(r) for r in rows]

@app.route("/api/tier-list")
def tier_list():
    role = request.args.get("role")
    latest_patch = query_db(
        "SELECT patch_id FROM hero_snapshots ORDER BY last_updated DESC LIMIT 1"
    )
    if not latest_patch:
        return jsonify([])
    patch_id = latest_patch[0]["patch_id"]
    sql = "SELECT * FROM hero_snapshots WHERE patch_id = ?"
    args = [patch_id]
    if role:
        sql += " AND role = ?"
        args.append(role)
    sql += " ORDER BY win_rate DESC"
    return jsonify(query_db(sql, args))

if __name__ == "__main__":
    app.run(debug=True, port=5000)

Adding a trend endpoint

A second endpoint, /api/hero-history?name=Mantis, that returns every snapshot row for one hero across patches, is what turns this from a static tier list clone into something genuinely more useful than the sites you're pulling from. Add it once the base endpoint from Step 8 is working and tested.

Step 9: Build the front-end tier list display

You don't need a framework for this. A single HTML page with a fetch call renders a tier list grid just fine.

<!-- templates/index.html -->
<div id="tier-list"></div>
<script>
async function loadTierList(role = null) {
  const url = role ? `/api/tier-list?role=${role}` : "/api/tier-list";
  const res = await fetch(url);
  const heroes = await res.json();
  const container = document.getElementById("tier-list");
  container.innerHTML = "";
  const tiers = ["S", "A", "B", "C", "D"];
  tiers.forEach(tier => {
    const row = heroes.filter(h => h.tier === tier);
    if (row.length === 0) return;
    const section = document.createElement("div");
    section.innerHTML = `<h3>${tier}-Tier</h3>` +
      row.map(h => `<span>${h.hero_name} (${h.role}, ${h.win_rate}%)</span>`).join(" ");
    container.appendChild(section);
  });
}
loadTierList();
</script>

Serve it from Flask with a basic route that renders the template, and you have a working, queryable tier list in a browser tab.

Step 10: Add role and season filters

Because your API already accepts a role query parameter, wire up three buttons, Vanguard, Duelist, Strategist, that call loadTierList(role) with each value. This is also where the season field on your HeroRecord pays off: extend the endpoint to accept a patch_id parameter so users can compare Season 10's tier list against Season 9's, the way Timesaver.gg's own archive lets readers compare Season 9 and Season 9.5 side by side. That comparison is genuinely useful context, since it's the fastest way to see that Mantis, Magik, and Peni Parker have anchored the Strategist, Duelist, and Vanguard S-tier slots across three consecutive seasons, while other names rotate in and out.

Step 11: Automate updates around patch day

Marvel Rivals patches land on a predictable cadence, and the official update page posts the schedule in advance. The September 11 patch, for instance, was announced with an exact UTC window and an estimated two-hour maintenance duration. Use that to your advantage instead of polling constantly.

# .github/workflows/update-tier-list.yml
name: Update tier list
on:
  schedule:
    - cron: "0 11 * * *"   # runs daily at 11:00 UTC, a couple hours after typical patch windows
  workflow_dispatch: {}     # lets you trigger it manually too

jobs:
  scrape:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
      - run: pip install requests beautifulsoup4
      - run: python src/scraper.py
      - run: |
          git config user.name "tier-bot"
          git config user.email "[email protected]"
          git add data/tracker.db
          git commit -m "Update tier snapshot" || echo "No changes"
          git push

Running a couple of hours after a typical patch window gives the source sites time to update their own numbers before you scrape them. Scraping immediately at patch launch just gets you yesterday's data with today's timestamp.

Step 12: Deploy and monitor your tracker

For a personal project, you don't need Kubernetes. A small VPS or a free-tier platform running Flask with gunicorn is enough, since this app serves lightweight JSON and a small HTML page, not video or heavy assets. Whatever you choose, add three things before calling it done: a health check endpoint that confirms the database has fresh data, a log line every time the scraper runs (success or failure), and an alert, even something as simple as an email or a webhook to your own Discord server, if the scraper hasn't run successfully in 48 hours. Silent failure is the single most common way these personal trackers die: nobody notices the cron job stopped until someone asks why the tier list looks three patches stale.

What the output looks like

Before troubleshooting anything, it helps to know what "working" actually looks like. Here's the terminal output from running the ingestion script in Step 4 against a fresh patch:

$ python src/scraper.py
Latest patch id: 20260911
Contains balance changes: True
Parsed 8 heroes from tier table
Inserted 8 rows into hero_snapshots for patch 20260911
Snapshot complete: 2026-09-14T11:02:07Z

And here's what the /api/tier-list?role=Strategist endpoint from Step 8 returns once that data is loaded:

[
  {
    "hero_name": "Mantis",
    "role": "Strategist",
    "win_rate": 56.7,
    "pick_rate": null,
    "tier": "S",
    "season_name": "Season 10: Butcher's Blasphemy",
    "patch_id": "20260911",
    "last_updated": "2026-09-14T11:02:07Z"
  },
  {
    "hero_name": "Ultron",
    "role": "Strategist",
    "win_rate": 54.3,
    "pick_rate": null,
    "tier": "S",
    "season_name": "Season 10: Butcher's Blasphemy",
    "patch_id": "20260911",
    "last_updated": "2026-09-14T11:02:07Z"
  },
  {
    "hero_name": "Rocket Raccoon",
    "role": "Strategist",
    "win_rate": 53.1,
    "pick_rate": null,
    "tier": "S",
    "season_name": "Season 10: Butcher's Blasphemy",
    "patch_id": "20260911",
    "last_updated": "2026-09-14T11:02:07Z"
  }
]

If your terminal output matches this shape, roughly a dozen lines confirming the patch ID, a hero count, and a clean insert, and your API returns valid JSON sorted by win rate, the pipeline is wired correctly end to end. If either step returns nothing or throws an error, jump to the troubleshooting guide below before touching the front end.

Common pitfalls to avoid

These are the mistakes that show up most often in tier-tracking side projects, whether for Marvel Rivals or any other live-service game.

  • Treating tiers as permanent. A hero's tier is a snapshot of one patch's balance state, not a fixed property. Season 10's energy economy nerf alone shifted multiple heroes' effective power without touching their individual kits at all.
  • Trusting raw win rate over shrunk win rate. A 65% win rate from 30 games at launch week means almost nothing statistically. Always gate tier assignment behind a minimum sample size, as shown in Step 6.
  • Ignoring rank-bracket differences. A hero can be S-tier in Bronze-to-Gold lobbies and B-tier at Grandmaster, because execution difficulty and team coordination change what "strong" means. If your source data doesn't specify rank, say so explicitly in your UI instead of implying it's universal.
  • Building a brittle scraper with no fallback. Source sites redesign their pages without warning. Wrap every parse call in a try/except that logs the failure and falls back to the last known-good snapshot instead of crashing your whole pipeline.
  • Skipping rate limiting. Sending requests in a tight loop against someone else's site is a fast way to get your IP blocked, and it's inconsiderate even when it isn't. Add a delay between requests and respect any documented rate limits.
  • Hardcoding the hero roster. Gorr the God Butcher didn't exist two patches ago. If your role enum or hero list is a fixed array instead of something you can append to without a code change, every new hero release breaks your app until you patch it.

Troubleshooting guide

Here's what actually goes wrong when you run this, and how to fix each one.

  • Scraper returns an empty list. The source page likely loads its table via JavaScript after the initial HTML response. Check the raw response with print(resp.text) before parsing; if the table markup isn't there, you need a headless browser tool instead of a plain requests call.
  • Win rates don't match what you see on the public site. You're probably looking at a different rank filter or a different queue type (role queue vs open queue). Confirm which filter the page defaults to before comparing numbers.
  • A new hero is missing from your tracker after a patch. Your Role enum or parser's expected column count doesn't account for the new roster entry. Log any row your parser skips instead of silently dropping it, so new heroes surface immediately.
  • The GitHub Actions cron job silently stops running. Scheduled workflows on GitHub get disabled automatically after 60 days of repository inactivity. Push a commit periodically, or check the workflow manually with the workflow_dispatch trigger.
  • The API returns stale data after a patch. Your /api/tier-list endpoint queries for the latest patch_id by last_updated timestamp. Check that your scraper is actually writing a fresh timestamp, not reusing the old row's value.
  • Duplicate hero rows appear after re-running the scraper. You're missing the UNIQUE(hero_name, patch_id) constraint from Step 7, or you're using plain INSERT instead of INSERT OR REPLACE.
  • The front end shows "undefined" for tier. A hero returned by your scraper has a win rate that didn't match any threshold band in assign_tier(). Add a default fallback case and log it rather than letting it propagate as None.
  • Requests start returning 403 errors. You're being rate-limited or blocked for missing a realistic User-Agent header. Set one, as shown in Step 4, and add a delay of at least one second between requests.
  • Historical trend queries return nothing. Check that hero_name spelling is consistent across snapshots. "Peni Parker" vs "Peni-Parker" vs "peni parker" will silently break a WHERE hero_name = ? filter. Normalize casing and spacing on ingestion.
  • Automation runs before the source site updates. Official patch windows are published in UTC and maintenance can run up to two hours past the announced start. Push your scheduled scrape at least two to three hours after the announced patch window, not right at it.

Advanced tips: pick rate, ban rate, and rank-specific tiers

Once the base tracker works, a few extensions make it genuinely more useful than a static tier list page.

Weight by pick rate, not just win rate. LFCarry's tier list data showed Rocket Raccoon at a 35% pick rate against Peni Parker's 23%, despite Peni Parker's slightly higher win rate. A hero with a high win rate but a near-zero pick rate might just be a niche pick abused by a handful of high-skill players, not a genuinely accessible S-tier choice. Add a pick_rate column to your display and let users sort by "effective strength," a simple composite of win rate and pick rate, instead of win rate alone.

Track ban culture separately from win rate tier. In ranked play, a hero can be functionally S-tier because opponents ban them before the match starts, which suppresses their measured win rate even though they'd dominate if left unbanned. If your data source publishes ban rate, surface it as a separate flag rather than folding it into the win-rate-based tier.

Split by rank bracket. If you can find or build a data source broken out by rank, add a rank filter alongside the role filter from Step 10. A single "overall" tier list, averaged across every rank, tends to overrate heroes with simple mechanics that perform well in low ranks and underrate execution-heavy heroes that only shine with coordinated play.

Chart the trend, not just the snapshot. With Step 7's historical table in place, a simple line chart of win rate over the last five patches for any hero tells a much richer story than a single tier letter. It's the difference between saying Peni Parker is S-tier and saying Peni Parker has been the top or near-top Vanguard for three straight seasons, which is a materially different and more useful claim.

The complete project, tied together

At this point you've built five pieces: a data model, a seed file, a scraper, a tier calculator, and an API. The last thing that turns those five files into one working project is an orchestration script that runs them in order and writes to the database.

# main.py
import json
import sqlite3
from datetime import datetime, timezone
from src.models import init_db
from src.tier_calculator import assign_tier

def load_seed_data(path="data/seed_heroes.json"):
    with open(path) as f:
        return json.load(f)

def ingest(heroes, conn):
    now = datetime.now(timezone.utc).isoformat()
    for hero in heroes:
        conn.execute("""
            INSERT OR REPLACE INTO hero_snapshots
            (hero_name, role, win_rate, pick_rate, tier, season_name, patch_id, last_updated)
            VALUES (?, ?, ?, ?, ?, ?, ?, ?)
        """, (
            hero["hero_name"], hero["role"], hero["win_rate"],
            hero.get("pick_rate"), hero["tier"], hero["season_name"],
            hero["patch_id"], now,
        ))
    conn.commit()

if __name__ == "__main__":
    conn = init_db()
    heroes = load_seed_data()
    ingest(heroes, conn)
    print(f"Loaded {len(heroes)} heroes into the tracker.")
    print("Run 'python src/api.py' to start the API, then open templates/index.html.")

Run python main.py once to seed the database, then python src/api.py to bring the API online. Open the front end from Step 9 in a browser, and you have a complete, working Marvel Rivals tier list tracker: eight files, one database, and a pipeline that scales from a hardcoded Season 10 seed to a fully automated daily scrape once you plug in Step 4's real scraper and Step 11's GitHub Actions workflow. Everything from here is refinement, not a different architecture. Swap the seed file for live scraped data, add the remaining tiers beyond S, and the same models, the same tier math, and the same API keep working without a rewrite.

Frequently asked questions

Is there an official Marvel Rivals API for stats?

No. NetEase publishes patch notes and update announcements at marvelrivals.com, but there's no documented public API for match or win-rate data. Third-party tier list sites like Counterwatch.gg compute their own numbers from aggregated match samples, which is what this tutorial's scraper is built to work with.

What is the current Marvel Rivals season as of September 2026?

Season 10: Butcher's Blasphemy, which launched September 11, 2026, following Season 9: The Mystery of Thebes. It introduced the Duelist hero Gorr the God Butcher and reworked Scarlet Witch around a new Chaos Marks ability.

Why do different tier list sites disagree on rankings?

They use different sample sizes, different rank-bracket filters, and different snapshot dates. A tier list pulled the day after a patch reflects a much smaller sample than one pulled a week later, and win rates typically shift as the initial meta settles.

How often should my tracker update?

Daily is enough for most use cases, and it matches the cadence this tutorial's GitHub Actions example uses. Updating more frequently than that mostly adds noise, since win rate data doesn't move meaningfully hour to hour outside of a fresh patch window.

What roles exist in Marvel Rivals, and how are they different from Overwatch 2?

Marvel Rivals uses Vanguard (tank), Duelist (damage), and Strategist (support). The concepts map roughly to Overwatch 2's tank, DPS, and support roles, but hero kits, team-up mechanics, and destructible environments change how those roles play out in practice.

Can I legally scrape a tier list site's data?

Always check the target site's terms of service and robots.txt before scraping, and prefer official or explicitly public data sources when available. This tutorial's approach favors low request volume, a clear identifying User-Agent, and reasonable delays specifically to be a good citizen of whatever site you point it at.

My scraper worked yesterday and returns nothing today. What changed?

The source site most likely changed its page structure. This is the single most common failure mode for any scraping-based project. Check the troubleshooting section above, and consider adding a small automated test that flags an unexpectedly empty parse result so you catch it fast instead of discovering it a week later.

Do I need Flask specifically, or can I use something else?

Flask is used here because it's minimal and fast to stand up, but the same pattern works with FastAPI, Express (Node), or any lightweight framework. The core logic, data model, tier calculation, and snapshot storage, is framework-agnostic and ports over with only the API layer needing a rewrite.