Brawl Stars just rolled through three balance patches in one month, and the community tier lists can’t keep up. Season 53 “Windstock” landed with a fresh map pool, a major August 31, 2026 release note dump from Supercell, and enough brawler shuffling that anyone relying on a static tier list from three weeks ago is playing with outdated information. If you’ve ever wanted a tool that updates itself instead of one you have to refresh by hand, this tutorial walks you through building one.

You’ll build a self-hosted Brawl Stars tier list tracker: a small web app where your community votes on brawler rankings per game mode, results get stored in a real database, and the whole thing can pull live win-rate context from patch data you control. No frameworks you’ve never heard of, no paid SaaS tier-list builder. Just Python, SQLite, and a sprinkle of JavaScript, deployable on a $5/month VPS or your own machine in under two hours.

Why build your own Brawl Stars tier list tracker

Community tier lists for Brawl Stars move fast because Supercell ships balance changes almost every two to three weeks. During August 2026 alone, the game saw patches on August 4, August 19, and a larger release notes update on August 31, each one nudging win rates across dozens of brawlers. Sites like Timesaver.gg and TrophyCoach publish manually curated tier lists after each patch, and DraftMeta tracks the resulting win-rate deltas in tables. That’s useful reading, but none of it reflects what your specific community actually thinks is strong in your meta, your mode mix, or your skill bracket.

A self-hosted voting tracker solves a different problem than a published tier list. It gives your Discord server, subreddit, or content channel a living document that updates in real time as members vote, tagged by the patch it was voted under so old opinions don’t quietly pollute the current picture. Supercell’s game still pulls tens of millions of monthly active players according to third-party analytics estimates from Udonis and AppMagic-sourced reporting, so there’s no shortage of people who’d vote if you gave them an easy interface.

There’s also a practical reason to own this data yourself: portability. If you build the tracker on your own infrastructure, you can export vote history, plug it into a Discord bot, or feed it into a stats dashboard later. A hosted tier-list widget on someone else’s site gives you none of that.

Prerequisites and versions

This build targets a standard Linux, macOS, or WSL2 environment. Here’s exactly what you need before starting, with the versions this tutorial was tested against:

ToolMinimum versionPurpose
Python3.11+Backend API and vote logic
Flask3.0+Lightweight web framework
SQLite3.40+ (bundled with Python)Vote and brawler storage
Node.js20 LTSOptional: frontend build tooling
Git2.40+Version control and deployment
curlany recentTesting API endpoints

You’ll also want a text editor (VS Code works fine), a free account on a VPS provider or a spare machine for deployment, and roughly 90 minutes of uninterrupted time. No Brawl Stars developer account is required for the core build described here, since we’re tracking community votes rather than pulling live player statistics. Supercell does publish an official API for Brawl Stars covering player profiles and battle logs, and we’ll cover how to wire that in as an optional enhancement near the end.

Step 1: Plan the data model before you write code

The temptation with tier-list tools is to jump straight into a frontend. Resist it. Your data model determines whether this tool is useful six months from now or whether it collapses into a spreadsheet of stale opinions. You need four entities: brawlers, game modes, patches, and votes.

Brawlers change rarely (Supercell adds a handful per year). Game modes are essentially fixed: Gem Grab, Showdown, Brawl Ball, Bounty, Knockout, Heist, and the rotating special modes. Patches are what actually drive the interesting behavior, because a vote cast during the August 4 patch window means something different than a vote cast after the August 31 release notes. Tie every vote to a patch ID and you get a tool that ages accurately instead of blending three months of shifting opinions into mush.

-- schema.sql
CREATE TABLE brawlers (
    id INTEGER PRIMARY KEY,
    name TEXT NOT NULL UNIQUE,
    rarity TEXT NOT NULL,
    release_year INTEGER
);

CREATE TABLE game_modes (
    id INTEGER PRIMARY KEY,
    name TEXT NOT NULL UNIQUE
);

CREATE TABLE patches (
    id INTEGER PRIMARY KEY,
    label TEXT NOT NULL UNIQUE,
    patch_date TEXT NOT NULL
);

