Scores, leaderboards and anti-cheat
- Written for
- + Written for
- Deprecated
- + Deprecated
- Applies to
- + Applies to
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 |
| The game's public leaderboard | Server-side checks on playtime, rate and plausibility |
Replay-verified (opt-in) |
| 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 endedsubmitScore 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:
The player is signed in. Guest scores are dropped. The site may show the guest a prompt to sign in.
submitScoreitself does nothing for guests.A play session exists. The site starts one when your game calls
GameSDK.start()orGameSDK.replay.ready(). If you call neither, there's no session and every score is dropped.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.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.
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 JavaScriptnumber, so a string such as"120"is dropped.You're under the rate limit. Each player can submit 30 scores per minute per game.
submitScoreandreplay.submitshare 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:
the site shows a "New personal best!" toast, and
the player earns a small XP bonus (see XP, achievements & social).
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})`;scoreis the player's best on the public leaderboard.rankis1 +the number of players with a strictly higher best, so tied players share a rank.Both values are
nullfor 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 |
|---|---|---|
| Every player with a score on your game | Works |
| 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.
rankcounts up from 1 in list order, so tied scores get consecutive ranks here. That differs fromgetMyScore, where ties share a rank.displayNameandavatarUrlcan benull. Fall back tohandle.In
"friends"scope,rankis 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 |
|
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 |
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 (
5rather 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 |
|
Advance the simulation in fixed steps, one recorded input per step | Variable |
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 |
Load all tuning values from your bundle or from the recorded inputs | Read remote config, |
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()andreplay.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:
loadDataresolvesnullafter about 8 seconds,getPlayerresolves 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. - Callreplay.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() openedIf 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:
Call
replay.reset()to clear the previous run's log.Record a run index as the first step.
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 itsinputandrng.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 identicalTo 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 ( | 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
Ranked matchmaking: head-to-head Elo, a different system from score leaderboards - Embedding & the score bridge
REST API overview: reading leaderboards (including weekly, monthly and verified) from your own server or site