A self-hosted stats tracker for BattleBit Remastered. It polls the public API, keeps a time series of every leaderboard player and clan, browses the live server list, and charts all of it on a dark interface.
Everything it shows is real data from publicapi.battlebit.cloud. Nothing is
simulated, and where the upstream data can't support a claim the UI says so
instead of guessing.
Live: https://bbrstats.duckdns.org
- Leaderboards — all nine player boards plus the clan board, searchable and paginated, with per-row value gain and rank movement.
- Player profiles — lifetime totals per board, rank and percentile, an estimated level, and two linked charts (value over time, rank over time) that re-render when you pick a different board or time range.
- Server browser — live list with region / mode / map facets, plus a detail page showing population over time, map rotation, and round boundaries.
- Overview — current population, who leads each board, who is gaining fastest, and where people are actually playing.
- Search —
⌘K(or/, or the button) across players, clans and servers. - BattleBit Arena — an archive of the community 1v1 ladder at bbrarena.gg: every match, every kill, the weapon used and where it happened, plus a ladder, head-to-head records and per-player weapon breakdowns. Clearly labelled as a scraped archive throughout, and kept in separate tables from the live-API data.
docs/DEPLOY.md— running it locally or on a VPS, resetting the data, and the disk-growth knobsSECURITY.md— threat model, hardening, and what an active sweep actually founddocs/ROADMAP.md— what's next and whydocs/CLAN-PAGES.md— design for clan-maintained rosters, and what adding a write surface would cost
docker compose up -d
# http://localhost:8106The first poll runs at boot: the server list lands in under a second and the full leaderboard (~45,000 observations) in about three. History accumulates from there — charts that need two points will say so until they have them.
Without Docker:
cd backend
npm install # one dependency: express
npm start # serves the API and the SPA on :8090, ingest includedNode 22 or newer is required — the app uses the built-in node:sqlite, so there
is no native module to compile and no build step for the frontend.
The previous version stored everything as JSON documents that were rewritten in
full on every poll cycle. At ~23,000 players across nine boards that produced a
292 MB players.json and a 535 MB ranks.json.
This version writes only what moved:
stat_currentholds one row per player per board — the latest value and rank. Leaderboard reads hit this and nothing else.stat_historygains a row only when a value actually changes. A player who hasn't played since the last poll costs oneupdated_atwrite.- A rank that drifts without the underlying value moving (someone else passed you) is throttled to at most one point an hour, measured against the last logged rank so a change during the quiet window is deferred, not lost.
The same 23,575 players and 5,000 clans occupy 7.7 MB. A second poll one minute later wrote zero history rows.
| Before | After | |
|---|---|---|
| Store | JSON documents, full rewrite per cycle | SQLite, append-on-change |
| Size at ~23k players | 827 MB | 7.7 MB |
| Full leaderboard ingest | — | ~3 s |
| Runtime dependencies | express, discord.js | express |
Every endpoint is read-only.
| Endpoint | Returns |
|---|---|
GET /api/overview |
live population, counts, leaders, movers, distributions |
GET /api/leaderboard/:category |
one board — ?page, ?perPage, ?q |
GET /api/player/:name |
profile: per-board value, rank, percentile, level, activity |
GET /api/player/:name/series |
time series — ?category, ?range=24h|7d|30d|all |
GET /api/compare?names=a,b |
up to four players on one board |
GET /api/servers |
live list with facets — ?region, ?gamemode, ?map, ?q, ?sort |
GET /api/server/:id |
samples, map rotation, round boundaries |
GET /api/clans, GET /api/clan/:id |
clan board and clan detail |
GET /api/search?q= |
players, clans and servers |
GET /api/arena/overview |
Arena archive totals, activity, top weapons, ladder |
GET /api/arena/ladder |
Arena ladder — ?sort, ?q, ?min, ?page |
GET /api/arena/matches |
Arena matches — ?player, ?weapon, ?page |
GET /api/arena/match/:id |
full kill log, weapons and arena polygon |
GET /api/arena/player/:name |
record, weapons used, head-to-head, rating history |
GET /api/arena/weapons |
weapon usage across the whole archive |
GET /api/health |
counts and the last ten ingest runs |
POST /api/refresh |
force an ingest; 404s unless ADMIN_TOKEN is set |
There is no login, because there is nothing to protect — the app has no write
surface unless you deliberately enable ADMIN_TOKEN.
Reads are rate limited per IP (240 requests/minute by default, RATE_MAX and
RATE_WINDOW_MS to tune). Static assets are exempt — throttling those would
break page loads for anyone sharing a public IP. The limiter lives in the app
rather than the proxy so it travels with the deployment.
These are properties of the public API, surfaced in the UI rather than papered over:
- No player IDs. Rows are keyed by display name, so a rename starts a fresh history under the new name.
- Names are not unique. A single board carries 25–40 repeated display names — two different players both called "Harry" at different ranks. Each board is collapsed to one row per name, keeping the best-ranked occurrence, and the profile discloses the collision count instead of blending the histories.
- Boards are truncated to roughly the top 5,000, so "percentile" means position within the published board, not within the whole player base.
- Level is estimated. The API publishes no level field; the curve here is an approximation and is labelled as such everywhere it appears.
- No per-match data. Server "rounds" are inferred from map changes between
consecutive polls, and clan membership is inferred from a
(TAG)prefix in a player's name — both are labelled as inferred.
A clan can maintain its own roster. The operator mints a PIN for whoever runs the clan; the first time they use it they set a password, and the PIN stops working. From then on they sign in on their clan's page and can write a description, add social links, and add/edit/reorder/remove members with free-form roles.
Links are allowlisted to http/https, and the YouTube / Twitch / Discord icon
is derived from the parsed hostname on the server — a link cannot wear a badge
it has not earned, and every link shows its real destination.
Everything else on the site stays a read. This is the only place a visitor can
write, so it carries scrypt passwords, server-side sessions whose tokens are
stored only as hashes, HttpOnly + SameSite=Strict cookies, CSRF tokens on
every write, a per-credential lockout, and authorisation taken from the session
rather than the URL.
→ docs/DEPLOY.md ·
SECURITY.md
A local-only control panel at /admin, on the tailnet and localhost but never
on the public domain. Seven pages: Overview, Cadence, Traffic, Arena, Clan
access, Settings, Maintenance.
The Traffic page carries an offline world map of where readers connect from, the
full visitor log (sortable, searchable, filterable, paged), request history, and
a manual blocklist. Geolocation is a local table — no address ever leaves the
machine. Install it with npm run geoip:update; without it the map says so and
everything else carries on.
Poll cadence, rate limits and feature switches live in the database, not only in
the environment. They are edited from the operator dashboard at /admin and
take effect on the next poll or the next request — no restart. An
environment variable of the same name is the default for a setting that has
never been changed, so a fresh clone behaves exactly as documented and stays
tunable afterwards.
Fourteen settings across four groups: ingest cadence, Arena schedule, public limits, feature switches. Each is range-checked; a key that is not in the registry cannot be written at all.
Everything is optional; see .env.example and
docs/DEPLOY.md for the full table.
Resetting the data is an operator command, not a web endpoint:
docker compose exec battlebit-stats node admin.js reseed # wipe and pull fresh
docker compose exec battlebit-stats node admin.js status # size and growth
docker compose exec battlebit-stats node admin.js backup # safe on a live dbGranting a clan access to their page, and archiving the Arena ladder, are also
operator commands — see
docs/DEPLOY.md:
docker compose exec battlebit-stats node admin.js clan-grant 5001 --discord "name"
docker compose exec battlebit-stats node admin.js clan-grants
docker compose exec battlebit-stats node admin.js clan-revoke 5001
docker compose exec battlebit-stats node admin.js arena-scrape
docker compose exec battlebit-stats node admin.js arena-updateThere is also a local-only operator dashboard at /admin for archive
status, Arena updates and minting clan PINs. It is served on localhost and the
tailnet only — the public domain returns 404 for it, and it is deliberately not
on a Tailscale Funnel. See
docs/DEPLOY.md.
It asks you to type a confirmation word first. Nothing destructive is reachable
over HTTP — see SECURITY.md.
The interface follows the Hallmark Midnight theme in the atmospheric genre:
near-black paper tinted toward the anchor hue rather than #000, a single cool
indigo accent, Space Grotesk over Inter with JetBrains Mono for figures. Fonts
are self-hosted — the app ships a strict CSP (default-src 'self') and fetches
nothing from a CDN at runtime.
Charts are hand-built SVG with no charting library. The categorical palette was validated with the data-viz palette checker against this app's actual dark surface and passes on lightness band, chroma floor, colour-vision separation, normal-vision separation and contrast. Distribution bars deliberately use a single hue: they compare one measure across labelled categories, so a second colour channel would encode nothing.
Layout tokens live in frontend/css/tokens.css and are portable.
cd backend
npm test # append-on-change rules, profile maths, sampling, rate limiting
npm run ingest:once # one live poll, exits non-zero if a feed failedThe frontend has no build step. frontend/js/core/ holds a ~70-line signal
implementation, a hash router and the fetch layer; frontend/js/charts/ holds
the chart primitives; frontend/js/views/ holds one file per route.
Product features live in docs/FEATURE-ROADMAP.md;
hosting and operations in docs/ROADMAP.md.
docs/ROADMAP.md — five phases. Phase 4 (a dedicated public
DuckDNS domain) is live. The measured finding driving the rest: history grows
~615,000 rows/day, and 76 % of those rows are rank drift rather than anyone
actually playing — bounding that, plus backups, is the next real work.
MIT. Not affiliated with BattleBit Remastered or its developers. Map thumbnails
under frontend/maps/ ship with the repository.