CREATE TABLE votes (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    brawler_id INTEGER NOT NULL,
    mode_id INTEGER NOT NULL,
    patch_id INTEGER NOT NULL,
    tier TEXT NOT NULL CHECK (tier IN ('S','A','B','C','D')),
    voter_hash TEXT NOT NULL,
    created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY (brawler_id) REFERENCES brawlers(id),
    FOREIGN KEY (mode_id) REFERENCES game_modes(id),
    FOREIGN KEY (patch_id) REFERENCES patches(id),
    UNIQUE(brawler_id, mode_id, patch_id, voter_hash)
);

The UNIQUE constraint on votes is doing real work here: it stops one person from stuffing the ballot box for the same brawler, mode, and patch combination. The voter_hash field stores a salted hash of the visitor’s IP plus a session cookie, not the raw IP itself, so you get one-vote-per-person-per-context without hoarding personal data you don’t need.

Step 2: Set up the project structure

Create a clean project folder and virtual environment. Keeping dependencies isolated matters more once you deploy, since a global Python environment on a shared VPS turns into a debugging nightmare the moment you add a second project.

mkdir brawl-tier-tracker && cd brawl-tier-tracker
python3 -m venv venv
source venv/bin/activate
pip install flask==3.0.3 gunicorn==22.0.0
mkdir -p templates static
touch app.py schema.sql seed_data.py requirements.txt
pip freeze > requirements.txt

Your folder should now look like this: app.py for the Flask application, schema.sql for the database structure from Step 1, templates/ for HTML pages, static/ for CSS and JavaScript, and seed_data.py to populate the initial brawler roster. This layout keeps things simple enough to deploy without a build pipeline, which matters if you’re running this on a budget VPS rather than a managed platform.

Step 3: Seed the brawler roster and current meta

Rather than hardcoding every brawler by hand, write a seed script that’s easy to update when Supercell adds new characters. As of the Season 53 “Windstock” meta in August 2026, community tier lists from sites like Timesaver.gg consistently placed Edgar, Surge, Sirius, Nori, and Brock in top-tier consensus, with Bolt, Wendy, Griff, Pearl, Leon, and Sprout showing up repeatedly as strong mode-specific picks after the August 4 balance patch. Use that as your starting seed data, not as gospel, since the entire point of the tracker is to let your community’s votes override any static list over time.

# seed_data.py
import sqlite3

conn = sqlite3.connect("tierlist.db")
cur = conn.cursor()

with open("schema.sql") as f:
    cur.executescript(f.read())

brawlers = [
    ("Edgar", "Mythic", 2020), ("Surge", "Chromatic", 2021),
    ("Sirius", "Epic", 2025), ("Nori", "Mythic", 2024),
    ("Brock", "Rare", 2017), ("Bolt", "Mythic", 2024),
    ("Wendy", "Epic", 2023), ("Griff", "Mythic", 2022),
    ("Pearl", "Epic", 2024), ("Leon", "Legendary", 2018),
    ("Sprout", "Epic", 2020), ("Shelly", "Starting", 2017),
]
cur.executemany(
    "INSERT OR IGNORE INTO brawlers (name, rarity, release_year) VALUES (?, ?, ?)",
    brawlers,
)

modes = ["Gem Grab", "Showdown", "Brawl Ball", "Bounty", "Knockout", "Heist"]
cur.executemany(
    "INSERT OR IGNORE INTO game_modes (name) VALUES (?)",
    [(m,) for m in modes],
)

patches = [("2026-08-04", "2026-08-04"), ("2026-08-19", "2026-08-19"),
           ("2026-08-31-windstock", "2026-08-31")]
cur.executemany(
    "INSERT OR IGNORE INTO patches (label, patch_date) VALUES (?, ?)",
    patches,
)

conn.commit()
conn.close()
print("Seed complete.")

Run it with python3 seed_data.py. You should see “Seed complete.” printed with no errors, and a new tierlist.db file appearing in your project folder. Add the full roster (Brawl Stars has well over 80 brawlers as of 2026) by extending the list, or scrape it once from a source you trust and paste it in as static data. Treat the roster as content you maintain quarterly, not something you need to automate on day one.

Step 4: Build the Flask backend and voting endpoint

The core of the tracker is a small API: one endpoint to fetch the current tier standings for a mode and patch, and one endpoint to accept a vote. Keep the logic boring and defensive, since this is the part of the app exposed to anonymous internet traffic.

# app.py
import hashlib
import sqlite3
from flask import Flask, request, jsonify, render_template, g

app = Flask(__name__)
DB_PATH = "tierlist.db"

