How games run

Embed
Preview of the card
<a href="https://coolgptgames.com/guides/how-games-run"><img src="https://coolgptgames.com/guides/_api/public/orgs/062b11d9-2b86-4627-9e86-96816a67d7a1/spaces/680f0c4e-f8bc-4af0-983c-c373db913597/pages/99218fa8-b4a0-47af-b704-9ba8234e0139/social-card.svg?v=3e7ae33eec8b" alt="How games run" width="600" height="315"></a>

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

https://coolgptgames.com

Game pages, the player's account session, the SDK's message partner

The game CDN

https://cdn.coolgptgames.com

Your uploaded files, the SDK script (/sdk/game-sdk.js)

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.png points 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 form https://cdn.coolgptgames.com/sdk/game-sdk.js also 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.html can'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 a 403). 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 eval / new Function

✅

The upload scanner flags eval-style code for human review. It isn't blocked.

WebAssembly

✅

.wasm files are allowed in uploads, and so are pre-compressed .wasm.br / .wasm.gz files (served with the matching Content-Encoding).

ES modules (<script type="module">, import)

✅

Your files are served with Access-Control-Allow-Origin: *, so module imports from your own bundle work. Both .js and .mjs files are allowed and served as JavaScript.

Canvas, WebGL, Web Audio

✅

Loading your own files with fetch / XHR

✅

Use relative URLs.

Pointer lock

✅

allow-pointer-lock

Gamepads

✅

allow="gamepad"

Fullscreen requested by your game

✅

allow="fullscreen". The site also has its own Fullscreen button.

Screen orientation lock

✅

allow-orientation-lock. Browsers generally only honour orientation lock while in fullscreen.

Autoplaying audio

⚠️

allow="autoplay" is delegated, but browser autoplay rules still apply. Start audio after the player's first tap or key press to be safe.

What's blocked

Capability

Why

Use instead

localStorage, sessionStorage, IndexedDB, cookies

Opaque origin: accessing them throws a SecurityError

GameSDK.saveData / loadData (see Saves & cloud progress). Wrap any storage access in try/catch.

alert(), confirm(), prompt()

No allow-modals

Draw your own dialogs

Opening new windows / tabs (window.open, target="_blank" links)

No allow-popups

GameSDK.social.viewProfile(handle) opens profiles for you

Navigating the top page, or redirecting away

No allow-top-navigation

Nothing. Games stay on their page.

Submitting HTML forms

No allow-forms, and CSP form-action 'none'

Handle input in JavaScript

Nested iframes

CSP frame-src 'none'

Bundle everything into your page

Service workers and SharedWorker

Opaque origin. Service workers are also flagged by the upload scanner

Not supported

Workers loaded from a file in your bundle (new Worker("worker.js"), including { type: "module" })

Opaque origin: a worker can't be created from a null-origin document's URL

Build the worker from a blob: URL — see below

Scripts or stylesheets from other hosts (public JS CDNs, Google Fonts CSS)

CSP script-src / style-src allow only your own files and inline code

Copy libraries and fonts into your zip

Network requests to hosts you haven't declared

CSP connect-src (see below)

Declare the host in connectHosts, or bundle the data

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") and new Worker("worker.js", { type: "module" }) never load, whichever file you point them at. Chromium throws a SecurityError at the new Worker(...) call; Firefox and WebKit return a handle and then fire onerror.

  • SharedWorker and navigator.serviceWorker are unavailable for the same reason.

  • new Worker(blobUrl) works in all three engines. The game CSP carries worker-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, or postMessage the data in.

  • It can fetch your own files with relative URLs, and any host you declared in connectHosts.

  • window.GameSDK doesn'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

script-src

Your own files, inline scripts, eval

style-src

Your own files, inline styles

img-src, media-src

Your own files, data: and blob: URLs, plus your declared hosts

font-src

Your own files, data: URLs, plus your declared hosts

connect-src

Your own files, the Cool GPT Games API, the multiplayer relay (only if your game is multiplayer-enabled), plus your declared hosts

worker-src

Your own files and blob: URLs. In practice only blob: workers can load — see Web Workers

frame-src, object-src

Nothing

base-uri, form-action

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

https://api.example.com or a bare api.example.com (treated as HTTPS). http:// is refused.

Exact hosts

No wildcards (*.example.com), no IP addresses, no localhost, and no single-label or internal names (.local, .internal, .lan, .corp, …).

Origins, not URLs

Paths, queries and fragments are dropped. An explicit port is kept (https://api.example.com:8443).

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: *, and

  • not 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 https:// host per line, checked as you type.

CLI

"connectHosts": ["https://api.example.com"] in game.json next to your files. See CLI.

REST API / Publisher SDK

connectHosts on the upload request. See REST API and 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.com and whose data has a string type. 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

.zip with an index.html (plain JS, Phaser, Three.js, Godot, Unity and other web exports)

100 MB

None

PICO-8

.zip of PICO-8's HTML export (EXPORT game.html)

8 MB

The files must look like a PICO-8 web export

Twine

.zip of Twine's "Publish to File" HTML

10 MB

The files must look like a Twine story (Harlowe, SugarCube, …)

Bitsy

.zip of Bitsy's downloaded game.html

8 MB

The files must look like a Bitsy export

Rules shared by every runtime:

  • The entry point is the shallowest index.html (or index.htm) in the zip. A single wrapping folder is fine. Rename a tool's game.html to index.html before 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 stray README.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/runtimes reports the current value as maxUploadBytes.

  • 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, twine or bitsy build. 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, so GameSDK is 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"
end

Then 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.html where needed, so a Twine game.html or a PICO-8 game.html + game.js pair can be dropped as they come out of the tool. A .zip is 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 .txt game 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, twine or bitsy). 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 ends

Step

Your call

What the site does

Loading

none

Shows your loading screen image (or cover) with a spinner

First screen visible

GameSDK.ready()

Removes the loading overlay right away. Without it, the overlay goes 1.2 s after index.html finishes loading.

Gameplay begins

GameSDK.start()

Starts the play session on the first call. Later calls reuse it.

Run ends

GameSDK.gameOver(score)

Signals the end of a run. It does not save the score.

Record the score

GameSDK.submitScore(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

pause

The site's browser tab becomes hidden (tab switch, window minimised, phone locked)

resume

The tab becomes visible again

Those are the only triggers. In particular:

  • Ads don't send pause/resume. Pause on adStarted and resume on adComplete or adError. See Ads & rewards.

  • Losing focus while still visible (clicking outside the game, another window on top) sends nothing.

  • The Fullscreen button sends nothing. Handle resize instead.

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

  1. Start. On the first start() call (or replay.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

✅

✅

saveData / loadData / listSaves / deleteSave

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

getPlayer()

{ signedIn: false, level: 1, handle: null }

Level and handle

awardXp, unlockAchievement

Nothing recorded (granted: 0). The site nudges the player to sign in.

✅ Subject to caps

submitScore (leaderboards, tournaments)

Dropped. The site nudges the player to sign in.

✅

getMyScore()

{ score: null, rank: null }

Their best and rank

Read the leaderboard, remote config, store items, tournament list

✅

✅

social.addFriend, social.getFriends

status: "error" / []

✅

economy.purchase

{ error: "sign_in_required" }

✅

tournaments.join

null

✅

ranked.* (except the leaderboard)

{ error: "sign_in_required" }

✅

Ads (requestAd)

✅

✅

Analytics (trackEvent)

✅ (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.innerHeight and listen for resize. 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 / pointermove work for mouse and touch), and set touch-action: none on 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).


Updated