Analytics
Your game does not have to measure anything. This is a capability, not an obligation — but if you want to know how far players get into your tutorial, which difficulty they quit on, or whether the level you rebalanced is actually finishing more often, the platform will count it for you and your game reaches it with the token your frame already holds.
Two things decide whether this feature fits your game, and both are unusual enough to read before you design around them:
Nothing is recorded that you did not declare. There is no free-text property. Every event, every dimension and every value a dimension may take is published in your game config ahead of time, and anything else is refused per event rather than stored.
Detail older than 30 days is gone. Raw events live 30 days. The daily counters built from them live 400. A question you can only answer by reading individual events is a question you can only ask about the last month.
What you record here is yours. It feeds your own reporting and nothing the platform treats as authoritative — see section 8 before you attach a reward to it.
1. Two roles, and they are not the same person#
The same split as save slots and leaderboards: one half is something you publish, the other is code your game runs.
| Who | When | |
|---|---|---|
| Declaring events | you, the developer | once, in your game config |
| Recording events | your game's running code | whenever the thing happens |
An event your config does not declare does not exist. Recording it is not an error your game can catch — it is accepted into the queue, sent, and refused one event at a time when it arrives. Section 7 is how you see that.
2. Declare the events, in your game config#
The analytics_events section names each event, the dimensions it is broken down by and the numbers it carries:
analytics_events:
- id: level_completed
description: A level ended in a win or a loss.
dimensions:
outcome: { values: [win, loss, quit] }
difficulty: { values: [easy, normal, hard] }
level: { range: { min: 1, max: 50 } }
measures:
duration_ms: int
score: intA dimension is a closed set, and that is the point. Either a values list or a bounded integer range, each at most 64 wide, at most three per event, and the product of an event's dimension sizes is capped — the example above is 3 × 3 × 50. A string with no values list is a config error rather than a warning, because the number of distinct rows an event can produce has to be knowable before a player produces any of them.
Measures are the numbers, at most four per event, int or float. The platform keeps their sum, count, minimum and maximum per day, and the individual value for as long as the raw window holds it.
Ship it the way you ship any config:
gemctl config schema # the authoritative field list
gemctl config upload --wait # register a version
gemctl config promote <version> --channel <channel> # this is what makes it liveUploading is not shipping. Until a version carrying analytics_events is promoted to the channel your game runs on, every event your game records is refused analytics_not_declared — including on a channel you use for development.
3. Funnels are declared too#
A funnel is an ordered list of two to eight event ids, declared once and read back as players at each step:
analytics_funnels:
- id: first_session
steps: [gem_session_start, tutorial_completed, level_completed]Steps may name your own events or the platform's. A funnel is answered two different ways and the answer says which. Inside the raw window it is ordered and can cross days, with the time each player took to reach each step. Outside it, the only thing left is the daily counters, so the answer becomes same-day and unordered: a player counts at a step if every earlier step has a row on the same day. A window that straddles the boundary is answered entirely from the counters rather than mixing the two definitions in one number.
So do not compare a funnel reading from last week against one from last quarter. They are different questions with the same name.
4. What the platform records for you#
The SDK emits these on its own. You do not declare them, you cannot suppress them individually, and a game that declares no events at all still gets them:
| Event | Dimensions | Measures |
|---|---|---|
gem_session_start | — | — |
gem_session_end | reason | duration_ms |
gem_pause / gem_resume | — | — |
gem_boot_ready | — | boot_ms |
gem_match_started / gem_match_ended | transport (dedicated, p2p) | — |
gem_auth_lost | — | — |
reason on a session end is one of grant_expiring, session_ended, navigating, kill_switch or pagehide.
The gem_ prefix is reserved. Your config cannot declare an id beginning with it and gem.analytics.track refuses one rather than sending it, so pick another name.
These columns are the whole of it, and setContext does not add to them. The dimensions above are fixed by the platform, and since no config can declare a gem_ id there is no way to widen one — a key that is not in this table would refuse the whole event rather than annotate it. Your context therefore reaches your own track calls and stops there: these arrive carrying exactly what the table says, however much context you have set.
Two of these are worth knowing the shape of before you build a report on them:
gem_session_startis the handshake, not the first frame. A game opened outside the arcade never establishes a play session, so it starts no session and sends nothing. That is expected while you are developing locally.gem_session_endon a closing page is best effort. The browser gives a page that is going away a small budget for requests in flight and the SDK spends part of it here; the room leave gets the larger part, because a stranded room membership is the failure a player notices. Treatduration_msfrom apagehideend as approximate.
gem_match_ended is skipped for a match this page never saw start — a reload into a running match, for instance — because transport is a declared dimension and the SDK will not guess one. Match starts and match ends therefore do not reconcile exactly; use starts for counting matches and ends for breaking them down.
5. Record an event#
gem.analytics.track('level_completed', {
outcome: 'win', // a string is a dimension
difficulty: 'normal',
level: 7,
duration_ms: 48120, // a number is a measure
score: 3100,
});A string is a dimension and a number is a measure, and which one a key is was settled by your config, not by the call. Passing level: '7' where the config declares a range is a refusal, not a conversion.
track is synchronous, never throws and never rejects. It puts the event on an in-memory queue that goes out on a timer, when it reaches 50 events, and on the way out of the page. Nothing is sent by the call itself, so a game that never flushes still reports.
Values every event should carry go in the context instead of on every call site:
gem.analytics.setContext({ build: 'v1.4.2' });Context reaches your events, never the platform's. It is merged into what you pass track and into nothing else — the gem_* events in section 4 have fixed dimensions no config can widen, so a context key on one would refuse the event instead of labelling it. Setting a context cannot cost you your session, pause, boot or match data.
A context key is declared like any other. One key your event does not carry refuses every event it reaches, not just the one you were thinking about — which is why the SDK does not stamp its own version or your game's onto your events. It offers them instead, for a game that has declared the dimensions to take them:
gem.analytics.setContext(gem.analytics.environment);Every dimension you add costs cardinality against the product cap, so do that on the handful of events where knowing which build produced a number is worth a dimension slot.
You can send the queue deliberately and read what happened to it:
const { accepted, rejected, dropped, duplicate } = await gem.analytics.flush();You rarely need to. The SDK flushes on its own every ten seconds — that is also the floor, because the platform limits how often one player may post — and again on every way out of the page, so the only reason to call it by hand is to look at the answer.
GemOptions.analytics carries the three knobs: enabled, flushIntervalMs and maxQueue. enabled: false turns your own track calls into no-ops and leaves the platform's gem_* events alone — it is narrower than it looks.
6. What your dedicated server records, and why it differs#
A server bundle reaches the same surface as gem.analytics, with one signature difference:
export default defineServer((gem) => ({
async shutdown() {
gem.analytics.track(winnerId, 'match_result', { outcome: 'win', score: 3100 });
await gem.analytics.flush();
},
}));The player id comes first and is required. A server credential names no player, so an event from a server has to say who it is about and the platform will not infer one. Take the id from join() or from the host's player list.
Flush before your match ends. A dedicated server is torn down when the match is over and nothing gets a last word in for it — there is no closing page here to trigger a final send. await gem.analytics.flush() in shutdown is the whole of it, and skipping it loses the tail of the queue. See server-bundles.md for the rest of a bundle's lifecycle.
A server cannot emit gem_* events. Those are declared client-sourced, so a server-side gem_match_started is refused; record your own event instead.
7. When something is refused#
Refusals arrive inside a successful response, one per event. A batch carrying one bad name is not rejected — its other events are written and the bad one comes back at its index. The SDK prints each refusal to the console in development, and flush() returns them in rejected.
| What you get | What happened | What to do |
|---|---|---|
analytics_not_declared | This channel resolves no analytics_events at all | Promote a config version that has the section to this channel |
unknown_event | The channel declares a set and your name is not in it | Check the id against your config — most often a typo or a new event not yet promoted |
unknown_dimension | A key you sent is not on that event | Declare it, or stop sending it — context keys count |
missing_dimension | A dimension the event declares was absent | Send every declared dimension on every event |
dimension_value_not_declared | The value is outside the closed set | Add the value to the config, or map it to one that exists |
unknown_measure | A number you sent is not a declared measure | Declare it, or drop it |
measure_wrong_type | An int measure got a fraction, or a dimension got a number | Match the declared type — a string is a dimension, a number is a measure |
source_not_permitted | This side may not emit this event | Narrow or widen sources on the event, or record it from the other side |
Two results are not refusals and should not be treated as one:
droppedcounts events the local queue threw away because it was full. The oldest go first and the cap defaults to 500. It is nonzero only when your game produces events faster than they can be sent, and it is worth alarming on.duplicate: truemeans the platform had already seen that batch and wrote nothing. That is a retry whose first attempt in fact landed — it is what stops a send racing a page close from being counted twice.
A rate limit or a temporarily unreachable backend keeps the queue and rejects the flush promise, so the next send carries the same events. A refusal that would fail identically forever discards its batch instead, because a queue that can never empty fills up and starts dropping the events that would explain the problem.
8. What analytics is not#
Client events are game-asserted browser facts. Anything a browser can record, a modified browser can record differently — the same boundary that keeps a leaderboard score off the client in leaderboards.md and an economy grant behind a recipe in inventory-and-catalogs.md.
So what you record here feeds your reporting and nothing else. It is not a basis for:
rewards or grants — run those through the economy, where the arithmetic happens on the server;
daily challenges or streaks — those are config-declared and settled by the platform;
store ranking or discovery — the platform does not read your events for any of it;
player progress the game reads back — analytics is write-only from the game's side. Progress that decides what a player sees belongs in saving-player-progress.md.
When a fact has to be true rather than reported, record it from a dedicated server, which holds a credential a player does not. That is the authoritative half of the pair, and it is the only half that is.
Nothing per-player comes back. The reads answer with counts and with how many distinct players reached a step; there is no drill-down to an individual, on any surface.