def get_db():
    if "db" not in g:
        g.db = sqlite3.connect(DB_PATH)
        g.db.row_factory = sqlite3.Row
    return g.db

@app.teardown_appcontext
def close_db(exception=None):
    db = g.pop("db", None)
    if db is not None:
        db.close()

def voter_hash(ip: str, mode_id: int, patch_id: int) -> str:
    raw = f"{ip}-{mode_id}-{patch_id}-brawl-salt-2026"
    return hashlib.sha256(raw.encode()).hexdigest()

@app.route("/api/tierlist")
def tierlist():
    mode_id = request.args.get("mode_id", type=int)
    patch_id = request.args.get("patch_id", type=int)
    db = get_db()
    rows = db.execute(
        """
        SELECT b.name, v.tier, COUNT(*) as vote_count
        FROM votes v
        JOIN brawlers b ON b.id = v.brawler_id
        WHERE v.mode_id = ? AND v.patch_id = ?
        GROUP BY b.name, v.tier
        ORDER BY vote_count DESC
        """,
        (mode_id, patch_id),
    ).fetchall()
    return jsonify([dict(r) for r in rows])

@app.route("/api/vote", methods=["POST"])
def vote():
    data = request.get_json(force=True)
    required = {"brawler_id", "mode_id", "patch_id", "tier"}
    if not required.issubset(data):
        return jsonify({"error": "missing fields"}), 400
    if data["tier"] not in ("S", "A", "B", "C", "D"):
        return jsonify({"error": "invalid tier"}), 400

    ip = request.headers.get("X-Forwarded-For", request.remote_addr)
    vhash = voter_hash(ip, data["mode_id"], data["patch_id"])

    db = get_db()
    try:
        db.execute(
            """
            INSERT INTO votes (brawler_id, mode_id, patch_id, tier, voter_hash)
            VALUES (?, ?, ?, ?, ?)
            ON CONFLICT(brawler_id, mode_id, patch_id, voter_hash)
            DO UPDATE SET tier = excluded.tier
            """,
            (data["brawler_id"], data["mode_id"], data["patch_id"], data["tier"], vhash),
        )
        db.commit()
    except sqlite3.IntegrityError as e:
        return jsonify({"error": str(e)}), 409
    return jsonify({"status": "recorded"}), 201

@app.route("/")
def index():
    return render_template("index.html")

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

Notice the ON CONFLICT ... DO UPDATE clause. That’s what lets someone change their mind about a brawler’s tier without your database rejecting the second vote outright or silently duplicating rows. Start the server locally with python3 app.py and confirm it’s alive.

Step 5: Test the voting API with curl

Before touching any frontend code, verify the backend logic works in isolation. This catches schema mistakes early, when they’re a two-minute fix instead of a confusing frontend bug three steps later.

curl -X POST http://localhost:5000/api/vote \
  -H "Content-Type: application/json" \
  -d '{"brawler_id": 1, "mode_id": 3, "patch_id": 3, "tier": "S"}'

curl "http://localhost:5000/api/tierlist?mode_id=3&patch_id=3"

Expected output from the vote call:

{"status": "recorded"}

Expected output from the tierlist call, after casting a couple of test votes for different brawlers:

[
  {"name": "Edgar", "tier": "S", "vote_count": 1},
  {"name": "Nori", "tier": "A", "vote_count": 1}
]

If you get an empty array back, double-check that the mode_id and patch_id in your curl command actually exist in your seeded database. This is the single most common early mistake: testing against IDs that were never inserted.

Step 6: Build a minimal voting frontend

You don’t need React or Vue for this. A single HTML page with vanilla JavaScript handles voting and rendering the live tier board just fine, and it keeps the deployment story simple.

<!-- templates/index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Brawl Stars Tier Tracker</title>
  <link rel="stylesheet" href="/static/style.css">
</head>
<body>
  <h1>Community Tier List — Windstock Patch</h1>
  <div id="tier-board"></div>

  <script>
    async function loadTierlist() {
      const res = await fetch('/api/tierlist?mode_id=3&patch_id=3');
      const data = await res.json();
      const board = document.getElementById('tier-board');
      board.innerHTML = data.map(row =>
        `<div class="row">${row.name}: ${row.tier} tier (${row.vote_count} votes)</div>`
      ).join('');
    }

    async function castVote(brawlerId, tier) {
      await fetch('/api/vote', {
        method: 'POST',
        headers: {'Content-Type': 'application/json'},
        body: JSON.stringify({brawler_id: brawlerId, mode_id: 3, patch_id: 3, tier})
      });
      loadTierlist();
    }

    loadTierlist();
  </script>
