Guides

Scores, leaderboards and anti-cheat

AdminUpdated Sep 22, 2026

Scores, leaderboards and anti-cheat

Every game on Cool GPT Games gets a per-game high-score leaderboard. You can report scores in two ways:

Path

Call

Where the score goes

Cheat resistance

Standard

GameSDK.submitScore(score)

The game's public leaderboard

Server-side checks on playtime, rate and plausibility

Replay-verified (opt-in)

GameSDK.replay.submit(score)

The public leaderboard and, if the server's re-simulation reproduces the exact score, the verified leaderboard

The same checks, plus the server re-runs your game with the player's recorded inputs

Start with submitScore. Add replay verification when your leaderboard matters enough that people will try to cheat it and your game can be made deterministic.

For exact signatures and return shapes, see GameSDK saves & scores reference.

Submitting a score

GameSDK.start();                 // once, when real play begins: starts the play session

// ...at the end of a run
GameSDK.submitScore(finalScore); // number; higher is better
GameSDK.gameOver(finalScore);    // tells the site (and any embedding page) the run ended

submitScore returns nothing. It posts a message and forgets about it. If you want to show a result, read it afterwards with getMyScore().

What gameOver does and doesn't do

GameSDK.gameOver(score) is a lifecycle signal. It tells an embedding page that the run finished (see Embedding & the score bridge). It does not record a leaderboard score. To record one, call submitScore or replay.submit.

Requirements for a score to be recorded

A score only reaches the leaderboard when all of the following are true:

  1. The player is signed in. Guest scores are dropped. The site may show the guest a prompt to sign in. submitScore itself does nothing for guests.

  2. A play session exists. The site starts one when your game calls GameSDK.start() or GameSDK.replay.ready(). If you call neither, there's no session and every score is dropped.

  3. The session has enough real playtime. While the game is open, the site sends a heartbeat about every 15 seconds. The server only counts one if at least 12 seconds of real time have passed since the previous one. A score needs 4 counted heartbeats, which is about one minute of real time since the session started. Menus count too. The clock runs from start(), not from the start of each run.

  4. The session is still valid. It hasn't ended (the player hasn't left the page), and it wasn't flagged when it started. A browser that has started more than 20 sessions of your game in the past 24 hours gets flagged sessions, and those don't record scores.

  5. The score is a number from 0 to 10¹². Decimals are rounded down with Math.floor. Negative scores are rejected. Scores above 1,000,000,000,000 are rejected as anomalous. The site ignores any value that isn't a JavaScript number, so a string such as "120" is dropped.

  6. You're under the rate limit. Each player can submit 30 scores per minute per game. submitScore and replay.submit share the same limit.

The site doesn't tell your game when a score is dropped. If your first run is usually shorter than a minute, submit at the end of every run anyway. A later run in the same session will qualify.

What gets stored

The leaderboard keeps one entry per player per game: their best score. A submission replaces it only if it's higher. When a player sets a new personal best:

Scores also count automatically toward any live tournament on your game (see Tournaments).

Leaderboards rank higher scores first, and there's no lower-is-better mode. For a time-based game, turn the time into a score where higher is better. For example, Math.max(0, 600000 - elapsedMs), or points awarded for finishing early.

Plausibility checks (shadowed scores)

Once at least 10 players have a score on your game, the server builds a plausibility model from your leaderboard. A new score is shadowed if:

  • it's more than 5× the 95th-percentile best score on your game, or - its points per second of verified session playtime are more than 5× the fastest rate anyone has recorded on your game.

A shadowed score doesn't appear on the public leaderboard and doesn't replace the player's best. It goes into a review queue. The player isn't told, which makes the check harder for cheaters to probe. A genuine outlier can be approved from review.

For scores that grow over a long session, playtime is measured from the whole session. So a big score after a long session is not penalised.

The nonce argument

submitScore(score, nonce) accepts an optional second argument. At the moment the site ignores it. It isn't checked, stored or used for replay protection. You can leave it out. Passing one does no harm.

Reading scores

Your own best and rank

