Technologie

How this site is built, what it stores, and which numbers are measured rather than computed here.

Rendering

Next.js 15 with the App Router, React 19 and TypeScript in strict mode. Styling is Tailwind v4 with the palette declared as theme tokens in a single stylesheet; there is no component library and no chart library. The five chart types are hand-written SVG so they render on the server and inherit the theme variables, which is also why light mode costs nothing at runtime.

Every page under the locale segment is rendered per request rather than statically. That is a deliberate choice, not an oversight: the header shows per-session state (who is signed in, their bookmarks), so the locale layout sets dynamic = "force-dynamic" and the whole subtree opts out of static generation. The locale itself lives in the URL; middleware negotiates it from a cookie and then Accept-Language, and copies the chosen locale onto a request header so the root layout can set <html lang>.

Pages never call the official API directly. They call a single service module which decides per request whether to serve the live API, the local cache or, when no token is configured, deterministic demo data — and returns that decision alongside the data so the UI can label it.

The local index

Persistence is node:sqlite, the SQLite binding built into Node 22 and newer. There is no native module to compile, no database server to run and no ORM — the whole data layer is one file of SQL plus a handful of small typed helpers. The file lives wherever DATABASE_PATH points, defaulting to ./data/brawlinsights.db, and is opened with WAL journalling, foreign keys on and a five-second busy timeout.

The schema is created with CREATE TABLE IF NOT EXISTS on every start. Since that never adds a column to a table that already exists, new fields are applied by a separate step that inspects each table and issues the missing ALTER TABLE statements, so an existing installation picks up new columns instead of having to be thrown away.

It holds everything the official API does not give us:

  • the player and club directory that makes name search possible;
  • name history and club history, reconstructed by noticing changes between polls;
  • hourly progression snapshots per player, which is where the trend charts come from;
  • the archived battle logs and the daily aggregates rolled up from them;
  • equipped skins, and the ranked tier-label table learned from observed profiles;
  • accounts, sessions, bookmarks, viewing history, board posts and announcements.

Talking to the official API

One module wraps api.brawlstars.com/v1. Every response is also held in Next’s data cache with a per-endpoint revalidation window and a cache tag, and player and club profiles are additionally reused from the SQLite copy for a short TTL before a new request is made at all. When a request fails for any reason other than 404, the last stored copy is served rather than erroring the page; a 404 means the account is gone, so the page reports not found and the refresh queue drops that tag from the index.

RequestRevalidate after
Player profile120 s
Battle log60 s
Club, club members300 s
Leaderboards900 s
Event rotation600 s
Brawler roster86400 s

API tokens are bound to the public IP that makes the requests, so a 403 accessDenied.invalidIp is by far the most common failure in production. It is detected specifically and surfaced with a plain-English hint on the health endpoint instead of being logged as a generic error.

The refresh queue

The API has no push channel and no “changed since” filter, so the only way to stay current is to poll — and polling everything equally would be both slow and wasteful. Instead every indexed player falls into a priority band, and a record is only a candidate for refresh once it is older than that band allows. Candidates are then processed highest band first, oldest record first.

hotbookmarked by any account, or viewed in the last hour2 min
warmviewed in the last day15 min
coldeverything else that has ever been indexed6 h

In that profile pass, a battle log is fetched alongside the profile only for the hot and warm bands — a cold account is unlikely to have played since we last looked, and each log is a second request. Clubs get their own sweep on the warm threshold with bookmarked clubs first, and refreshing a player also refreshes that player’s club, which keeps club pages current without a third queue.

Battle logs are not confined to those bands, though. A separate harvest pass — normally the largest share of each cycle — walks the entire index by how long it has been since each account’s log was last collected, cold accounts included, fetching a log for anyone not harvested within HARVEST_INTERVAL (30 min). An account nobody has viewed still plays matches, and those matches are what grow the tier-list sample — so this pass, not the profile queue, is what builds the corpus.

Every outbound request first takes a token from a bucket that refills at BS_API_RPS tokens per second (default 8) up to a capacity of BS_API_BURST (default 16). Because the bucket is a single shared object, the rate holds no matter how many cycles or manual refreshes overlap; a caller that finds the bucket empty waits for the exact time it needs rather than spinning.

A cycle is driven either in-process — an interval every SYNC_INTERVAL_MS (default 30 s) spending at most SYNC_BUDGET requests (default 100), normally split roughly one half to the battle-log harvest, about a third to player profiles and the remainder to clubs, flipping to favour profiles while more than a thousand newly discovered accounts still await their first profile fetch — or, where the platform cannot hold a background interval, by an external scheduler calling /api/cron. Overlapping cycles return immediately rather than doubling the request rate, the first run after boot is staggered by a few random seconds so a restart storm does not hit the API at once, and a 429 or an invalid-IP 403 breaks the cycle early instead of hammering a door that is closed. A prune pass every six hours drops archived battles older than 90 days, aggregates older than 120 days and snapshots older than a year.

The refresh now button on a player or club page bypasses every TTL through a separate endpoint with a 20-second per-tag cooldown, so a held-down button cannot be turned into an amplifier. /api/health reports live-or-demo mode, index sizes, archive size, queue depth and the last error.

The battle archive

The API only ever returns a player’s last 25 battles, and there is no history endpoint. Everything on this site that looks further back than 25 matches exists because those windows are archived: every profile view and every queue refresh stores whatever is new, keyed so that seeing the same battle twice is a no-op.