</body>
</html>

This is intentionally bare. Once it works, layer in a proper voting UI with tier buttons per brawler card, brawler icons, and mode tabs. Resist rebuilding this in a heavy frontend framework until you actually have traffic that justifies the added complexity.

Step 7: Add patch-aware filtering so old votes don’t distort the board

This is the step most tier-list side projects skip, and it’s the reason so many community tier lists feel stale within a month. Because every vote in your schema is tied to a patch_id, you can default the frontend to the current patch while still letting power users compare how sentiment shifted between the August 4 patch and the August 31 Windstock release notes.

@app.route("/api/patches")
def patches():
    db = get_db()
    rows = db.execute("SELECT id, label, patch_date FROM patches ORDER BY patch_date DESC").fetchall()
    return jsonify([dict(r) for r in rows])

Add a dropdown on the frontend that calls this endpoint and lets visitors pick a patch, defaulting to the most recent one. When Supercell ships a new patch, insert a new row into the patches table and your historical data stays intact instead of getting silently overwritten.

Step 8: Rate-limit voting to prevent brigading

Any public voting tool tied to a game with millions of players is a target for coordinated brigading, especially around a brawler that just got nerfed. Add basic rate limiting before you announce the tool anywhere public.

pip install flask-limiter==3.7.0

The flask-limiter documentation covers additional storage backends if you outgrow the default in-memory limiter, which matters once you run multiple Gunicorn workers, since each worker otherwise tracks its own separate rate-limit counters.

from flask_limiter import Limiter
from flask_limiter.util import get_remote_address

limiter = Limiter(get_remote_address, app=app, default_limits=["100 per hour"])

@app.route("/api/vote", methods=["POST"])
@limiter.limit("20 per minute")
def vote():
    # existing vote logic
    ...

Twenty votes per minute per IP is generous enough for a legitimate user voting on their favorite brawlers across multiple modes, while making large-scale automated brigading noticeably harder to pull off without real infrastructure behind it.

Step 9: Deploy with Gunicorn and a reverse proxy

Flask’s built-in server is fine for development, not for anything public. Run the app behind Gunicorn, then put Nginx in front of it to handle TLS and static files.

gunicorn -w 4 -b 127.0.0.1:8000 app:app
# /etc/nginx/sites-available/tierlist
server {
    listen 80;
    server_name tierlist.example.com;

    location /static/ {
        alias /home/deploy/brawl-tier-tracker/static/;
    }

    location / {
        proxy_pass http://127.0.0.1:8000;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header Host $host;
    }
}

Enable the site with ln -s /etc/nginx/sites-available/tierlist /etc/nginx/sites-enabled/, test the config with nginx -t, and reload with systemctl reload nginx. Add TLS afterward with Certbot so votes aren’t traveling over plain HTTP.

Step 10: Keep Gunicorn running with systemd

A tracker that dies the moment you close your SSH session isn’t a tracker, it’s a demo. Wrap Gunicorn in a systemd service so it survives reboots and crashes.

# /etc/systemd/system/tierlist.service
[Unit]
Description=Brawl Stars Tier Tracker
After=network.target

[Service]
User=deploy
WorkingDirectory=/home/deploy/brawl-tier-tracker
ExecStart=/home/deploy/brawl-tier-tracker/venv/bin/gunicorn -w 4 -b 127.0.0.1:8000 app:app
Restart=always

