How games run
This page explains what happens between a player clicking your game and your code running: where your files are served from, the sandbox your game runs in, how the SDK talks to the page, what network access you have, how plays and play time are measured, and what guests get compared with signed-in players.
If you just want to ship something, start with Build and publish your first game and come back here when something behaves unexpectedly.
The big picture
┌──────────────────────── coolgptgames.com ────────────────────────┐
│ Game page (the site) │
│ • player account & sign-in • ads, toasts, loading screen │
│ • play-session metering • talks to the API │
│ │
│ ┌──────────── sandboxed iframe ─────────────┐ │
│ │ Your game, served from the game CDN │ │
│ │ https://cdn.coolgptgames.com/g/<game>/<version>/index.html │
│ │ │ │
│ │ GameSDK ── postMessage ──► the site │ │
│ │ ◄── postMessage ── │ │
│ └───────────────────────────────────────────┘ │
└───────────────────────────────────────────────────────────────────┘Your game never talks to player accounts directly. It asks the site through the SDK, and the site decides what is allowed, calls the Cool GPT Games API with the player's credentials, and sends the answer back. That's how saves, XP, scores and purchases work without your game ever handling a login token.
Two origins: the site and the game CDN
Origin | What lives there | |
|---|---|---|
The site |
| Game pages, the player's account session, the SDK's message partner |
The game CDN |
| Your uploaded files, the SDK script ( |
Your build is stored under a version-specific path, for example https://cdn.coolgptgames.com/g/<gameId>/<versionId>/index.html. That has some practical consequences:
Use relative paths for your own files (
sprites/hero.png,./levels.json). A root-relative path like/sprites/hero.pngpoints at the root of the CDN, not your build, and will 404.The one exception is the SDK, which really does live at the CDN root:
<script src="/sdk/game-sdk.js"></script>. The absolute formhttps://cdn.coolgptgames.com/sdk/game-sdk.jsalso works and passes the upload scanner, but the relative path is recommended. (In a PICO-8, Twine or Bitsy build the CDN adds this tag for you — see The SDK in cart runtimes.)Don't write absolute URLs to your own files. The version-specific folder isn't known until you upload, so an absolute URL can't point at the right build. Absolute URLs to hosts other than the platform's own (and any you declared) are treated as external network access by the upload scanner and get the build rejected.
Each uploaded version gets a new path, and your files are served with long-lived caching. You never need cache-busting query strings between versions.
Your
index.htmlcan't be opened by pasting its CDN URL into a browser. The site adds a short-lived access ticket (valid for 15 minutes) when it renders the player, and the CDN refuses to serve the entry page without one (you get a403). Always test through the game's page on the site.
Because the game is on a different origin from the site, your code can't read the site's cookies, storage or DOM, and the site can't read yours. The two sides communicate only through the SDK.
The sandbox
Your game runs in an <iframe> with these attributes:
<iframe sandbox="allow-scripts allow-pointer-lock allow-orientation-lock"
allow="autoplay; fullscreen; gamepad"
referrerpolicy="no-referrer">The sandbox deliberately leaves out allow-same-origin, so your game runs with an opaque origin (its origin is the string "null"). This is the most important fact about the environment.
What's allowed
Capability | Status | Notes |
|---|---|---|
JavaScript, including | ✅ | The upload scanner flags |
WebAssembly | ✅ |
|
ES modules ( | ✅ | Your files are served with |
Canvas, WebGL, Web Audio | ✅ | |
Loading your own files with | ✅ | Use relative URLs. |
Pointer lock | ✅ |
|
Gamepads | ✅ |
|
Fullscreen requested by your game | ✅ |
|
Screen orientation lock | ✅ |
|
Autoplaying audio | ⚠️ |
|
What's blocked
Capability | Why | Use instead |
|---|---|---|
| Opaque origin: accessing them throws a |
|
| No | Draw your own dialogs |
Opening new windows / tabs ( | No |
|
Navigating the top page, or redirecting away | No | Nothing. Games stay on their page. |
Submitting HTML forms | No | Handle input in JavaScript |
Nested iframes | CSP | Bundle everything into your page |
Service workers and | Opaque origin. Service workers are also flagged by the upload scanner | Not supported |
Workers loaded from a file in your bundle ( | Opaque origin: a worker can't be created from a | Build the worker from a |
Scripts or stylesheets from other hosts (public JS CDNs, Google Fonts CSS) | CSP | Copy libraries and fonts into your zip |
Network requests to hosts you haven't declared | CSP | Declare the host in |
Web Workers
Workers do work in games, but only from a blob: URL. This is measured behaviour in Chromium, Firefox and WebKit, and it follows from the opaque origin:
new Worker("worker.js")andnew Worker("worker.js", { type: "module" })never load, whichever file you point them at. Chromium throws aSecurityErrorat thenew Worker(...)call; Firefox and WebKit return a handle and then fireonerror.SharedWorkerandnavigator.serviceWorkerare unavailable for the same reason.new Worker(blobUrl)works in all three engines. The game CSP carriesworker-src 'self' blob:for exactly this.data:URLs are not allowed — use a blob.
Build the worker's source as a string in your bundle, wrap it in a Blob, and hand the object URL to new Worker:
// The worker's code, as a string. Keep it in its own file and inline it at
// build time if you'd rather not write JavaScript inside a template literal.
var workerSource = `
self.onmessage = function (e) {
var total = 0;
for (var i = 0; i < e.data.n; i++) total += Math.sqrt(i);
self.postMessage(total);
};
`;
var blob = new Blob([workerSource], { type: "text/javascript" });
var url = URL.createObjectURL(blob);
var worker = new Worker(url);
URL.revokeObjectURL(url); // the worker keeps running; the URL isn't needed again
worker.onmessage = function (e) { console.log("worker said", e.data); };
worker.postMessage({ n: 1e6 });Notes for this pattern:
The worker runs on the same opaque origin, so it can't
importScripts()a file from your bundle either. Put everything the worker needs in the source string, orpostMessagethe data in.It can
fetchyour own files with relative URLs, and any host you declared inconnectHosts.window.GameSDKdoesn't exist inside a worker. Post results back to the main thread and call the SDK from there.If your engine's export creates workers from files (some Emscripten and threading builds do), that part won't run. Check for a
blob:-worker option in the engine's export settings, or ship the single-threaded build.
Content Security Policy
Every file in your build is served with a Content Security Policy. In summary:
Directive | Allows |
|---|---|
| Your own files, inline scripts, |
| Your own files, inline styles |
| Your own files, |
| Your own files, |
| Your own files, the Cool GPT Games API, the multiplayer relay (only if your game is multiplayer-enabled), plus your declared hosts |
| Your own files and |
| Nothing |
| Nothing |
The same policy applies whichever runtime you uploaded with.
Network access and connectHosts
Games are sealed by default: apart from its own files, the Cool GPT Games API and (for multiplayer games) the multiplayer relay, a game can't reach the network. The browser enforces this, not your code.
If your game needs an external service (a public API, images from a known host), you can declare up to 10 hosts for a version. The declared hosts are added to that version's connect-src, img-src, media-src and font-src, so one declaration covers fetch/XHR, images, audio/video and web fonts. Declared hosts do not let you load scripts or stylesheets from that host.
Rules for declared hosts
Rule | Detail |
|---|---|
HTTPS only |
|
Exact hosts | No wildcards ( |
Origins, not URLs | Paths, queries and fragments are dropped. An explicit port is kept ( |
No first-party hosts | The site, API, CDN and multiplayer relay are already allowed and can't be declared. |
Maximum 10 | Duplicates are removed first. |
Per version | Declared again with every upload, and reviewed by moderation for each version. |
A declaration that breaks these rules fails the upload immediately with a bad_connect_hosts or too_many_connect_hosts error.
Moderation honours declarations too: a hard-coded fetch("https://api.example.com/…") or <img src="https://api.example.com/…"> to a declared host passes the code scan and the play-test. A request to a host you didn't declare is still an automatic rejection.
Requirements on the external service
Your game's origin is "null", so the service you call must:
respond with
Access-Control-Allow-Origin: *, andnot need cookies or other credentials.
Most public read-only APIs meet this. Anything that needs a secret API key doesn't belong in client-side game code anyway. Put it behind your own server, and declare that server's host.
How to declare hosts
Upload path | How |
|---|---|
Website upload form | An External connections field, on the upload form and in the manage page's new-version panel. One |
CLI |
|
REST API / Publisher SDK |
|
If the data never changes, it's simpler to ship it in your zip (for example data.json): no network, no CORS, nothing extra to review.
How the SDK talks to the page
The SDK uses window.postMessage between your iframe and the site:
Outgoing. Every SDK call becomes a message to the parent page, addressed only to
https://coolgptgames.com. If the parent is anything else (another site, or no parent at all), the browser silently discards the message.Incoming. The SDK only accepts messages whose origin is exactly
https://coolgptgames.comand whose data has a stringtype. Anything else is ignored. Other scripts can't impersonate the site to your game.On the site's side. The page only accepts messages from your game's own iframe window, checks each message's type and fields, and ignores unknown types. - Rate cap. The page accepts at most 30 messages per second from a game and silently drops the rest. - Loading order. The site attaches its message listener before your game starts loading, so calls you make as soon as your script runs (like
ready()) are never missed.
The site never trusts what a game claims about the player. It forwards requests to the API with the player's own credentials, and the API decides what actually happens (for example, how much of a requested XP grant lands). See GameSDK core reference for the per-method details and fallbacks.
Supported runtimes
When you upload, your build is tagged with a runtime:
Runtime | Upload | Max upload size | Extra check at upload |
|---|---|---|---|
HTML5 |
| 100 MB | None |
PICO-8 |
| 8 MB | The files must look like a PICO-8 web export |
Twine |
| 10 MB | The files must look like a Twine story (Harlowe, SugarCube, …) |
Bitsy |
| 8 MB | The files must look like a Bitsy export |
Rules shared by every runtime:
The entry point is the shallowest
index.html(orindex.htm) in the zip. A single wrapping folder is fine. Rename a tool'sgame.htmltoindex.htmlbefore zipping.Only web file types are allowed inside the zip:
.html .htm .js .mjs .css .json .map .txt .wasm .png .jpg .jpeg .gif .webp .svg .ico .mp3 .ogg .wav .woff .woff2 .ttf .glb .gltf .bin .pck .data .br .gz. Anything else, such as a strayREADME.md, fails the upload. OS clutter (__MACOSX/,.DS_Store,Thumbs.db,desktop.ini) is skipped silently.The max upload size in the table is enforced per runtime, and
GET /v1/runtimesreports the current value asmaxUploadBytes.Limits: at most 2,000 files, 300 MB uncompressed, and a compression ratio no higher than 100:1.
At runtime, every runtime is served the same way: your export is the game page. The platform doesn't wrap it in a player or bundle any engine — PICO-8, Twine and Bitsy exports already contain theirs. The single edit the CDN makes is adding the SDK script tag to a cart runtime's entry HTML (see below); HTML5 bundles are served byte for byte.
See Packaging & publishing for the full packaging rules and error codes.
The SDK in cart runtimes (PICO-8, Twine, Bitsy)
For these three runtimes the game CDN injects the SDK for you. A PICO-8, Twine or Bitsy export is a self-contained HTML page produced by a tool you can't easily edit, so the CDN adds <script src="/sdk/game-sdk.js"></script> to the entry document as it serves it. That's what makes window.GameSDK exist in a cart game.
The details:
It applies to the entry HTML document of a
pico8,twineorbitsybuild. HTML5 bundles are served byte for byte, untouched — as an HTML5 creator you add the script tag yourself.The tag goes in before
</head>, or failing that before the first<script>, then after<body>, then at the top of the document. Either way it runs before your game's own scripts, soGameSDKis there when they start.It's idempotent: a page that already loads
/sdk/game-sdk.js(relative or absolute) is left alone. Adding the tag yourself before zipping is still fine.The script is same-origin with your bundle, so every runtime's
script-src 'self'policy allows it.
Getting the SDK into the page is the easy half. Calling it is still up to you. Twine can run JavaScript directly; PICO-8 and Bitsy need a small bridge in the exported page. In all three cases the pattern is the same: your game signals what happened, and a few lines of JavaScript turn that into SDK calls.
Every example below assumes the SDK is present (it is — the CDN injected it), and that you guard for the case where it isn't, so the game still runs when you open the file locally:
var sdk = window.GameSDK || { ready(){}, start(){}, gameOver(){}, submitScore(){} };Twine. Put this in the story's JavaScript panel (Story Details → JavaScript in the Twine editor), which runs on every play:
var sdk = window.GameSDK || { ready(){}, start(){}, gameOver(){}, submitScore(){} };
sdk.ready();
sdk.start(); // the story beginning IS the run starting
// SugarCube: end the run when the player reaches a passage tagged "ending".
$(document).on(":passagedisplay", function () {
if (tags().includes("ending")) sdk.gameOver(State.variables.score || 0);
});In Harlowe, call the same functions from a (script:) macro inside the passage where the story ends.
PICO-8. A cart can't call JavaScript, but it can write to its GPIO pins, and the web export exposes those as pico8_gpio on the page. Use one pin as a command and a couple more as a value. In your Lua cart:
-- 0x5f80 is GPIO pin 0. Pin 0 = command, pins 1-2 = a 16-bit score.
function report_score(score)
poke(0x5f81, flr(score / 256)) -- high byte
poke(0x5f82, score % 256) -- low byte
poke(0x5f80, 1) -- 1 = "run over, here's the score"
endThen add this to the exported index.html, before </body>:
<script>
var sdk = window.GameSDK || { ready(){}, start(){}, gameOver(){}, submitScore(){} };
sdk.ready();
sdk.start();
setInterval(function () {
var gpio = window.pico8_gpio;
if (!gpio || gpio[0] !== 1) return;
gpio[0] = 0; // acknowledge, so it fires once
var score = (gpio[1] << 8) | gpio[2];
sdk.submitScore(score, "run-" + Date.now());
sdk.gameOver(score);
}, 250);
</script>Keep the poll slow (a few times a second is plenty) and always clear the command pin, or one game over becomes hundreds.
Bitsy. Bitsy has no scripting panel, so edit the exported HTML and add a script before </body>. Bitsy's dialog system is the natural trigger: give the ending dialog a distinctive line, and watch for it.
<script>
var sdk = window.GameSDK || { ready(){}, start(){}, gameOver(){}, submitScore(){} };
sdk.ready();
sdk.start();
// Bitsy renders dialog text into the page; watch for the ending line.
var done = false;
setInterval(function () {
if (done) return;
var text = document.body.innerText || "";
if (text.indexOf("THE END") !== -1) { done = true; sdk.gameOver(); }
}, 500);
</script>If you use the community "bitsy hacks" toolkit, calling the SDK from a custom dialog function is tidier than watching the page text.
Re-export from the tool and you'll lose these edits, so keep the snippet in a file next to your project and paste it back in, or add it with a one-line build step before you zip.
Choosing the runtime
The website upload form asks you. A dropdown lists every runtime currently accepting uploads, with that tool's export instructions underneath; the form pre-selects a guess from what you dropped, but never overrides your choice. Loose files are zipped in your browser, with the entry renamed to
index.htmlwhere needed, so a Twinegame.htmlor a PICO-8game.html+game.jspair can be dropped as they come out of the tool. A.zipis sent untouched.Raw carts and project files are refused before anything is uploaded (
.p8,.p8.png,.bitsy,.twee,.tws,.gb,.gbsproj, or a selection that's nothing but.txtgame data). They hold the game but not the engine, and the platform ships no engine of its own, so the form tells you which export to make instead.The REST API and Publisher SDK take the runtime explicitly (
html5,pico8,twineorbitsy). The CLI always uploads as HTML5.
Tagging a build with its real runtime is what gets you that engine's signature check at upload, its own size limit, and the SDK injection described above.
Game lifecycle
page opens ──► iframe loads index.html ──► ready() ──► start() ──► … play … ──► gameOver(score)
│ │ + submitScore(score)
│ └─ play session starts (heartbeat every 15 s)
└─ loading overlay removed
start() again for the next round (same session)
player leaves the page ──► play session endsStep | Your call | What the site does |
|---|---|---|
Loading | none | Shows your loading screen image (or cover) with a spinner |
First screen visible |
| Removes the loading overlay right away. Without it, the overlay goes 1.2 s after |
Gameplay begins |
| Starts the play session on the first call. Later calls reuse it. |
Run ends |
| Signals the end of a run. It does not save the score. |
Record the score |
| Sends the score to the leaderboard (see Scores, leaderboards & anti-cheat) |
Player leaves | none | Ends the play session |
If index.html hasn't finished loading about 15 seconds after the player opens the page (plus a short runtime-specific warm-up), the site shows "This game failed to load".
There's no "restart" or "quit" call. Show your own game-over screen and "Play again" button, and call start() again when the next round begins.
Pause and resume
The site sends two lifecycle events to your game, which you receive with GameSDK.on(...):
Event | Sent when |
|---|---|
| The site's browser tab becomes hidden (tab switch, window minimised, phone locked) |
| The tab becomes visible again |
Those are the only triggers. In particular:
Ads don't send
pause/resume. Pause onadStartedand resume onadCompleteoradError. See Ads & rewards.Losing focus while still visible (clicking outside the game, another window on top) sends nothing.
The Fullscreen button sends nothing. Handle
resizeinstead.
A robust game pauses on pause and adStarted, resumes on resume, adComplete and adError, and caps the frame delta so a long gap doesn't become one giant physics step. There's an example in GameSDK core reference.
Play sessions and play time
A play session is how the platform measures that someone actually played your game. Plays on your game page and dashboard, XP and leaderboard eligibility, mid-roll ads and analytics all hang off it.
Lifecycle of a session
Start. On the first
start()call (orreplay.ready()) of a page load, the site asks the API to start a session. This works for guests and signed-in players, but only for published games. 2. Heartbeats. While the game page stays open, the site sends a heartbeat every 15 seconds. A heartbeat only counts if at least 12 seconds of real time have passed since the last counted one, so play time can't be inflated by sending heartbeats faster. 3. End. When the player leaves the game page, including closing the tab, the site ends the session. The end call is sent in a way that survives the page unloading. This is where the session is judged: duration and heartbeats are frozen and the session is marked valid or not, once and only once, so a replayed end call can't earn a second helping of XP or quest credit. 4. Or: the sweeper ends it. If the end call never arrives — a browser crash, a closed laptop, a lost connection — a session with no heartbeat for 5 minutes is finalised for you. It's ended at its last sign of life, not at the moment it's noticed, and judged by exactly the same rule. So an abandoned tab can't accrue hours of play time, and a real 45-second session still counts even though nothing said goodbye. The sweep runs about once a minute.
There's one session per page load. "Play again" rounds share it.
Heartbeats run on a timer while the page is open. They don't stop when your game pauses. Browsers may slow timers in background tabs.
What counts
Threshold | Requirement |
|---|---|
Valid play (counted in your game's play total) | The session finished — by its end call or by the sweeper — having lasted at least 30 seconds with at least 2 counted heartbeats, and wasn't flagged |
A session is flagged (it doesn't count and earns nothing) when the same browser has already started 20 sessions of your game in the last 24 hours.
In practice:
A player who closes your game after 20 seconds generates a session but not a valid play.
In a quick game, the first round's score may arrive before the session has a minute of play. That score isn't recorded. Later rounds in the same visit are. Test with at least a minute of play.
XP, achievements and scores are decided while the session is open, against the counters so far. The end (or the sweep) only settles whether the session counts as a play.
A play can take a few minutes to show up, because a session that ends without its end call waits for the sweeper.
Starting sessions is limited to 60 per hour per IP address.
The play total on your game page is refreshed periodically, not in real time. Your dashboard's daily plays and players are bucketed by UTC day — see Remote config & analytics.
Guests vs. signed-in players
Anyone can play without an account. Signing in unlocks everything tied to a player's identity.
Feature | Guest | Signed in |
|---|---|---|
Play the game, play session metering | ✅ | ✅ |
| ✅ Saved in this browser only, on the site | ✅ Saved to their account (cross-device) |
Guest saves carried over on sign-in | n/a | ✅ Local saves are copied into the account at sign-in. Existing cloud slots are never overwritten. |
|
| Level and handle |
| Nothing recorded ( | ✅ Subject to caps |
| Dropped. The site nudges the player to sign in. | ✅ |
|
| Their best and rank |
Read the leaderboard, remote config, store items, tournament list | ✅ | ✅ |
|
| ✅ |
|
| ✅ |
|
| ✅ |
|
| ✅ |
Ads ( | ✅ | ✅ |
Analytics ( | ✅ (needs a session) | ✅ (needs a session) |
Guest saves live in the site's browser storage, so a guest who clears site data or switches browsers loses them. That's one reason to show "Sign in to save your progress" in your UI when getPlayer() says the player is a guest.
Embedded games (played through the Cool GPT Games embed player on another website) always run as guests. See Embedding & the score bridge.
Screen size, mobile and fullscreen
Desktop. The game area is a 16:9 box as wide as the page's main column. - Phones and small screens (narrower than about 720 px). The game area is full width and uses most of the screen height (up to 680 px), so portrait games get real vertical room. The exact aspect ratio depends on the device. - Fullscreen button. The bar under the game has a Fullscreen button that makes the whole player (your game plus the site's in-game notifications) fill the screen. Your game can also request fullscreen itself. - Embed player. The game fills the embedding iframe.
To handle all of these:
Size your canvas from
window.innerWidth/window.innerHeightand listen forresize. Don't assume a fixed resolution or aspect ratio.Add
<meta name="viewport" content="width=device-width, initial-scale=1">so mobile browsers don't render your page zoomed out.Support touch (
pointerdown/pointermovework for mouse and touch), and settouch-action: noneon your canvas so swipes don't scroll the page.Keep important UI away from the top-right corner. The site shows short notifications there (XP, achievements, level-ups, sign-in nudges).
Related
Build and publish your first game: a complete walkthrough. - GameSDK core reference:
ready,start,gameOver,getPlayer, events, fallbacks.Packaging & publishing: zip rules, moderation, versions. - Errors & limits: every limit in one place.