Remote config, analytics & crash reporting

Embed
Preview of the card
<a href="https://coolgptgames.com/guides/remote-config-and-analytics"><img src="https://coolgptgames.com/guides/_api/public/orgs/062b11d9-2b86-4627-9e86-96816a67d7a1/spaces/680f0c4e-f8bc-4af0-983c-c373db913597/pages/b42940be-b42e-4fae-890c-fecf92caa6be/social-card.svg?v=3e7ae33eec8b" alt="Remote config, analytics &amp; crash reporting" width="600" height="315"></a>

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

GameSDK.getConfig()

Game dashboard → Live config, or PUT /v1/dev/games/:id/config

Analytics

GameSDK.trackEvent(name, props)

Game dashboard → Analytics, GET /v1/dev/games/:id/analytics for aggregates, or GET /v1/dev/games/:id/analytics/events for the raw events

Crash reporting

GameSDK.reportError(message, extra)

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 } }

PUT replaces 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_found to 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 config scope, 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 sent private, 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

  1. 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.

  2. 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.

  3. If the platform can't load the config (for example, a network error), your game receives {}. Nothing is cached, so the next getConfig() call tries again.

  4. If no answer arrives within 6 seconds, the promise resolves with {} and nothing is cached. The next getConfig() 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. Call start() 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, _, ., -, space, $, :

Reserved

$error, which counts as a crash report (use reportError instead)

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 props out if you have none.

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 429 and 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

totalEvents

All events in the last 30 days, $error included

uniquePlayers

Distinct players who sent any event in the last 30 days

events

Up to 50 event names (crash reports excluded), highest count first

errors

Up to 20 crash messages, highest count first

daily

One row per day that had plays: finalised valid plays and distinct players

totalPlays, totalPlayers

The same two numbers across the whole 30-day window

returningPlayers

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

from

timestamp

to minus 7 days

Inclusive lower bound. Anything new Date() accepts, e.g. 2026-09-01T00:00:00Z.

to

timestamp

now

Exclusive upper bound

name

string

—

Only this event name. Same character rules as an event name; $error gets you crash reports.

cursor

string

—

nextCursor from the previous page

limit

integer

500

1–1,000

  • The window may span at most 31 days (range_too_large), and from must be before to (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. Follow nextCursor until it's null. Treat cursors as opaque; a corrupted one fails with bad_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

props

Exactly what your game sent, {} if none

playerId

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. null if the platform has no subject for the event. Use it to count and to stitch a player's events together; it's the only player identifier the export gives you.

signedIn

Whether that player was signed in

sessionId

The play session the event belongs to — group by it to reconstruct a run

createdAt

UTC, ISO 8601

range

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:

  • reportError sends an analytics event named $error with props = { message, ...extra }.

  • message is converted to a string and cut to 500 characters. If you pass null or undefined, the message is "error".

  • The top-level keys of extra are copied into the same object. The message argument always wins: a message key in extra is ignored, so use another key name for extra text.

  • Passing an Error object as extra adds nothing, because its message and stack aren'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.


Updated