const { score, rank } = await GameSDK.getMyScore();
if (score !== null) hud.text = `Your best: ${score.toLocaleString()} (#${rank})`;
  • score is the player's best on the public leaderboard.

  • rank is 1 + the number of players with a strictly higher best, so tied players share a rank.

  • Both values are null for guests, for players with no score yet, on error, or after a 6-second timeout.

The leaderboard

const rows = await GameSDK.social.getLeaderboard("global");   // or "friends"
// [{ rank, handle, displayName, avatarUrl, level, score }, ...]  (top 50)

Scope

Who is included

Guest behaviour

"global" (default)

Every player with a score on your game

Works

"friends"

The signed-in player and their friends

Resolves []

Any other value for scope is treated as "global".

Things to know:

  • It returns the top 50 entries, sorted by score. rank counts up from 1 in list order, so tied scores get consecutive ranks here. That differs from getMyScore, where ties share a rank.

  • displayName and avatarUrl can be null. Fall back to handle.

  • In "friends" scope, rank is the position within that friends list.

  • The SDK always returns the all-time public leaderboard. Weekly and monthly leaderboards and the verified leaderboard are available through the leaderboard REST endpoint (period=weekly|monthly, verified=1), not through the SDK. See REST API overview.

  • On any error or timeout (6 seconds) it resolves [] and never rejects. Show an empty state for [] rather than an error.

Use the handles from the leaderboard with GameSDK.social.viewProfile(handle) and GameSDK.social.addFriend(handle). See XP, achievements & social.

Replay verification (verified leaderboards)

Standard scores are what the client reports. The server checks that they're plausible, but a modified client can still claim any score within those limits. Replay verification closes that gap. The server doesn't trust the score. It runs your game again from the player's inputs and checks that it gets the same score.

