All pages

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.

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:

ConventionRule
NumbersEvery numeric value on the wire is a JSON integer. A fraction travels as fixed-point, scaled by its unit
TimeTimestamps are epoch milliseconds in UTC; durations are milliseconds
CoinsWhole coins, truncated toward zero
UUIDsUndashed, lower-case, 32 hexadecimal characters
NamescamelCase for JSON fields, lower.dotted for counter keys
Missing valuesA 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):

IdentifierFormComes fromIdentifies
uuidMojang UUID, 32 hexThe signed-in session only, never a request bodyThe person
profileIdHypixel profile id, 32 hexThe Profile ID: line Hypixel prints on every SkyBlock joinWhich SkyBlock profile
installId32 random hexA file written once per game installThe axis along which two computers merge
dayYYYY-MM-DDThe client, in the account's time zoneThe bucket every counter lives in
keyA dotted stringThe recorderWhat 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):

ChangeNew schema?
A new counter keyNo: a key is data
A new timeline kind or session activityNo
A new namespaceYes: a namespace is a consent boundary
A new unitYes: the page's formatters are a closed list
A new field on any object, or a new mirror sectionYes

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):

KindMeansTwo installs, one dayAcross days
countEvents or a quantity that daySumSum
maxA best: top score, biggest hitMaximumMaximum
minA best that is smallest: fastest runMinimumMinimum
gaugeA level or balance as it stood, with the day's low and highThe later readingThe 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).

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):

PartReplayed or sent twice
The whole uploadThe same batchId within 7 days gets the stored answer back, marked as a duplicate, and nothing is applied
CountersThe incoming absolute value replaces the stored one, so applying it again changes nothing
Two installsNever a conflict: they are separate rows, folded when read, by the key's kind
SessionsReplaced by id, and only by a copy that ended at least as late
Timeline eventsA repeated id is ignored
Mirror sectionsReplaced 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 gauge counter 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 itself

Each 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

SectionsSubject
§4The profile mirror: the player's own profile as their own client saw it, section by section
§5The consent categories, visibility, the share key and the settings document
§6Signing in: the Mojang check, the token, devices, the development sign-in
§7 and §8Every write and read endpoint, with its errors
§9What the service validates, the denylist of shapes another player's name takes, and what the mod scrubs first
§10Backfill, versioning and the golden fixtures in full
§13What v1 deliberately does not do

The sections not listed are about the service's own storage and running it.