The Stats protocol
An overview of the wire contract between the mod, the service and the Vantage Stats page - the identifiers, the counter model, one upload, why a retry never doubles anything, the day seal and the key catalogue - with a link to the full text.
On this page
Vantage Stats is three programs: the recorder inside the mod, the service that stores what it sends, and the page
that shows it. They agree through one document,
docs/design/stats-protocol.md, in
the mod's public repository. It is long, because it fixes every name, shape, limit and number the three share. This
page is an overview of its foundations, sections 1 to 3, for a developer who wants to record something new, debug
an upload, or understand what a compatible service has to accept. When this page and the document disagree, the
document is right.
Three programs, one contract
The protocol describes what crosses the wire, in what shape, under what rules, and what the server keeps. It does
not describe the mod's classes, the page's components or the consent screen's words. Its section numbers are
stable: other documents and code comments cite them as §2.3 or §7.4, and a section that stops being true gains
a sentence saying so rather than a new number. This page cites them the same way.
A few conventions hold everywhere:
| Convention | Rule |
|---|---|
| Numbers | Every numeric value on the wire is a JSON integer. A fraction travels as fixed-point, scaled by its unit |
| Time | Timestamps are epoch milliseconds in UTC; durations are milliseconds |
| Coins | Whole coins, truncated toward zero |
| UUIDs | Undashed, lower-case, 32 hexadecimal characters |
| Names | camelCase for JSON fields, lower.dotted for counter keys |
| Missing values | A client never sends null, and absent never means zero: a value that was not observed is not there at all |
The integer rule is not fussiness. One schema keyword rejects NaN, infinities and rounding noise, SQLite stores
every value exactly, and two replays of the same uploads produce byte-identical totals.
The two rules everything follows from
Values are absolute per install and day, never deltas (§2.1.2). For every day it may still rewrite, the mod holds the absolute value of each key it touched, and an upload sends that value. Sending the same value twice changes nothing, so a lost request can be retried for free. A value going down is accepted, because an install whose local store was reset has an honest smaller answer.
Counts, never coins, except where coins actually moved (§2.1.3). Loot is stored as item counts, not as a price per item. Coin keys exist only for coins that genuinely changed hands, or a valuation that was true at that moment. The service owns no price feed and never re-prices history.
Identity
Five identifiers
Every row the protocol stores is addressed by some of exactly five keys (§1.1):
| Identifier | Form | Comes from | Identifies |
|---|---|---|---|
uuid | Mojang UUID, 32 hex | The signed-in session only, never a request body | The person |
profileId | Hypixel profile id, 32 hex | The Profile ID: line Hypixel prints on every SkyBlock join | Which SkyBlock profile |
installId | 32 random hex | A file written once per game install | The axis along which two computers merge |
day | YYYY-MM-DD | The client, in the account's time zone | The bucket every counter lives in |
key | A dotted string | The recorder | What was counted |
A uuid can never appear in a body, because no schema has anywhere to put one: every ingest object refuses
unknown fields, so a body that carries one is rejected without a check anyone could forget. The installId is
sixteen random bytes, never derived from hardware, a hostname or a path. A reserved all-zero profileId means
"the account, not a profile", for the few numbers that genuinely belong to no SkyBlock profile, such as total
playtime and Vantage's own usage.
Which profile
Nothing is attributed to a SkyBlock profile until its id is known (§1.2). What the mod observes before the id arrives waits in a pending bucket, and is discarded if the game restarts first; it is never guessed from the profile's fruit name. That name rides along for display only, and must be one of the names Hypixel actually hands out, which the catalogue lists.
Which day
A day is a calendar date in one time zone that belongs to the account (§1.3). It is set at the first sign-in from the computer's zone, and the client computes every label; the server never derives a day from a timestamp. UTC would split an evening of play across two days for most players. Two computers of one account must agree, so the zone is the account's, not each install's, and it can change at most once every 30 days. Counters belong to the day the thing happened on; a session belongs to the day it began.
Versions
Every body carries "schema": 1, and the service accepts the current schema and the one before it, advertising
both at GET /v1/health. The mod reads that once per launch. What forces a new schema is deliberately narrow
(§1.5.2):
| Change | New schema? |
|---|---|
| A new counter key | No: a key is data |
| A new timeline kind or session activity | No |
| A new namespace | Yes: a namespace is a consent boundary |
| A new unit | Yes: the page's formatters are a closed list |
| A new field on any object, or a new mirror section | Yes |
A field is never given a new meaning; a new meaning gets a new name.
Counters
Keys
A key is a namespace and one to five more segments, separated by dots, at most 128 bytes (§2.2):
key := namespace ( "." segment ){1,5}
namespace := [a-z][a-z0-9_]{1,15} and one of the closed list
segment := [A-Za-z0-9_][A-Za-z0-9_;:+#-]{0,63}After the namespace, ids go on the wire exactly as the mod already holds them: loot.item.ENCHANTED_DIAMOND,
kills.island.DWARVEN_MINES, dungeon.best_split.F7.finish, loot.item.WOLF;4.
Four kinds
A key's kind decides how values fold together (§2.3):
| Kind | Means | Two installs, one day | Across days |
|---|---|---|---|
count | Events or a quantity that day | Sum | Sum |
max | A best: top score, biggest hit | Maximum | Maximum |
min | A best that is smallest: fastest run | Minimum | Minimum |
gauge | A level or balance as it stood, with the day's low and high | The later reading | The last day's |
A count sums across installs because two computers cannot observe the same event. Once a key exists on the service, its kind, unit and category are pinned; a later upload that declares something else is accepted with a warning, never refused, so one bad key cannot wedge a player's uploads for ever (§2.6).
Namespaces and consent
The namespaces are a closed list, and each one belongs to exactly one of the ten consent categories a player turns
on one by one (§2.5). That one-to-one mapping is what makes a sentence on the consent screen checkable: turning
gathering off stops exactly mining, farming, fishing, foraging and hunting. The mod does not send a
row whose category is off; if one arrives anyway, the service drops it and says so in its response.
One upload
Everything travels through one endpoint, POST /v1/stats/ingest, in one envelope, applied in one transaction,
under one id (§7.1). Shortened, an upload looks like this:
{
"schema": 1,
"batchId": "8f14e45fce0a4b2d9b0f7c1e2a3d5b6c",
"sentAt": 1789118900000,
"mode": "live",
"client": { "install": "3b1c7a0e5d2f48a1b6c9d0e1f2a3b4c5", "modVersion": "x.y.z", "minecraft": "26.2", "catalogue": 3 },
"zone": { "id": "Europe/London" },
"settingsVersion": 7,
"profile": { "id": "d5e1b3a0f4c2476e9a8b7c6d5e4f3a2b", "cuteName": "Mango" },
"categories": ["combat"],
"counters": [
{ "profileId": "d5e1b3a0f4c2476e9a8b7c6d5e4f3a2b", "scope": "day", "day": "2026-09-20",
"rows": [ { "k": "kills.total", "v": 1842 }, { "k": "dungeon.score.F7", "v": 312, "m": "max" } ] }
]
}Beside counters, the same envelope may carry sessions, timeline and mirror. One upload covers one SkyBlock
profile, plus the reserved account scope. categories must name exactly the categories the payload contains, which
turns "the mod's scrubber ran" into something the service can check. A body over 8 KiB is gzipped, and an upload
that carries nothing at all is refused.
Why a retry never doubles anything
Each part of an upload is idempotent on its own terms (§7.2):
| Part | Replayed or sent twice |
|---|---|
| The whole upload | The same batchId within 7 days gets the stored answer back, marked as a duplicate, and nothing is applied |
| Counters | The incoming absolute value replaces the stored one, so applying it again changes nothing |
| Two installs | Never a conflict: they are separate rows, folded when read, by the key's kind |
| Sessions | Replaced by id, and only by a copy that ended at least as late |
| Timeline events | A repeated id is ignored |
| Mirror sections | Replaced by section, and only by a newer observation |
Baselines and the day seal
A baseline is one row per key meaning "everything up to and including day D" (§2.7). A key's lifetime total is its baseline folded with every later day. Two things write baselines, and they are the same arithmetic:
- Backfill. When a player first consents, the mod imports lifetime totals it already kept locally. Those numbers have no dates, so filing them on one day would draw a spike on every chart; filing them as a baseline does not.
- The retention seal. Each account chooses how long day rows are kept. Before the service deletes days older than that, it folds them into the baseline in the same transaction, so a lifetime total survives retention and can still be recomputed exactly.
The day seal is the other edge of a day's life. Every upload's answer carries sealedBeforeDay, the oldest day
that is still writable; in the current text that is 120 days back, in the account's zone (§11.7.1). Sealing a day
collapses its per-install rows into one and keeps its value, so a chart does not change, but nothing can rewrite
that day any more. A live row for an older day is refused as day_sealed, and the mod prunes such days from its
outbox rather than resending them (§7.3, §7.5). Two
installs' baselines merge by maximum, not by sum, because they are two views of one history; a seal and a backfill
add, because they cover different stretches of it. The page shows a lifetime figure with its baseline's date
beside it, and never passes one number off as the other.
The same section guards the calendar. A day one ahead of the service's own is accepted, since a player at UTC+13 is legitimately there; a day further ahead fails the whole upload. A clock that is merely wrong produces a warning, shown on the mod's status screen, and never a refusal.
Sessions, the diary and history
Section 3 adds two row types and explains why it does not add a third:
- A session is one closed stretch of one activity: the grind ledger's session on the wire, with its gains and costs. Its id is made by the client when the session opens.
- A timeline event is a notable moment: a level-up, a rare drop, a first, a personal best, a death, a Vet unlock. Its kinds are open, and the page has a fallback for one it does not know.
- A history series is not a new thing. Networth on one day, purse on the next, is a
gaugecounter read one row per day. There is no history table. A day with no observation is a gap in the chart, not a zero, and nothing is recorded at finer than a day in v1.
The catalogue
The key catalogue ships as data, not code: src/main/resources/assets/vantage/stats/catalogue.json in the mod
(§2.9). It holds every key and key pattern with its kind, unit, category, label, group, icon and scope, and beside
them the namespaces, the categories, the units, the mirror sections, the closed lists both sides validate against,
and the levelling tables, so that the mod, the Stats page and the /p/ page share one copy. Its catalogueVersion
is reported on every upload and answer; a mismatch is logged and never refused, because the catalogue is labels and
units, not validation.
python tools/stats_catalogue.py regenerates the file from tables in that script; numbers that also exist in the
mod's source, such as the experience tables, are cross-checked against it on every --check. The service vendors
the same file byte for byte and serves it at GET /v1/stats/catalogue. The golden fixtures under
src/test/resources/stats/golden/ are uploads the mod's tests emit and the service's tests ingest, so a drift fails
a test on both sides; python tools/stats_golden.py --check confirms they are current.
A key that is not in the catalogue still uploads, and appears on the page under a label made from the key itself.
Recording something new
This is the whole ceremony (§2.10), through the facade in core/stats/Stats.java:
Stats.count("mining.blocks.TUNGSTEN", 1);
Stats.min("mining.nucleus_best", millis);
Stats.max("dungeon.score.F7", score);
Stats.gauge("economy.bank", coins); // stamps the time, the low and the high itselfEach call checks the namespace's consent category, the key's grammar and the key budget, then merges into today's
table. While Stats is off, or the player has consented to nothing, or is in a lobby, a call costs one volatile read.
A proper label, unit and icon for the new key is a row in tools/stats_catalogue.py, shipped whenever.
What the rest of the document covers
| Sections | Subject |
|---|---|
| §4 | The profile mirror: the player's own profile as their own client saw it, section by section |
| §5 | The consent categories, visibility, the share key and the settings document |
| §6 | Signing in: the Mojang check, the token, devices, the development sign-in |
| §7 and §8 | Every write and read endpoint, with its errors |
| §9 | What the service validates, the denylist of shapes another player's name takes, and what the mod scrubs first |
| §10 | Backfill, versioning and the golden fixtures in full |
| §13 | What v1 deliberately does not do |
The sections not listed are about the service's own storage and running it.
Related
- The service: the program on the other end of this contract.
- What is recorded: the same categories, as a player reads them.
- Testing: the Stats tools beside the others.
- Architecture: where
core.statssits in the mod.