How it works

 LIVE PLAY (player's browser)                        SERVER
 ────────────────────────────                        ──────
 replay.ready()  ────── asks for seed ─────────────► session start issues a random seed
                 ◄───── server-issued seed ────────  (stored with the session)
 seed RNG from it
 each step: input = read(); replay.record(input); simulate(input)
 game over: replay.submit(score) ── score + input log ─►  1. record on the public board
                                                          2. if it could reach the verified
                                                             top 50, queue a re-simulation

 VERIFICATION (headless browser, later)
 ──────────────────────────────────────
 loads the SAME game version, injects { seed, inputs } before any script runs
 replay.isVerifying() === true
 replay.ready() → the stored seed; replay.inputs() → the recorded log
 simulate every input (no live input, no clock) → replay.submit(score)
                                                    ──►  re-simulated score == claimed score?
                                                          yes → promoted to verified board
                                                          no  → rejected (stays unverified)

The key points:

  • The seed comes from the server. It's a random value created when the play session starts. The client can't choose it, so a cheater can't try seeds until they find a lucky one. Verification always uses the seed the server issued for that session. It never uses a seed supplied by the client.

  • The score comes from the re-simulation. The claimed score is only compared against the re-simulated one. The two must match exactly after Math.floor.

  • The verifier runs the exact version the player played. It isn't affected by later uploads.

  • Verification happens in the background. The score appears on the public leaderboard straight away. It's promoted to the verified leaderboard once verification succeeds.

When a replay is verified

The re-simulation runs a real headless browser, so it's costly. The server only queues one when the claimed score could change the verified leaderboard:

  • the play session must have a server-issued seed,

  • the score must beat the player's own current verified best, and - fewer than 50 other players have a verified best that is strictly higher.

Otherwise the score is still recorded on the public leaderboard, but no re-simulation runs. Replay submissions must also pass everything in Requirements for a score to be recorded: sign-in, session, about a minute of play, and the rate limit.

What happens to unverified scores

Nothing bad. They stay on the public leaderboard exactly like a submitScore score. A replay can end up unverified because:

Outcome

Cause

Effect

Not queued

It wouldn't reach the verified top 50, it doesn't beat the player's verified best, or the session has no seed

Public leaderboard only

Rejected

The re-simulated score differs from the claimed score (cheating, or your game isn't deterministic)

Public leaderboard only. The player isn't penalised or notified.

Error

The run produced no score in time, the input log couldn't be read, or the verifier failed

Public leaderboard only

A score is never verified by mistake. Any failure leaves it unverified.

The headless replay verifier is enabled in production, so replays that qualify are re-simulated and verified scores reach the verified leaderboard.

Limits

Limit

Value

Notes

Recorded steps per run

200,000

replay.record() quietly stops recording after this. The verifier then replays a shorter log and the score won't match.

Input log size

512 KB (the JSON-serialized log)

Larger submissions are rejected in full and lost silently. They don't even reach the public leaderboard.

Verification time budget

About 60 seconds for the re-simulation, after the page has loaded

Your verify-mode run must call replay.submit() within this time.

Verifier viewport

640 × 480

A headless browser. Don't assume a GPU, audio or user gestures.

Seed

One per play session

"Play again" in the same session reuses it. Derive a seed for each run (see below).

The 512 KB limit usually bites first. At 60 steps per second with about 5 bytes per step (0.12,), you reach 512 KB after roughly 100,000 steps, about 28 minutes. To record longer runs:

  • record one step per simulation tick, and don't record steps for rendering,

  • keep each step small, such as a number, a short string or a bit mask (5 rather than {"left":true,"fire":true}),

  • for input that rarely changes, record [tick, input] only when the input changes, and fill in the gaps during verification.

What makes a game deterministic

The verifier must reach exactly the same score from the same seed and inputs. Your simulation must depend only on the seed and the recorded inputs.

Do

Don't

Seed a PRNG (for example mulberry32 or sfc32) from await replay.ready()

Math.random(), crypto.getRandomValues()

Advance the simulation in fixed steps, one recorded input per step

Variable dt from requestAnimationFrame or performance.now() in game logic

Record the exact value the step used, and simulate with that value

Record a rounded value but simulate with the unrounded one (they drift apart)

Keep the simulation separate from rendering, audio and particles

Let visual effects, camera shake or audio timing change game state

Keep all game state in plain JS that you reset for each run

Read screen size, device pixel ratio, fonts or text measurement inside game logic

Use integer or fixed-point math, or the same float operations in the same order

Depend on object key order from data you don't control, on Date, or on time zones

Load all tuning values from your bundle or from the recorded inputs

Read remote config, loadData or network data inside the simulation

If gameplay depends on remote config such as a difficulty value, the verifier won't have it. Record the value as one of the first input steps and read it back during verification.

Floating-point math is deterministic in the same browser engine when you perform the same operations in the same order. The verifier runs headless Chromium. Stick to plain arithmetic in game logic. Transcendental functions (Math.sin, Math.exp, ...) can give slightly different results on different engines.

Verify mode: what's different

When replay.isVerifying() is true, your bundle is running headless with no Cool GPT Games page around it:

  • Don't read live input, the clock or randomness. Take everything from replay.ready() and replay.inputs().

  • Run the simulation as fast as possible, in a plain loop rather than one step per animation frame, so you finish inside the time budget. You don't need to render.

  • Don't wait for other SDK calls. No page answers them, so each one only resolves its timeout fallback: loadData resolves null after about 8 seconds, getPlayer resolves guest defaults after about 5 seconds, and most other calls resolve empty after about 6 seconds. Those seconds come out of the verifier's time budget, so branch to the verify path before any of these. - Call replay.submit(score) exactly once, with the score your simulation produced. In verify mode that publishes the result for the verifier. Nothing is sent anywhere else.

  • Other SDK calls (submitScore, awardXp, saveData, ...) have no effect here, but avoid them anyway.

Getting the seed

replay.ready() starts the play session if needed and resolves with that session's seed. start() and replay.ready() always share one play session, so you can call them in either order, or back to back:

GameSDK.start();
const seed = await GameSDK.replay.ready();   // the seed of the session start() opened

If the site never supplies a seed (for example, the session couldn't start), replay.ready() resolves after about 6 seconds with a local fallback seed that starts with local.. The game is still playable, but that run can't be verified.

Several runs in one session

The seed stays the same for the whole play session. For "play again", give every run its own seed and make it rebuildable:

  1. Call replay.reset() to clear the previous run's log.

  2. Record a run index as the first step.

  3. Seed your PRNG from hash(sessionSeed + ":" + runIndex).

During verification, read inputs()[0] to get the run index and rebuild the same seed.

replay.submit vs submitScore

replay.submit(score) puts the score on the public leaderboard itself, so you don't need submitScore as well. Call one or the other for each run, not both. Calling both submits the score twice. That uses up the rate limit twice as fast and, in a tournament that adds up scores (total_score), counts the run twice.

If your game can be embedded, replay.submit sends the same score bridge event to the embedding page as submitScore. Call GameSDK.gameOver(score) as well if the embedding page listens for gameover. See Embedding & the score bridge.

Worked example: a deterministic game loop

This is a complete, dodge-the-blocks game in one HTML file. The same simulation code runs live and in verify mode.

<!doctype html>
<html>
<head><meta charset="utf-8"><title>Block Dodger</title>
<style>html,body{margin:0;background:#111;color:#eee;font:16px system-ui}canvas{display:block;margin:auto}</style>
</head>
<body>
<canvas id="c" width="320" height="480"></canvas>
<script src="/sdk/game-sdk.js"></script>
<script>
(function () {
  "use strict";
  var SDK = window.GameSDK;
  var R = SDK.replay;

  // ---- 1. Deterministic PRNG + seed hashing (pure functions) ----
  function mulberry32(a) {
    return function () {
      a |= 0; a = (a + 0x6D2B79F5) | 0;
      var t = Math.imul(a ^ (a >>> 15), 1 | a);
      t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
      return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
    };
  }
  function hashSeed(s) {
    var h = 2166136261;
    for (var i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); }
    return h >>> 0;
  }

  // ---- 2. The simulation: state + step(input). No DOM, no clock, no Math.random ----
  var W = 320, H = 480, LANES = 5, LANE_W = W / LANES;
  var sim, rng;

  function newRun(seed) {
    rng = mulberry32(hashSeed(seed));
    sim = { tick: 0, lane: 2, blocks: [], score: 0, over: false };
  }

  // input: -1 (left), 0 (stay), 1 (right). Integers only → stays exact in JSON.
  function step(input) {
    if (sim.over) return;
    sim.lane = Math.max(0, Math.min(LANES - 1, sim.lane + input));
    var spawnEvery = Math.max(12, 40 - Math.floor(sim.tick / 300));
    if (sim.tick % spawnEvery === 0) {
      sim.blocks.push({ lane: Math.floor(rng() * LANES), y: -20 });
    }
    var speed = 4 + Math.floor(sim.tick / 600);          // integer speed → exact
    for (var i = sim.blocks.length - 1; i >= 0; i--) {
      var b = sim.blocks[i];
      b.y += speed;
      if (b.y >= H - 60 && b.y <= H - 20 && b.lane === sim.lane) sim.over = true;
      if (b.y > H) { sim.blocks.splice(i, 1); sim.score += 1; }
    }
    sim.tick++;
  }

  // ---- 3. Rendering: reads sim, never changes it ----
  var ctx = document.getElementById("c").getContext("2d");
  function draw() {
    ctx.fillStyle = "#111"; ctx.fillRect(0, 0, W, H);
    ctx.fillStyle = "#e44";
    sim.blocks.forEach(function (b) { ctx.fillRect(b.lane * LANE_W + 6, b.y, LANE_W - 12, 20); });
    ctx.fillStyle = "#4cf";
    ctx.fillRect(sim.lane * LANE_W + 10, H - 50, LANE_W - 20, 20);
    ctx.fillStyle = "#eee"; ctx.fillText("Score " + sim.score, 8, 20);
    if (sim.over) ctx.fillText("Game over — press Space", 90, H / 2);
  }

  // ---- 4. Live input: sampled once per tick, then recorded ----
  var held = { left: false, right: false }, tapped = 0;
  addEventListener("keydown", function (e) {
    if (e.key === "ArrowLeft") { held.left = true; tapped = -1; }
    if (e.key === "ArrowRight") { held.right = true; tapped = 1; }
    if (e.key === " " && sim.over) startRun();
  });
  addEventListener("keyup", function (e) {
    if (e.key === "ArrowLeft") held.left = false;
    if (e.key === "ArrowRight") held.right = false;
  });
  function readInput() {
    // One lane move per 6 ticks while a key is held, plus the initial tap.
    var v = tapped || ((sim.tick % 6 === 0) ? (held.right ? 1 : 0) - (held.left ? 1 : 0) : 0);
    tapped = 0;
    return v;
  }

  // ---- 5. Live loop: fixed 60 Hz steps, decoupled from frame rate ----
  var sessionSeed, runIndex = 0, acc = 0, last = 0, STEP_MS = 1000 / 60;

  function startRun() {
    runIndex++;
    R.reset();                         // fresh input log for this run
    R.record(runIndex);                // step 0 = run index (verify mode reads it back)
    newRun(sessionSeed + ":" + runIndex);
  }

  function frame(now) {
    acc += Math.min(250, now - (last || now)); last = now;
    while (acc >= STEP_MS && !sim.over) {
      var input = readInput();
      R.record(input);                 // record EXACTLY what step() receives
      step(input);
      acc -= STEP_MS;
      if (sim.over) {
        R.submit(sim.score);           // public board now; verified board if it re-simulates
        SDK.gameOver(sim.score);       // lifecycle + embed score bridge
      }
    }
    if (sim.over) acc = 0;
    draw();
    requestAnimationFrame(frame);
  }

  // ---- 6. Boot: branch into verify mode FIRST ----
  async function boot() {
    sessionSeed = await R.ready();     // server seed (live) or recorded seed (verify)

    if (R.isVerifying()) {
      var inputs = R.inputs();
      var idx = inputs.length ? inputs[0] : 1;
      newRun(sessionSeed + ":" + idx);
      for (var i = 1; i < inputs.length && !sim.over; i++) step(inputs[i]);  // tight loop, no rAF
      R.submit(sim.score);             // publishes the re-simulated score to the verifier
      return;
    }

    SDK.ready();
    SDK.start();                       // shares the session ready() opened
    startRun();
    requestAnimationFrame(frame);
  }
  boot();
})();
</script>
</body>
</html>

Why this is deterministic:

  • All randomness comes from mulberry32, seeded from the session seed and run index.

  • step() is the only function that changes game state. It reads nothing except its input and rng.

  • Every recorded input is a small integer, and step() receives exactly that integer both live and in verify mode.

  • Speeds and positions are integers, so there's no floating-point drift.

  • The live loop runs fixed 60 Hz steps no matter how fast frames are drawn. The verify loop runs the same steps as fast as possible.

  • Rendering (draw) only reads state.

Size check: one step is 1–2 characters plus a comma, so a 10-minute run (36,000 steps) is about 100 KB, well under the 512 KB limit.

Testing replays locally

You can check determinism without the site. Capture the inputs of a live run, then run the simulation again from them:

// In your dev build, keep your own copy of the inputs:
var devLog = [];
function recordBoth(v) { devLog.push(v); R.record(v); }

// After game over, in the console:
newRun(sessionSeed + ":" + devLog[0]);
for (var i = 1; i < devLog.length && !sim.over; i++) step(devLog[i]);
console.log("live", liveScore, "replayed", sim.score);  // must be identical

To exercise the real verify path, set the injected replay object before the SDK script loads:

<script>
  window.__ARCADEY_REPLAY = { seed: "test-seed", inputs: [1, 0, 0, 1, -1 /* ... */] };
</script>
<script src="/sdk/game-sdk.js"></script>

With that in place, replay.isVerifying() returns true, and replay.submit(score) stores the result in window.__ARCADEY_REPLAY_RESULT, which you can inspect in the console. Run the same inputs with the same seed several times, and on another browser if you can. The result must never change.

Rate limits and limits summary

Limit

Value

Score submissions (submitScore + replay.submit combined)

30 per minute per player per game

Playtime before a score counts

4 counted heartbeats, about 60 seconds since the session started

Score range

0 – 1,000,000,000,000, rounded down to an integer

Leaderboard size returned

Top 50

Plausibility checks

Start once your game has 10+ players on the leaderboard. Flag scores above 5× the p95, or 5× the fastest points-per-second rate.

Replay input log

512 KB serialized, 200,000 steps

Replay verification budget

About 60 seconds

SDK messages to the site

About 30 per second

Sessions per browser per game

Over 20 in 24 hours → later sessions are flagged and record no scores

See Errors & limits for platform-wide limits.

Related

Was this page helpful?