[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now tierlist.service
sudo systemctl status tierlist.service

A healthy status check should show “active (running)” in green. If it shows “failed,” jump to the troubleshooting section below before doing anything else.

Step 11: Add a simple admin view for patch management

Rather than SSH-ing in and running SQL by hand every time Supercell ships a patch, add a lightweight, password-protected admin route to insert new patches and, if you want, retire old brawlers that get reworked into new characters.

import os
from functools import wraps
from flask import abort

ADMIN_TOKEN = os.environ.get("ADMIN_TOKEN")

def require_admin(f):
    @wraps(f)
    def wrapper(*args, **kwargs):
        if request.headers.get("X-Admin-Token") != ADMIN_TOKEN:
            abort(403)
        return f(*args, **kwargs)
    return wrapper

@app.route("/api/admin/patches", methods=["POST"])
@require_admin
def add_patch():
    data = request.get_json(force=True)
    db = get_db()
    db.execute(
        "INSERT INTO patches (label, patch_date) VALUES (?, ?)",
        (data["label"], data["patch_date"]),
    )
    db.commit()
    return jsonify({"status": "patch added"}), 201

Set ADMIN_TOKEN as an environment variable on your server, never hardcoded in the source. This is the minimum viable protection for an admin route; if you later expose more sensitive operations, swap this for proper session-based auth.

Step 12: Wire in optional live stats context (advanced)

Supercell maintains an official public API for Brawl Stars that exposes player profiles, battle logs, and club data. You can use it to add context to your voting page, such as showing a brawler’s usage trend alongside the community vote, without needing the API to power the voting itself. Since exact endpoint paths change and require an API key tied to your own developer account, register for credentials directly through Supercell’s developer portal rather than relying on any third-party mirror, and cache whatever you pull aggressively since these APIs are typically rate-limited per key.

A safe pattern is a nightly cron job that fetches aggregate stats once, stores them in your own database, and serves everything else from that cache. That keeps your tracker fast, keeps you well under any rate limit, and means a temporary outage on Supercell’s side never takes your voting page down with it.

Time and cost breakdown

Here’s roughly how long each stage takes and what it costs, based on running through the full build on a fresh $5/month VPS instance.

StageTimeCost
Local setup and schema (Steps 1-5)25-35 min$0
Frontend and patch filtering (Steps 6-7)20-30 min$0
Rate limiting and deployment (Steps 8-10)25-35 min$5/mo VPS
Admin route and optional API wiring (Steps 11-12)15-25 min$0 (API is free with a developer key)
TLS certificate via Certbot5-10 min$0

Total build time lands around 90 minutes to two hours for someone comfortable with basic Python and command-line deployment, with the only recurring cost being whatever you pay for the VPS itself. There’s no licensing fee for any tool used here: Flask, SQLite, Gunicorn, Nginx, and Certbot are all free and open source.

Sample project structure once complete

Here’s what a finished, deployed version of this project looks like on disk:

brawl-tier-tracker/
├── app.py
├── schema.sql
├── seed_data.py
├── requirements.txt
├── tierlist.db
├── templates/
│   └── index.html
├── static/
│   ├── style.css
│   └── vote.js
└── venv/

Common pitfalls when building a tier list tracker

A handful of mistakes show up repeatedly in DIY tier-list projects, and most of them are easy to avoid once you know to look for them.

  • Forgetting the UNIQUE constraint on votes. Without it, one visitor refreshing the page can accidentally submit dozens of duplicate votes, silently skewing your S-tier results.
  • Not tying votes to a patch. A tier list that blends opinions from three balance patches ago with today’s votes will always look muddled and won’t reflect the current meta accurately.
  • Hardcoding the brawler roster once and never updating it. Supercell adds new brawlers multiple times a year; a tracker missing the last two releases looks abandoned immediately.
  • Skipping rate limiting until after a brigading incident. Add it during Step 8, not after your S-tier board gets flooded overnight.
  • Running Flask’s development server in production. It isn’t built for concurrent traffic and will fall over under any real load; always deploy behind Gunicorn or a similar WSGI server.
  • Storing raw IP addresses instead of a salted hash. This is both a privacy liability and unnecessary, since a hash accomplishes the same duplicate-vote prevention.
  • Fetching from unofficial third-party stat mirrors without checking their reliability. If you add live stats later, prefer Supercell’s own developer API over unverified scrapers.

Current Brawl Stars meta reference table (Season 53 Windstock, August 2026)

Use this as your initial seed data reference, sourced from community tier-list aggregation across mode-specific meta breakdowns published in August 2026. Treat it as a starting point your own voters will refine, not a permanent ranking.

BrawlerFrequently cited strong modeCommunity consensus tier
EdgarGeneral/ShowdownS
SurgeGeneralS
SiriusGeneralS
NoriBounty/KnockoutS
BrockBountyS
BoltShowdown/BountyA
WendyBounty/KnockoutA
GriffKnockoutA
PearlKnockoutA
LeonKnockoutA
SproutBounty/KnockoutA

Troubleshooting: 8 common issues and fixes

1. “sqlite3.OperationalError: no such table: votes.” Your schema.sql never ran. Delete tierlist.db and re-run seed_data.py from scratch, and confirm the file path matches DB_PATH in app.py.

2. Votes return 201 but never show up in /api/tierlist. Check that mode_id and patch_id in your GET request exactly match the ones used in your POST request. A mismatched patch_id is the most common cause.

3. Gunicorn service shows “failed” in systemctl status. Run journalctl -u tierlist.service -n 50 to see the actual traceback. Nine times out of ten it’s a missing dependency because you forgot to point ExecStart at the venv’s gunicorn binary rather than a system-wide one.

4. Nginx returns 502 Bad Gateway. Gunicorn isn’t running or is bound to the wrong port. Confirm with curl http://127.0.0.1:8000 directly on the server before blaming Nginx.

5. Every vote gets rejected with a 409 Conflict. Your ON CONFLICT clause syntax doesn’t match your UNIQUE constraint column order. They must reference the exact same columns in the exact same order.

6. Rate limiter blocks legitimate users on shared networks. University or office networks often share one public IP across hundreds of people. If you see complaints, raise the per-IP limit or switch to a cookie-based identifier layered on top of IP.

7. Admin routes return 403 even with the correct token. Check for trailing whitespace or newline characters in your environment variable, a common copy-paste artifact when setting env vars through some hosting panels.

8. The tier board shows brawlers out of order or duplicated. Your GROUP BY is likely missing a column, or you’re aggregating across multiple tiers for the same brawler without picking the top one. Add a secondary query step that selects only the tier with the highest vote_count per brawler.

Advanced tips for scaling the tracker

Once the basic version is stable and your community is actively voting, a few upgrades pay off quickly. Migrate from SQLite to PostgreSQL once you’re comfortably past a few thousand votes a day, since SQLite handles concurrent writes fine at small scale but starts to show lock contention under sustained heavy write traffic. Add a Redis-backed cache in front of the /api/tierlist endpoint so repeated reads don’t hit the database on every page load, since tier boards are read far more often than they’re written to.

Consider exposing a public read-only JSON endpoint so other community sites or Discord bots can embed your live tier data instead of scraping your HTML. And if you want historical trend charts, don’t throw away old votes when a new patch drops. Keep every patch’s votes in the table permanently, tagged as shown in Step 1, and let a simple chart library plot how a brawler’s community standing moved across the past three or four patches.

How a self-hosted tracker compares to existing Brawl Stars meta sites

It’s worth being honest about what this project is and isn’t. Sites like Supercell’s own release notes, Timesaver.gg, and TrophyCoach publish tier lists backed by aggregated win-rate telemetry across millions of matches, something a brand-new community tool simply can’t match on day one. DraftMeta goes further and publishes per-patch win-rate deltas down to individual brawlers, which requires match-level data most solo builders don’t have access to.

What your tracker offers instead is specificity and ownership. A win-rate-driven tier list tells you what’s statistically strong across the entire player base, but it says nothing about what your Discord server’s regulars actually enjoy playing or believe is strong in their bracket. Casual pub-stomping opinions and top-ladder win rates frequently diverge, and a community vote captures the former in a way aggregate statistics never will. You also control the data outright: no rate limits imposed by someone else’s business model, no risk that a third-party embed disappears if that site changes its terms.

The strongest version of this project treats the two approaches as complementary rather than competing. Seed your initial tiers from published community consensus (as shown in Step 3), let your own voters override it over time, and optionally layer in real usage data later through Supercell’s official developer API once you’re ready for Step 12’s advanced integration.

Security and privacy considerations before you go public

A voting tool that anyone on the internet can hit is a small but real attack surface, and it’s worth locking down before you post the link in a Discord server with thousands of members. Run the Flask app itself with debug mode disabled in production; the debug=True flag used in Step 4 is for local development only and will leak stack traces and enable remote code execution through the interactive debugger if left on in a public deployment. Check the official Flask documentation on deployment for the full list of production settings worth reviewing.

Validate every field on the vote endpoint server-side, not just in the frontend JavaScript. The example in Step 4 already checks that brawler_id, mode_id, patch_id, and tier are present and that tier matches an allowed value, but if you extend the schema later (adding a comment field, for instance) apply the same discipline: never trust that a request came from your own frontend just because it looks like it did. Keep your SQLite database file outside any web-served static directory, since a misconfigured Nginx location block can otherwise expose it for direct download.

Finally, back up tierlist.db on a schedule, even a simple nightly cron job that copies it to a second disk or an object storage bucket. SQLite databases are just files, which makes backup trivially easy and losing months of community votes to a bad deploy entirely avoidable.

Why patch-aware data beats a static tier list

The core insight behind this whole build is that Brawl Stars’ meta doesn’t hold still long enough for a manually updated list to stay accurate. The August 4, 2026 patch alone measurably shifted win rates on brawlers including Damian, Crow, Starr Nova, Bolt, and Surge according to patch-tracking analysis from DraftMeta, and another round of changes followed on August 19 before Supercell’s larger August 31 release notes reworked attack, gadget, and super mechanics across multiple brawlers. A tool that timestamps every vote against the patch it was cast under is the only realistic way to keep a community tier list honest across that kind of churn, rather than freezing opinions from three patches ago and calling it current.

Pre-launch checklist

Before sharing the link with your community, run through this list. It catches the mistakes that most commonly turn a working local build into a broken public one.

  • Flask’s debug=True flag is removed or set to False for the production entry point.
  • Gunicorn is running as a systemd service with Restart=always, confirmed with a test reboot.
  • Nginx is serving the site over HTTPS, not plain HTTP, with a valid certificate.
  • The rate limiter from Step 8 is active and tested with a quick burst of curl requests.
  • ADMIN_TOKEN is set as an environment variable, not committed to source control.
  • tierlist.db lives outside the Nginx static file root and isn’t publicly downloadable.
  • A backup cron job for tierlist.db is scheduled and has been tested by actually restoring from it once.
  • The brawler roster reflects the current patch, including any brawler added in the last balance update.

Once every item on that list is checked, the project described across these twelve steps is a complete, working tool: a Flask backend with a tested voting API, a SQLite schema that respects patch history, a rate-limited public endpoint, a systemd-managed deployment behind Nginx, and an admin path for ongoing maintenance. That’s a meaningfully more durable setup than a single HTML page with a JavaScript tally that resets every time someone clears their browser cache, which is how a lot of fan-made tier-list tools get built and then abandoned within a season.

Frequently asked questions

Do I need Supercell’s official API to build a tier list tracker?

No. The voting-based tracker in this tutorial runs entirely on votes your own community submits, with no dependency on Supercell’s API. The official API is only needed if you want to layer in live player stats or battle log data as extra context.

Why use SQLite instead of a bigger database like PostgreSQL from the start?

SQLite has zero setup overhead, ships built into Python, and comfortably handles the vote volume of a small-to-medium community. Migrate to PostgreSQL only once you’re seeing write contention, which typically means several thousand votes per day.

How often should I add a new patch entry to the database?

Add one every time Supercell ships a balance change or release notes update, roughly every two to four weeks based on the August 2026 cadence. Tie it to the actual patch date so historical comparisons stay accurate.

Can this tracker handle voting for every game mode separately?

Yes. The schema already separates votes by mode_id, so a brawler can sit in S-tier for Bounty while landing in B-tier for Heist in the same patch, which matches how most community tier lists already break down brawler strength by mode.

What stops someone from voting hundreds of times to manipulate the results?

The combination of a UNIQUE constraint on brawler, mode, patch, and voter hash, plus the rate limiter from Step 8, makes large-scale manipulation from a single source impractical. It won’t stop a genuinely coordinated brigading effort with many real IPs, but that’s a much higher bar than casual manipulation.

Is Flask fast enough for a public-facing voting tool?

Yes, when deployed behind Gunicorn with multiple workers as shown in Step 9. Flask’s own development server is the part that isn’t production-ready, not the framework itself.

Can I turn this into a Discord bot instead of a website?

Yes. Since votes and reads go through a REST API, you can point a Discord bot’s slash commands at the same /api/vote and /api/tierlist endpoints instead of, or alongside, the web frontend.

How do I keep the brawler roster updated when Supercell releases a new one?

Add a row to the brawlers table through the admin route from Step 11, or extend seed_data.py and re-run it, since INSERT OR IGNORE won’t duplicate existing entries.