Scores, leaderboards and anti-cheat

Embed
Preview of the card
<a href="https://coolgptgames.com/guides/scores-leaderboards-and-anti-cheat"><img src="https://coolgptgames.com/guides/_api/public/orgs/062b11d9-2b86-4627-9e86-96816a67d7a1/spaces/680f0c4e-f8bc-4af0-983c-c373db913597/pages/31889670-9cca-4d3f-a467-5b0d007ca127/social-card.svg?v=3e7ae33eec8b" alt="Scores, leaderboards and anti-cheat" width="600" height="315"></a>

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, runId);  // number; higher is better. runId: see below
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().

The second argument is optional but worth passing: it's an idempotency key that stops the same run being recorded twice. See The nonce argument.

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.

The queue is worked by Cool GPT Games moderators, not by creators: you can't see or act on your game's shadowed scores. A score you submitted with replay.submit can clear itself, though — see When a replay un-shadows a score.

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: submitting exactly once

submitScore(score, nonce) takes an optional second argument, and it's a real idempotency key. Pass a value your game generates once per run, and the platform guarantees that run is recorded at most once, however many times the call is repeated.

const runId = "run-" + Date.now() + "-" + Math.random().toString(36).slice(2);
GameSDK.submitScore(finalScore, runId);
GameSDK.submitScore(finalScore, runId);   // harmless: the second one records nothing
  • The key is scoped to the play session, so it only has to be unique within one session. Any string up to 128 characters works. An empty string means "no key".

  • The first call with a given key does the work and its result is stored. Any repeat returns that stored result, marked duplicate: true, without submitting again.

  • A repeat that arrives while the first call is still running waits briefly for it. If the first is still going, the repeat comes back as { recorded: false, reason: "duplicate_in_flight", duplicate: true } — the first call's result is the one that counts.

  • A repeat is answered before the rate limiter, so retrying never turns into rate_limited.

Without a key, two calls record two submissions: the personal-best XP, the submission count and any total_score tournament all count the run twice. Pass one whenever your submission could be retried — on a flaky connection, after an unload, or from a "submit" button the player can tap twice.

submitScore returns nothing, so your game can't read duplicate itself. Read the outcome with getMyScore() if you need to show one.

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"
const best = await GameSDK.social.getLeaderboard("global", { verified: true });
// [{ 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".

Pass { verified: true } for the verified leaderboard: only scores the platform re-simulated and reproduced exactly (see Replay verification). It resolves [] for a game with no verified scores yet, so check for an empty result before drawing a "Verified" view.

Things to know:

  • It returns the top 50 entries, sorted by score. - Ranking is competition ranking. Tied players share a rank and the next rank skips: 1, 2, 2, 4. Within a tie, whoever reached the score first is listed first. getMyScore uses the same rule, so the two always agree — a player who sees "#2" in your HUD sees "#2" on the board.

  • Banned and deleted players never appear, in any scope, and they don't count toward anyone else's rank.

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

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

  • Weekly and monthly boards aren't available through the SDK. Use the leaderboard REST endpoint (period=weekly|monthly) for those. 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.

Two rows can share a rank, so don't use rank as a unique key or a list index.

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 submission must have been recorded (a score rejected outright as invalid or anomalous is never re-simulated — there's nothing to promote),

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

When a replay un-shadows a score

A score the plausibility model shadowed is still queued for re-simulation. It's recorded, just held off the public board, so there's something to promote.

If the re-simulation reproduces it, the replay wins: a faithful re-run of the recorded inputs from the server's own seed is direct evidence, while the plausibility model is only a heuristic. The pending review is resolved as approved, the score goes onto the public leaderboard exactly as a moderator approval would put it (becoming the player's best only if it beats their current one), and it's promoted to the verified board too.

A review a moderator has already decided is left alone. A human verdict stands, whichever way it went.

This is the one way a genuine outlier can clear itself without waiting for review, and it's a good reason to add replay verification to a game whose honest players routinely produce scores far above the pack.

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, the session has no seed, or the score wasn't recorded at all

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.

Where the verified board shows up

Once your game has at least one verified score, it's visible in three places:

  • Your game's page on the site gains a ✓ Verified tab next to All-time and This week. The tab only appears when there's something on it.

  • Inside your game, through GameSDK.social.getLeaderboard(scope, { verified: true }).

  • From your own server, through the leaderboard REST endpoint with verified=1. That response also carries hasVerified, which tells you whether the game has any verified scores at all — handy for deciding whether to offer a verified view. The in-game call returns the rows only, so check for an empty array instead.

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.

Math is not the same in every browser

Floating-point arithmetic — +, -, *, / — is specified exactly, so it gives the same answer everywhere as long as you perform the same operations in the same order. Most of the Math library is not. The player's run happens in whatever browser they use; the verifier runs headless Chromium. Where the two disagree, an honest replay fails verification.

We measured this across Chromium, Firefox and WebKit over 30,020 fixed inputs per function:

Function

Inputs that differ between engines

Use it in a replay?

Math.sqrt

0 — bit-identical in all three engines

Yes. Every engine uses the hardware's IEEE-754 square root, which is exactly rounded.

Math.pow

Matches except at extremes (for example Math.pow(0.1, 100) differs in WebKit)

Only for exact small integer exponents, and write x * x rather than Math.pow(x, 2).

Math.log

1.7% (WebKit vs. the other two)

No

Math.sin, Math.cos

~3% — including Chromium vs. Firefox, not just Safari

No

Math.exp

7.7% (WebKit vs. the other two)

No

Math.atan2

18.3% (WebKit vs. the other two)

No

Math.tan

38.3% (WebKit vs. the other two)

No

Two things to take from that table. The first is that this isn't "a Safari problem": sin and cos already disagree between Chromium and Firefox, so a Firefox player's honest run can fail against a Chromium verifier.

The second is that every difference we saw was 1 ulp — the smallest representable step, around 1 part in 10^16. That sounds harmless, and in a single frame it is. In a simulation it isn't: a 1 ulp difference in a velocity feeds the next position, which feeds the next collision test, and after a few hundred ticks one run has a ball bouncing off a wall the other run missed. There is no "small enough" difference in a deterministic simulation — either the two runs are identical, or they diverge.

What to do:

  • Use integers or fixed-point for anything that has to reproduce. Keep positions and velocities as integers in a small unit (sixteenths of a pixel, say) and do the arithmetic with +, -, *, |0 and Math.floor. This is the reliable fix and it sidesteps the whole question.

  • Math.sqrt is safe — use it freely, including for distances. - Keep sin, cos, tan, exp, log and atan2 out of your simulation. If you need them, either ship your own implementation (a small polynomial or a lookup table you generate at startup from integer arithmetic gives you the same answer everywhere), or precompute a table of values as integers and index into it.

  • Rendering can use whatever it likes. Determinism only applies to the state your score depends on. Camera shake, particles and easing curves are free to use Math.sin, as long as they never feed back into the simulation.

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

submitScore idempotency key (nonce)

Up to 128 characters, unique per play session

Leaderboard size returned

Top 50 (public and verified)

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.

Updated