As each battle is archived it is also rolled into a daily aggregate table keyed by day, mode, map, bracket and brawler, counting picks, wins, draws and star-player awards. Four rows are updated per battle — the actual map and a synthetic all map, each written twice: once under the coarse bracket (ranked or trophies) and once under the exact band (ranked:masters, trophies:500-750). The all map rows are what make a mode-wide tier list a single indexed read rather than a scan, and the band rows are what make the rank-range and trophy-range filters on the tier lists real rather than decorative. Friendly battles and battles where the player’s brawler cannot be identified are skipped.

Showdown has no win or loss field, only a placement, so a finish in the top half counts as a win: top 5 of 10 in solo, top 3 of 5 in duo, top 2 of 3 elsewhere. That rule is applied consistently in win rates, tier lists and per-brawler breakdowns.

Because the archive is built from the accounts this instance has seen, it is a sample, not the full match population — and a sample biased toward accounts people look up. That affects absolute counts far more than it affects relative shape.

How tier lists are scored

Ranking by raw win rate puts whichever niche brawler had a good week with 40 games at the top. Ranking by pick rate just re-labels popularity. The score blends the two, with the win rate discounted according to how much of it we actually believe.

rows          = { b : picks(b) ≥ 200 }
globalWinRate = Σ wins / Σ picks

shrunk        = (wins + globalWinRate * 1500) / (picks + 1500)
pickRate      = picks / total picks
popularity    = log10(1 + pickRate * rows * 3) / log10(4)

score         = (shrunk - globalWinRate) * 100 + popularity * 1.8

The 1500 is a prior expressed in pseudo-battles: a brawler with 200 picks is pulled most of the way back to the global mean, while one with 30,000 picks keeps essentially its own win rate. This is the empirical-Bayes shrinkage idea, applied crudely but transparently. The popularity term is logarithmic so that a brawler picked ten times as often is worth a modest bonus rather than ten times the score.

Tiers are then cut from the distribution of scores rather than from fixed thresholds: the mean and standard deviation are computed over the brawlers that qualified, and each brawler is placed by how many standard deviations above or below the mean it sits — S at +1.25, A at +0.5, B at −0.25, C at −1, and D below that. A consequence worth knowing: the tiers are relative to the current meta, so a list always has an S tier even when the meta is flat.

A tier list is only built from real data when the selected window holds at least 5,000 picks (the default window is seven days). Below that the page falls back to the deterministic generator and flags itself as demo data rather than showing a confident ranking derived from a few hundred matches.

Straight from the API

These are read from the official response and displayed as-is. The API publishes more than most community wrappers model, so several numbers that other sites estimate are measured here.

  • Trophies, highest trophies, experience level, 3v3 / solo / duo victories
  • Ranked tier index and its display name, ranked elo, season id
  • Peak ranked tier and elo, for the season and all-time
  • Total prestige level, fame and fame tier
  • Per brawler: power, rank, trophies, prestige, current and max win streak
  • Per brawler: gadgets, star powers, gears, hypercharges, equipped skin
  • Club membership, club roster and member roles, required trophies
  • Leaderboards by country, and the live event rotation
  • The last 25 battles per player, with mode, map, type, result and trophy change
  • The brawler roster itself, pulled by a sync script into a generated file — currently synced 15/09/2026

Derived here

These do not exist in the API in any form. They are computed from what this instance has observed, and every one of them is labelled where it appears.

  • Name history and club history — built by diffing successive polls
  • Trophy, elo, prestige and fame trends — from the hourly snapshot table
  • Tier lists, win rates, pick rates and star-player rates — from the battle archive
  • Skin usage ranking — counted across the equipped-skin field of indexed profiles
  • The ranked tier-label table — learned from observed profiles, not hard-coded
  • Estimated play time — from victory counts and experience level, roughly ±15%, since the API exposes no clock
  • The ranked-score timeline — ranked battles carry no score delta, so wins and losses are modelled at roughly ±30 to reconstruct the shape of a season
  • Rarity and class per brawler — the only roster fields the API omits, looked up by name from a bundled table and left as unknown rather than guessed
  • The calculator tables (upgrade costs, trophy change, Star Drop odds, Trophy Road) — transcribed from the game, snapshot 2026-09-16

What is still modelled

Some pages are not yet backed by real data, and say so on the page itself rather than quietly presenting a guess:

  • Ranked distribution. The page is wired to the real query over the ranked tier recorded on every fully indexed profile, and falls back to a modelled ladder only while fewer than 200 indexed profiles carry a ranked tier — the indexed population is small and skewed enough that the shape would mislead below that. When it falls back it says so, naming the count it has.
  • Tier lists on a thin selection. A mode, map or bracket with fewer than 5,000 archived picks falls back to the modelled distribution rather than ranking a handful of matches. The page states the count it has and the count it needs.
  • Pins, battle cards and profile titles. These are not in the API in any form, so their ranking pages are demonstrations of the layout behind an explicit notice.
  • Demo mode. With no API token configured, the entire site runs on a deterministic generator seeded from the tag you searched, so the same tag always produces the same numbers. Every affected page carries a warning banner.

Fonctionnement de la recherche explains what the index means for search results.

Technologie · BrawlPeek