Remote config, analytics & crash reporting
Three live-ops tools let you tune and understand your game after it ships, without uploading a new build:
Tool | In your game | Where you manage / read it |
|---|---|---|
Remote config |
| Game dashboard → Live config, or |
Analytics |
| Game dashboard → Analytics, |
Crash reporting |
| Game dashboard → Analytics → Crashes (same endpoint) |
The platform also records plays and players for you automatically. You get daily plays, daily players and returning players even if you never call trackEvent. See Built-in metrics.
For exact signatures, see GameSDK live-ops reference. For the GameSDK global and the game lifecycle (ready(), start()), see GameSDK core reference and How games run.
Remote config
Remote config is a JSON object stored with your game. Your game reads it at runtime, and you can change it at any time from the dashboard or the API. The change takes effect without a re-upload and without another review. Typical uses:
difficulty and balance numbers (enemy speed, drop rates, timers)
feature flags (turn a new mode on or off)
seasonal or event content (a theme name, a banner message, a limited-time multiplier)
kill switches (disable a feature that turned out to be broken)
Shape
The top level must be a JSON object. It can't be an array, string or number.
Values can be any JSON: strings, numbers, booleans,
null, arrays and nested objects.A game that has never had a config set returns
{}.
{
"difficulty": "normal",
"dropRate": 0.12,
"features": { "dailyChallenge": true, "newBoss": false },
"event": { "name": "Harvest Festival", "scoreMultiplier": 2 }
}Size limit
The config must be under 16 KB once serialized. The server measures the length of the JSON text (JSON.stringify(config).length) and rejects anything over 16,000 characters with 400 too_large. Store tuning values in config, not content. Large level data belongs in your game bundle.
Editing the config
From the dashboard. Open your game's manage page in the creator dashboard and find the Live config panel. Edit the JSON and click Save. The editor checks that the JSON is valid and that it is an object before it saves.
From the API. Use an API key with the config scope (see API keys & scopes). A signed-in dashboard session can also make these calls. You must own the game.
# Read the current config
curl https://api.coolgptgames.com/v1/dev/games/$GAME_ID/config \
-H "Authorization: Bearer $COOLGPT_API_KEY"
# → { "config": { "difficulty": "normal", "dropRate": 0.12 } }
# Replace it
curl -X PUT https://api.coolgptgames.com/v1/dev/games/$GAME_ID/config \
-H "Authorization: Bearer $COOLGPT_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "config": { "difficulty": "hard", "dropRate": 0.08 } }'
# → { "ok": true, "config": { "difficulty": "hard", "dropRate": 0.08 } }
PUTreplaces the whole object. Nothing is merged. Keys that you leave out are deleted. To change a single value, read the config, modify it, then write the whole object back.
The Publisher SDK wraps these two calls as getConfig(gameId) and setConfig(gameId, config). The platform keeps no version history. To be able to roll back, keep a copy of your config yourself, for example in source control.
Config is public
A published game's config is served from a public, unauthenticated endpoint that anyone can call:
curl -i https://api.coolgptgames.com/v1/games/$GAME_SLUG/config
# cache-control: public, max-age=30
# → { "config": { ... } }Never put secrets in remote config: no API keys, no admin passwords, no unreleased spoilers you care about. Anything in the config is readable by anyone who knows your game's slug.
Two things to know about who gets an answer:
Published games only. A draft, a game in review, a rejected, delisted, DMCA-removed or deleted game answers
404 not_foundto the public — exactly as if the game didn't exist, so an unreleased game's tuning values don't leak before launch.You can read your own. Send a session, or an API key with the
configscope, and the owner (or an admin) gets the config back for a game in any state, so you can test before you publish. That answer is sentprivate, no-store.
Public reads carry Cache-Control: public, max-age=30, which allows a client to reuse an answer it already has for 30 seconds. Nothing shared caches it. We checked every response from the production API: each one came back cf-cache-status: DYNAMIC with no age header, so no CDN or proxy sits in front of this endpoint. A save is live on the next request.
How the SDK fetches and caches config
The first
getConfig()call asks the platform for the config. Calls made while that request is in flight share it and resolve with the same object.A successful response is cached for the rest of the page load. Every later
getConfig()call resolves with the same cached object straight away and makes no network request.If the platform can't load the config (for example, a network error), your game receives
{}. Nothing is cached, so the nextgetConfig()call tries again.If no answer arrives within 6 seconds, the promise resolves with
{}and nothing is cached. The nextgetConfig()call tries again. If the answer arrives late, it's cached for that next call.
getConfig() never rejects. The worst case is an empty object, so your game must always have its own defaults (see Safe patterns). Because a failure isn't cached, you can call getConfig() again at a later safe point, such as the next menu screen, to pick up the config after a failed first load.
The cached object is shared. If you change it (cfg.x = 1), later getConfig() calls return your changed copy. Treat it as read-only.
Change propagation
Saving takes effect immediately. There's no CDN or edge cache to wait out and nothing to purge: the next game to load reads the new values. - The only staleness that matters is the SDK's own in-memory cache, which holds for the page load. A player who is already playing keeps the config they loaded until they reload the game. No push or notification tells a running game that the config changed. - So "how long until my change reaches players?" has a simple answer: as fast as they reload. A change you make now is live for the next player who opens the game, and invisible to everyone currently in a session.
Design changes so that it's safe for old and new values to run at the same time for a while — you'll always have both populations at once.
There is no way to force a refresh mid-session. Once
getConfig()has a successful answer it never fetches again on that page load, so a value a running game has already read is fixed until the player reloads.
Safe patterns
Always merge over defaults. Your game has to work when the config is {}: on first launch, when the config fails to load, or after someone deletes a key.
const DEFAULTS = {
difficulty: "normal",
dropRate: 0.1,
features: { dailyChallenge: false },
};
async function loadConfig() {
const remote = await GameSDK.getConfig(); // never rejects; may be {}
return {
...DEFAULTS,
...remote,
features: { ...DEFAULTS.features, ...(remote.features || {}) },
};
}Validate types and ranges. A typo in the dashboard shouldn't break the game.
function num(v, fallback, min, max) {
return typeof v === "number" && v >= min && v <= max ? v : fallback;
}
const dropRate = num(cfg.dropRate, 0.1, 0, 1);Feature flags default to off. Check that the flag is true, not just truthy. A missing key then leaves the feature off.
if (cfg.features.newBoss === true) spawnNewBoss();Read at a known point. Call getConfig() during loading, before GameSDK.start(). Don't read it mid-run. A successful value won't change during the page load anyway, and reading only between runs keeps gameplay deterministic. This matters if you use replay-verified scores; see Scores, leaderboards & anti-cheat.
Gradual rollouts are up to you. The platform returns the same config to every player. To show a feature to only some players, put a percentage in the config and decide on the client:
// cfg.rollout.newBoss = 25 → about 25% of page loads see it
const pct = num(cfg.rollout?.newBoss, 0, 0, 100);
const enabled = Math.random() * 100 < pct;To keep a player in the same group across sessions, store the random roll with the SDK's save API (see Saves & cloud progress) and reuse it.
Keep a kill switch. For any risky new feature, add a flag such as features.shopEnabled from day one. You can then turn the feature off in seconds without a new build.
Analytics
Call trackEvent to record the moments that matter in your game: level starts and completions, deaths, purchases, tutorial steps.
GameSDK.trackEvent("level_complete", { level: 3, timeMs: 42000, stars: 2 });
GameSDK.trackEvent("boss_defeated", { boss: "dragon", attempts: 4 });
GameSDK.trackEvent("tutorial_step");trackEvent is fire-and-forget. It returns nothing, never throws, and doesn't tell you whether the event was stored.
When events are recorded
Events are attached to the player's current play session. The platform opens a session when your game calls GameSDK.start(). Requesting a replay seed also opens one (see Scores, leaderboards & anti-cheat).
Events sent before the first
GameSDK.start()are silently dropped. Callstart()when gameplay begins, then track.Events sent after the player leaves the game page are dropped.
Sessions only open for published games. Unpublished games don't record events.
Guests and signed-in players are both tracked. Signing in isn't required.
This works both on coolgptgames.com and when your game is embedded (see Embedding & the score bridge).
The platform attaches the game, the player (signed-in user, or an anonymous browser ID for guests) and the session to each event. Don't put that information in props yourself, and never put personal data such as emails or real names in props.
Event names
Rule | Value |
|---|---|
Length | 1–64 characters. The SDK cuts longer names to 64. |
Allowed characters | Letters, digits, |
Reserved |
|
A name with any other character (for example /, !, # or non-ASCII letters) is rejected, and the event is lost without an error. Use a consistent snake_case or dot.case scheme, such as level_complete or shop.open.
Event properties
Rule | Value |
|---|---|
Type | A plain JSON object. Leave |
Top-level keys | At most 50 |
Nesting | Allowed (nested keys don't count toward the 50) |
Events that break these limits are rejected, not truncated. The whole event is lost, and the SDK doesn't report the error to your game. Keep props small: IDs, numbers and short labels. Don't send whole game states.
Arrays are not accepted as props. Values that can't be sent between windows, such as functions or DOM nodes, cause the event to be dropped.
Rate limits and sampling
Up to 120 events per minute per play session. Events beyond that get
429and are lost.Event requests also count toward the platform-wide per-IP request limit that every API call shares (see Errors & limits).
The game-to-page message channel accepts at most 30 SDK messages per second across all SDK calls.
The platform does no sampling: every event that is accepted is stored. The limits above are ceilings, not targets. Don't track every frame or every input. For high-frequency actions, count them locally and send one summary event (for example run_summary with { jumps: 212, coins: 48 }) at the end of a run.
Where you see results
Open your game's manage page in the creator dashboard and find the Analytics panel. It covers the last 30 days and shows:
Daily plays / daily players, plus returning players — shown for every game, whether or not you ever call
trackEvent(see Built-in metrics)Total events: every event recorded, crash reports included - Unique players: distinct players (signed-in or guest) who sent at least one event - Top events: your most frequent event names with counts - Crashes: your most frequent error messages with counts
The same data is available from the API with the analytics scope:
curl https://api.coolgptgames.com/v1/dev/games/$GAME_ID/analytics \
-H "Authorization: Bearer $COOLGPT_API_KEY"{
"game": { "slug": "sky-hopper", "title": "Sky Hopper" },
"totalEvents": 18234,
"uniquePlayers": 1502,
"events": [
{ "name": "level_complete", "count": 9120 },
{ "name": "boss_defeated", "count": 2210 }
],
"errors": [
{ "message": "physics step overflowed", "count": 14 }
],
"daily": [
{ "date": "2026-09-19", "plays": 640, "players": 512 },
{ "date": "2026-09-20", "plays": 702, "players": 560 }
],
"totalPlays": 12480,
"totalPlayers": 3190,
"returningPlayers": 311
}Field | Meaning |
|---|---|
| All events in the last 30 days, |
| Distinct players who sent any event in the last 30 days |
| Up to 50 event names (crash reports excluded), highest count first |
| Up to 20 crash messages, highest count first |
| One row per day that had plays: finalised valid plays and distinct players |
| The same two numbers across the whole 30-day window |
| Players with valid plays on 2 or more different days in the window |
Days are UTC calendar days. date is the UTC day the session started, whatever the time zone you or your players are in, so a day boundary means the same thing in every row.
This endpoint returns aggregates only: counts per event name. For the individual events and their props, use the raw export below.
Raw event export
GET /v1/dev/games/:id/analytics/events returns the events themselves, so you can load them into your own warehouse, notebook or spreadsheet.
Auth: API key with the analytics scope (or a signed-in session), for a game you own · Rate limit: 120 requests / minute / account
Query | Type | Default | Notes |
|---|---|---|---|
| timestamp |
| Inclusive lower bound. Anything |
| timestamp | now | Exclusive upper bound |
| string | — | Only this event name. Same character rules as an event name; |
| string | — |
|
| integer | 500 | 1–1,000 |
The window may span at most 31 days (
range_too_large), andfrommust be beforeto(bad_range). Walk a longer period in month-sized chunks.Paging is keyset, ordered oldest first by
(createdAt, id), so there's no depth ceiling and no skipped or repeated row when many events share a millisecond. FollownextCursoruntil it'snull. Treat cursors as opaque; a corrupted one fails withbad_cursor.
curl -G https://api.coolgptgames.com/v1/dev/games/$GAME_ID/analytics/events \
-H "Authorization: Bearer $COOLGPT_API_KEY" \
--data-urlencode "from=2026-09-01T00:00:00Z" \
--data-urlencode "to=2026-09-08T00:00:00Z" \
--data-urlencode "name=level_complete" \
--data-urlencode "limit=500"{
"events": [
{
"id": "ev_…",
"name": "level_complete",
"props": { "level": 3, "timeMs": 42000, "stars": 2 },
"playerId": "9f2c41ab77e0c3d5a1b4e803",
"signedIn": true,
"sessionId": "ps_…",
"createdAt": "2026-09-01T10:14:02.481Z"
}
],
"nextCursor": "eyJ0IjoiMjAyNi0wOS0wMVQxMDoxNDowMi40ODEyMzBaIiwiaWQiOiJldl_igKYifQ",
"range": { "from": "2026-09-01T00:00:00.000Z", "to": "2026-09-08T00:00:00.000Z" }
}Field | Meaning |
|---|---|
| Exactly what your game sent, |
| A pseudonym, not an account id: stable for the same player in your game, different for the same person in someone else's, and not reversible to an account or a browser. |
| Whether that player was signed in |
| The play session the event belongs to — group by it to reconstruct a run |
| UTC, ISO 8601 |
| The window actually queried, after defaults are applied |
Paging a week of one event name:
let cursor = null;
const rows = [];
do {
const qs = new URLSearchParams({ from, to, name: "level_complete", limit: "1000" });
if (cursor) qs.set("cursor", cursor);
const res = await fetch(
`https://api.coolgptgames.com/v1/dev/games/${gameId}/analytics/events?${qs}`,
{ headers: { Authorization: `Bearer ${process.env.COOLGPT_API_KEY}` } },
);
const page = await res.json();
if (!res.ok) throw new Error(page.error.code);
rows.push(...page.events);
cursor = page.nextCursor;
} while (cursor);The dashboard's Analytics panel stays aggregate-only: it doesn't show props.
Built-in metrics
The platform records these for every game, whether or not you call trackEvent:
Metric | How it's counted |
|---|---|
Plays (daily) | Finalised valid play sessions, bucketed by the UTC day they started |
A session is valid when it ran for at least 30 seconds with at least 2 counted heartbeats and wasn't flagged. The platform sends heartbeats every 15 seconds while the game is open. Sessions can be flagged, for example when a single browser plays the same game more than 20 times in 24 hours, or when traffic comes from a data centre.
Only finished sessions count. Validity is decided when the session ends, and a session still in progress isn't in your numbers yet. If the end call never arrives — a closed laptop, a crash, a lost connection — a sweeper finalises the session for you: any session with no heartbeat for 5 minutes is ended at its last sign of life, and judged by the same rule, with play time measured to that last heartbeat rather than to now. So an abandoned tab can't accrue hours, and a genuine 45-second play still counts even though the browser never said goodbye. The sweep runs about once a minute, which is why a play can take a few minutes to appear.
Plays and players are counted per browser, so one person on two devices counts as two players.
The dashboard's Analytics panel always shows your plays and players chart, whether or not your game has ever called trackEvent — the event and crash lists are the parts that wait for data.
Crash reporting
Call reportError for errors that you catch or that reach a global handler:
try {
loadLevel(n);
} catch (e) {
GameSDK.reportError(e.message, { where: "loadLevel", level: n });
}
// Catch-all for uncaught errors
window.addEventListener("error", (ev) => {
GameSDK.reportError(ev.message, {
where: "window.onerror",
stack: String(ev.error && ev.error.stack || "").slice(0, 1500),
});
});How it works:
reportErrorsends an analytics event named$errorwithprops = { message, ...extra }.messageis converted to a string and cut to 500 characters. If you passnullorundefined, the message is"error".The top-level keys of
extraare copied into the same object. Themessageargument always wins: amessagekey inextrais ignored, so use another key name for extra text.Passing an
Errorobject asextraadds nothing, because itsmessageandstackaren't enumerable. Copy the fields you want into a plain object.All the analytics rules apply: a session is required, the whole event must stay under 4,000 characters and 50 keys, and the 120/min rate limit is shared with
trackEvent. Stacks get long, so cut them as in the example above. If the event is too large, the whole report is dropped.
Where it shows up. The Crashes list in the dashboard's Analytics panel groups reports by exact message text and shows the top 20 over the last 30 days. The API returns the same list in errors. extra fields are stored but not shown. Messages that contain changing values, such as IDs or coordinates, split into many separate rows. For clean grouping, keep message stable and put the variable details in extra.
Related
GameSDK live-ops reference: exact signatures and resolved shapes - Webhooks: server-side notifications for reviews, reports and moderation - API keys & scopes: the
configandanalyticsscopesErrors & limits: error envelope, rate limits