Packaging & publishing your game
This guide covers everything between "my game runs locally" and "players can find it": how to package a build, how to create a game listing, the three ways to upload (website, CLI and REST), what moderation checks, what every status means, and how to ship updates, preview builds, roll back and take a game down.
For exact request and response shapes, see Games & uploads API reference. For a five-minute first publish, start with Quickstart.
The lifecycle at a glance
create game (draft)
│
├─ add a cover image ← required before you can submit a build
│
├─ upload init → PUT the .zip → upload complete
│ │
│ bundle checks (instant, synchronous)
│ ├─ fail → version "failed", game stays as it was
│ └─ pass → game "scanning"
│ │
│ automated moderation (typically a minute or two)
│ ├─ auto-approve → "published"
│ ├─ auto-reject → "rejected"
│ └─ needs review → "pending_review" → a human decides
│
└─ later: new version → same checks again (the live version keeps serving)
· rollback · appeal a rejection · unpublish (restorable for 30 days)Each upload creates a new version of the game (v1, v2, v3…). The game has one current version, which is the one players get. Moderation looks at each version separately.
1. Package your build
Supported runtimes
Ask the API which runtimes are accepting uploads right now:
curl https://api.coolgptgames.com/v1/runtimesCurrently available:
| What to upload | Published upload limit |
|---|---|---|
| A | 100 MB |
| A | 8 MB |
| A | 10 MB |
| A | 8 MB |
The maxUploadBytes value returned by GET /v1/runtimes is the limit, and upload complete enforces it for each runtime: a bigger zip fails with upload_too_large.
Everything is uploaded as a .zip. PICO-8, Twine and Bitsy builds are also checked for an engine signature (for example, a Twine upload must actually contain Twine story data). If the check fails you get invalid_cart_format with a hint for re-exporting from that tool.
Zip layout and the entry file
Your zip must contain an
index.html(orindex.htm). The file name is case-insensitive.If there is more than one, the one closest to the top of the archive is used. A single top-level folder is fine:
my-game.zip
├── index.html ← entry (preferred: at the root)
├── game.js
├── style.css
└── assets/
├── sprites.png
└── music.oggmy-game.zip
└── dist/
├── index.html ← also fine: the shallowest index.html wins
└── assets/…Use relative paths for everything your game loads (
assets/sprites.pngor./assets/sprites.png). Your game is served from its own folder on our game CDN, so a root-absolute path such as/assets/sprites.pngpoints at the CDN root rather than your build, and it will 404.Your game can't load scripts, images, fonts or media from third-party hosts. Put every asset inside the zip (see Calling external hosts for the one exception).
Allowed file types
Every file in the archive must have one of these extensions (case-insensitive):
Kind | Extensions |
|---|---|
Markup, code & data |
|
Images |
|
Audio |
|
Fonts |
|
3D |
|
Engine data |
|
Pre-compressed files |
|
OS clutter is skipped silently, so you don't have to strip it: __MACOSX/ folders, .DS_Store, Thumbs.db and desktop.ini are simply left out of the stored build.
Any other extension rejects the whole upload with disallowed_extension, and the error names the file. Common culprits:
File | Why it's there | Fix |
|---|---|---|
| Project files | Leave them out of the build folder |
| Video, AAC audio | Remove, or convert (for example to |
Engine exports (Godot, Unity, Emscripten): web exports work as-is, including compressed builds. A file named
x.<type>.brorx.<type>.gzis served as the inner type (soBuild.wasm.bris served as WebAssembly) withContent-Encoding: brorgzip, which is what Unity's loader andWebAssembly.instantiateStreamingexpect. Compressed code is decompressed and scanned by moderation like any other code. A barearchive.gzwith no inner extension is served as an opaque gzip file.
Structural limits
Limit | Value | Error code |
|---|---|---|
Zip size | See the runtime table above |
|
Total size after unzipping | 300 MB |
|
Number of files | 2,000 |
|
Compression ratio of any single file | 100 : 1 |
|
Time to read the archive | 20 seconds |
|
Empty archive | Not allowed |
|
Folders don't count toward the file limit. The compression-ratio limit protects against zip bombs. A normal asset never gets close to it, but a large file of zeros or a huge repetitive text file might.
Path rules
These are rejected for safety:
Problem | Example | Error code |
|---|---|---|
Parent-directory segments |
|
|
Backslashes in names |
|
|
Absolute paths / drive letters |
|
|
Symbolic links | a symlinked |
|
Not a zip at all | a renamed |
|
Archives made on Windows with some older tools store backslashes. Re-zip with a modern tool or from the command line.
Making a clean zip
# macOS / Linux: zip the CONTENTS of your build folder
cd dist
zip -r ../my-game.zip .OS clutter is skipped for you, so no exclusions are needed. Leave out source maps (-x "*.map") if you'd rather not publish your original source.
# Windows: use PowerShell 7+ (older Windows PowerShell 5.1 can write backslashes into zip paths)
Compress-Archive -Path dist\* -DestinationPath my-game.zipBefore you upload, list the archive and check it against the tables above:
unzip -l my-game.zipDesign for the automated play-test
Moderation loads your game in a headless browser, sends some basic input (arrow keys, Space, W/A/S/D and a click near the middle of the screen) and takes screenshots over the first 30 seconds. To pass cleanly:
Draw something immediately. A blank screen after warm-up is an automatic rejection (
blank_screen).A title screen is fine. Make sure a click or key press starts the game, and that something visible is on screen while it loads.
Don't require sign-in, an external account or a network connection before anything is drawn.
2. Create the game listing
A game starts as a draft. Drafts are visible only to you.
Fields
Field | Required | Limit | Notes |
|---|---|---|---|
| Yes | 1 – 120 chars | Also generates the URL slug when the game is created (e.g. |
| No | 4,000 chars | Short description shown on the game page. |
| No | 4,000 chars | "How to play". |
| No | — | One of: |
| No | 160 chars | One-line pitch. |
| No | 8,000 chars | A longer store-page write-up. Helps search ranking. |
| No | Up to 20 entries | Each entry is |
| No | Up to 5 URLs | Your own sites (store page, socials, homepage). Each must start with |
| No | boolean | Shows an "AI-made" label on the game page. Please be honest here. |
| No | 2,000 chars | Optional note on which tools or models you used. |
| No | Up to 5 | Style, mode and theme labels from a curated list. See Tags below. |
| No (edit only) | boolean | Default |
websiteUrl(a single URL) is still accepted for older clients and is treated as a one-itemwebsiteslist. Preferwebsites.
Tags
Tags sit alongside your category: the category is the genre, tags describe style, mode and theme (pixel-art, co-op, roguelike, mobile-friendly…). They power the tag filter in the catalog and the "more like this" row on your game page.
You can set up to 5 tags, on create (
POST /v1/games) or any later edit (POST /v1/games/{id}), and in both the website's upload form and your game's manage page.Tags come from a curated list, not free text. Fetch it with
GET /v1/tags, which returns{ tags: [{ slug, name }], maxPerGame }.Slugs are lowercase kebab-case. A slug that isn't on the list fails the request with
unknown_tagand the message names the offending tags; more than five istoo_many_tags. Duplicates are removed for you.Sending
tagsreplaces the whole set."tags": []clears them. Omit the field to leave them unchanged.Players can browse by tag with
GET /v1/games?tag=pixel-art.
curl https://api.coolgptgames.com/v1/tags
# → { "tags": [{ "slug": "pixel-art", "name": "Pixel art" }, …], "maxPerGame": 5 }Human verification
On the website, creating a game includes a human-verification check (Cloudflare Turnstile). Where the check is configured, it fails closed for browser sessions: a session-authenticated create with no turnstileToken (or an invalid one) is refused with captcha_failed.
API keys are exempt. A request authenticated with Authorization: Bearer ak_… never needs turnstileToken, so the CLI, the Publisher SDK, the MCP server and your own scripts are unaffected — a long-lived scoped credential can't solve a browser challenge. If Cloudflare itself is unreachable the check passes rather than locking creators out.
Retry-safe creation
Send an Idempotency-Key header (any unique string, up to 128 characters) when you create a game. If the request is retried with the same key, you get the same game back (200 with "idempotent": true) instead of a duplicate draft.
curl -X POST https://api.coolgptgames.com/v1/games \
-H "Authorization: Bearer $CGG_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: space-dodger-first-publish" \
-d '{
"title": "Space Dodger",
"description": "Dodge asteroids. Grab stars. Don't blink.",
"instructions": "Arrow keys to move, Space to boost.",
"categorySlug": "arcade",
"tagline": "A 60-second arcade dodger",
"websites": ["https://example.itch.io/space-dodger"],
"tags": ["pixel-art", "fast-paced", "endless"],
"aiGenerated": true,
"aiDisclosure": "Code drafted with an AI assistant; art hand-drawn."
}'{ "game": { "id": "g_…", "slug": "space-dodger-3f9a1c", "status": "draft" } }Keep the id. Every other creator endpoint uses it.
Editing metadata later
POST /v1/games/{id} takes the same fields (all optional) and updates only the fields you send. Metadata edits take effect immediately and do not trigger re-moderation. They don't create a new version either.
To clear your websites, send
"websites": [].Sending an empty string (
"") for a text field such asdescriptionclears it.
3. Cover, screenshots & loading screen
Images are uploaded as raw bytes to POST /v1/games/{id}/media?kind=…, with the image's content type as the Content-Type header.
| What it is | Count |
|---|---|---|
| The card image in the catalog and on your game page. Required before you can submit a build. | 1 (uploading again replaces it) |
| Gallery images above your description (this is the default if | Up to 8 |
| Shown while your game boots. If unset, the cover is used instead. | 1 (uploading again replaces it) |
Requirements for every image:
Formats: PNG, JPEG, GIF or WebP. The format is detected from the file's contents, not its name or header. Anything else is rejected with
bad_image.Size: at most 5 MB per image (
too_large).Dimensions: none are enforced. Catalog cards display covers at 4:3, so a 4:3 cover (for example 1200 × 900) looks best.
# Cover (required)
curl -X POST "https://api.coolgptgames.com/v1/games/$GAME_ID/media?kind=cover" \
-H "Authorization: Bearer $CGG_API_KEY" \
-H "Content-Type: image/png" \
--data-binary @cover.png
# A screenshot
curl -X POST "https://api.coolgptgames.com/v1/games/$GAME_ID/media?kind=screenshot" \
-H "Authorization: Bearer $CGG_API_KEY" \
-H "Content-Type: image/jpeg" \
--data-binary @shot1.jpgEach upload returns the image's key and public url. Screenshot uploads also return the full gallery in order. Keep the key: you need it to remove an image or reorder the gallery:
DELETE /v1/games/{id}/mediawith{ "key": "…" }removes a cover, loading screen or screenshot.POST /v1/games/{id}/media/reorderwith{ "keys": ["…", "…"] }sets the gallery order.
If you lost a key, it's the part of the image URL after the CDN host (it starts with media/).
Image uploads are rate-limited to 60 per hour per IP address.
4. Upload a build
Uploading always takes three steps. Every client (website, CLI, SDK) does the same thing behind the scenes:
Init.
POST /v1/upload/initreserves a new version number and returns a one-timeuploadUrlplus anuploadId.PUT. Send the raw zip bytes to
uploadUrl, withContent-Type: application/zip. The URL goes directly to storage, expires after 15 minutes, and takes noAuthorizationheader.Complete.
POST /v1/upload/completewith theuploadId. The API unpacks and validates the bundle right away and returns either an error code from the packaging tables above or{ "status": "scanning" }, which means automated moderation has started.
Via REST (curl)
API=https://api.coolgptgames.com
AUTH="Authorization: Bearer $CGG_API_KEY"
# 1. init
INIT=$(curl -s -X POST $API/v1/upload/init -H "$AUTH" -H "Content-Type: application/json" \
-d "{\"gameId\":\"$GAME_ID\",\"runtime\":\"html5\",\"filename\":\"my-game.zip\"}")
UPLOAD_URL=$(echo "$INIT" | jq -r .uploadUrl)
UPLOAD_ID=$(echo "$INIT" | jq -r .uploadId)
# 2. PUT the bytes (Content-Type must be exactly application/zip)
curl -X PUT "$UPLOAD_URL" -H "Content-Type: application/zip" --data-binary @my-game.zip
# 3. complete
curl -X POST $API/v1/upload/complete -H "$AUTH" -H "Content-Type: application/json" \
-d "{\"uploadId\":\"$UPLOAD_ID\"}"
# → {"gameVersionId":"…","status":"scanning"}Via REST (JavaScript)
const API = "https://api.coolgptgames.com";
const headers = {
Authorization: `Bearer ${process.env.CGG_API_KEY}`,
"Content-Type": "application/json",
};
async function call(method, path, body) {
const res = await fetch(API + path, { method, headers, body: body && JSON.stringify(body) });
const json = await res.json();
if (!res.ok) throw new Error(`${json.error.code}: ${json.error.message}`);
return json;
}
// zipBytes: a Buffer / Uint8Array / Blob of your .zip
export async function uploadBuild(gameId, zipBytes) {
const { uploadUrl, uploadId } = await call("POST", "/v1/upload/init", {
gameId,
runtime: "html5",
filename: "my-game.zip",
});
const put = await fetch(uploadUrl, {
method: "PUT",
headers: { "Content-Type": "application/zip" },
body: zipBytes,
});
if (!put.ok) throw new Error(`storage PUT failed: HTTP ${put.status}`);
return call("POST", "/v1/upload/complete", { uploadId }); // { gameVersionId, status: "scanning" }
}Optional fields on init:
Field | Purpose | See |
|---|---|---|
| Turns on multiplayer rooms for this version | |
| External HTTPS hosts this version may call | |
| Declares achievements and XP intent for this version |
Via the website
Upload a game on coolgptgames.com runs the whole flow for you: drop your build, pick the runtime, fill in the listing (including tags and any external hosts), add a cover (required) and up to 8 screenshots, then press Publish game. The page waits a few seconds for the verdict. If moderation is still running, your dashboard updates when it finishes.
Two things the form does before it uploads:
Runtime is your choice. A dropdown lists everything
GET /v1/runtimesreports as accepting uploads, with that tool's export instructions underneath. The form pre-selects a guess from what you dropped, but never overrides you — so a PICO-8, Twine or Bitsy build really is tagged as that runtime and gets its engine-signature check.Loose files are zipped in your browser. Drop a folder's worth of files (or the single
game.htmla Twine publish or a Bitsy download gives you, or PICO-8'sgame.html+game.jspair) and the page builds the.zipitself, renaming the entry toindex.htmlwhen it isn't called that already. Drop a.zipand it's sent untouched. It's one or the other: a.zipplus loose files is refused.
Raw carts are refused, with the fix. A .p8 or .p8.png cart, a .bitsy game-data file, a .twee or .tws Twine source, a .gb ROM, a .gbsproj project, or a selection that's nothing but .txt game data all hold the game but not the engine that runs it, and the platform ships no engine of its own. Zipping one would upload "successfully" and then show players a blank screen, so the form stops and tells you which export to make instead (Twine → Build → Publish to File; Bitsy → Download → game.html; PICO-8 → EXPORT game.html). Anything else that isn't an allowed bundle file type is named and refused the same way, as is a build over the runtime's size limit.
Shipping an update from the website
Your game's manage page has a New version panel that does init → PUT → complete for you: drop the new build, pick the runtime, add a changelog, tick multiplayer if the version needs it, and list any external hosts. It then polls that version until it leaves scanning, so you see live, rejected or "waiting for a human" on the page without refreshing.
Via the CLI
arcadey publish <dir> --cover <image> zips a folder, creates the game, uploads its cover (and any screenshots) and the build, and waits for the verdict. The cover is required for a new game, and the CLI stops before making any API call if it's missing, so you never get a stray draft. It prints the new game's id and the command for shipping updates.
To ship an update to an existing game, add --game <id> (or put "id" in game.json). --cover is optional then and replaces the current cover. --changelog adds release notes.
An optional game.json in the folder can set title, category, description, instructions, realtime, connectHosts, gamification (declared achievements), cover, screenshots and id. See CLI for install, login, every flag and the exit codes.
Via the Publisher SDK
publish({ title, files, cover, … }) creates a game and publishes it with its cover in one call, and publishVersion(gameId, { files, … }) ships an update to an existing game (optionally replacing the cover or adding screenshots). Both wait for the verdict. gamification can be passed on either. Lower-level helpers (uploadCover, uploadScreenshot, uploadVersion) are there if you want each step yourself. See Publisher SDK.
A changelog and a
gamificationblock can be sent together.publishVersion(gameId, { changelog, gamification })routes the upload throughPOST /v1/games/{id}/versions, which carries both.
Upload rate limits
Starting uploads (upload/init and games/{id}/versions, which share one allowance) is limited per account, per hour, and the allowance grows with your creator level:
Creator level | Uploads per hour |
|---|---|
1 – 2 | 5 |
3 – 5 | 8 |
6 – 9 | 12 |
10 – 14 | 20 |
15 – 24 | 30 |
25+ | 50 |
A failed upload still used up its slot. So validate your zip locally before retrying in a loop. All API calls are also subject to the general per-IP limit (see Errors & limits).
When complete fails
If complete returns an error, that version is marked failed and your game's status doesn't change (a live game stays live). Fix the problem and start again from init. Retrying complete itself after it succeeded is harmless: it returns 200 with "idempotent": true and the version's current state. Each attempt uses a new version number, so it's normal to see gaps like v1, v4.
Error code | Meaning |
|---|---|
| Upload a cover first ( |
| The PUT never happened, failed, or went to an expired URL. |
| The game was delisted or removed after a DMCA notice, and can't take new builds. |
Any code from the packaging tables | The bundle itself is invalid. The error message names the file or limit. |
5. Moderation
Every version goes through the same pipeline.
How long it takes. The automated checks normally finish within a minute or two of complete, and most versions get their answer there. If a version is held for a human, we aim to decide within two business days. If it's been longer than that, email support@coolgptgames.com with the game id and we'll chase it.
While an update to a live game is in review, players keep playing the version that's already live — so a slow review never takes your game offline.
What gets checked
Check | What it looks for |
|---|---|
Bundle validation (at | Everything in section 1: format, entry file, file types, sizes, paths. |
Static code scan | Parses every |
Play-test render | Loads the game in a headless browser, plays briefly and captures screenshots. Checks that the game draws something, and flags any network request to a host you didn't declare. |
The three outcomes
Verdict | Happens when | Game not live yet: it becomes | Update to a live game |
|---|---|---|---|
Auto-approve | Every check is clean and confidence is high |
| The new version goes live in place of the old one |
Auto-reject | Any hard-fail signal (table below) |
| Only the new version is |
Needs review | Anything that needs a human's judgement |
| The new version waits ( |
Hard-fail signals (automatic rejection):
Signal | Reason code |
|---|---|
Code or HTML loads from, or sends data to, a host that isn't allowed and isn't declared in |
|
A network request to an undeclared host during the play-test |
|
Known crypto-miner code |
|
Blank screen, or the game isn't playable |
|
Doesn't look like a game at all |
|
Byte-identical to a bundle another creator already uploaded |
|
An exploitable XP or achievement economy |
|
Review signals (sent to a human, not rejected). None of these is forbidden. They just need a human to look:
eval(…),new Function(…),setTimeout("code string"), any.constructor(…)callnew XMLHttpRequest(), orfetch/WebSocketwith a URL built at runtimedynamic
import(), service-worker registrationwindow.top/window.parent,document.cookie,document.writechanging
location,window.open(…),javascript:URLs,<meta http-equiv="refresh">to another hostinline
on…="…"event-handler attributes in HTML.wasmfiles (they can't be statically analysed)heavily obfuscated code (lots of
_0x…identifiers), or JS/HTML that fails to parsepossible sexual content, graphic violence, hate symbols or IP infringement
title or description text that reads as spam
a near-duplicate of another game
low AI confidence in any of the above
Tip: a clean, un-minified or lightly minified build without
eval, inlineonclick=""handlers or runtime-built URLs is much more likely to be auto-approved. Obfuscators almost always send you to human review.
Re-uploading the same zip: your own uploads never count as duplicates, so re-uploading an unchanged zip (for example after a scan hit an error) works.
Reading the result
GET /v1/me/games/{id} (the status page in your dashboard, or arcadey status <gameId>) returns every version with its moderation data:
{
"game": { "id": "g_…", "status": "rejected", "visibility": "full", "currentVersionId": null, "…": "…" },
"versions": [
{
"id": "v_…",
"versionNumber": 2,
"status": "failed",
"phase": "rejected",
"isCurrent": false,
"changelog": "Boss fight rebalanced.",
"connectHosts": [],
"moderation": { "verdict": "needs_review", "confidence": 0.62, "status": "complete", "error": null },
"review": {
"decision": "reject",
"reasonCodes": ["ip_infringement"],
"notes": "Sprites are lifted from a commercial game.",
"automated": false,
"at": "2026-09-20T14:02:11.000Z"
},
"appeal": null
}
]
}phaseis the plain-language reading ofstatus, and the one to branch on. It'slive(being served now),approved(approved, but a newer version is current),rejected,in_human_review,superseded(cleared the automated checks, but a newer version went live first),scanning, orawaiting_upload(a version reserved byinitwhose bytes never arrived).moderationis the automated result:verdict(auto_approve/auto_reject/needs_review),confidence(0 to 1), and jobstatus(queued/running/complete/error).reviewis the decision with itsreasonCodesandnotes, plusautomated:truewhen the pipeline decided,falsewhen a human did. Automated decisions havenotesof"Approved by automated moderation."or"Rejected by automated moderation.", and their reason codes come from the hard-fail signals above. A version held for a human has noreviewuntil the reviewer decides. If an appeal is later overturned or upheld,reviewshows that newer decision.changelogandconnectHostsare the ones you sent with that upload.appealis your appeal on this version, if you filed one (see After a rejection).To poll one version, use
GET /v1/me/games/{id}/versions/{versionId}instead of refetching the whole game. It needs thereadscope and returns{ version, gameStatus, gameSlug }— the same version object, plus the game's own status (which stayspublishedwhile an update to a live game is moderated).Every decision also sends a
game.approved/game.rejectedwebhook if you've set one up. The payload includes thegameVersionIdandautomated: trueorfalse(see Webhooks). - Every decision reaches you directly, automated ones included. Approvals and rejections put a notification in your dashboard and send you an email, whether a human or the pipeline made the call.
Reason codes
Code | Meaning |
|---|---|
| Sexual content or nudity |
| Graphic violence or gore |
| Hateful imagery or harassment |
| Someone else's characters, art, music or brand |
| Doesn't load, or nothing is drawn |
| Not interactive, or not a game |
| A copy or near-copy of an existing game |
| Risky code patterns (eval, obfuscation, cookie or frame access…) |
| Crypto-mining code |
| Talks to hosts it isn't allowed to |
| Fake login or payment screens |
| The title, description or cover doesn't match the game |
| Spammy text, or ads for contact details |
| Real-money gambling mechanics |
| Unsuitable for the general audience |
| Farms XP, achievements or scores |
| See the reviewer's notes |
After a rejection
Usually the fastest route is to fix the issue and upload a new version: it goes through moderation again from scratch. If the rejection looks like a false positive (say, a legitimate eval in a scripting engine), removing or isolating the flagged pattern is the most reliable fix.
If you're sure the rejection is wrong, you can appeal that version:
curl -X POST "https://api.coolgptgames.com/v1/me/games/$GAME_ID/versions/$VERSION_ID/appeal" \
-H "Authorization: Bearer $CGG_API_KEY" -H "Content-Type: application/json" \
-d '{"note":"The flagged sprites are my own work — here is the source file and its history."}'
# → 201 { "appeal": { "id": "…", "status": "open", "note": "…", "createdAt": "…" } }The Appeal button on the rejected version in your dashboard does the same thing.
noteis required and must be 10 to 2,000 characters. Say what the moderator should look at. - Only a version that moderation rejected can be appealed. A build that failed validation atcompletehas nothing to overturn (not_rejected).One appeal per version (
appeal_exists). To try again, upload a new version.Up to 5 appeals a day across all your games (
429 rate_limited). A refused appeal doesn't spend the allowance.Upload something newer in the meantime and the old appeal is off the table (
appeal_superseded) — approving an older build over a newer one would silently roll your game back. A game that has been taken down can't have its versions appealed (game_removed).The moderator sees your note next to that version's changelog and the original decision, and can overturn it or let it stand. Either way you get an email and a dashboard notification with any moderator notes, and the outcome shows on the version as
appeal.status(open,approvedorupheld).An overturned rejection publishes that version exactly as a normal approval would, and sends a
game.approvedwebhook carryingappeal: true(andautomated: false).
Still stuck, or the appeal was upheld and you think something is genuinely wrong? Write to support@coolgptgames.com.
6. Status reference
Game status
| Meaning | Playable by the public? |
|---|---|---|
| Created, but no build has passed validation yet | No |
| A build passed validation and automated moderation is running | No (see the note below) |
| Automated checks want a human to look | No |
| Live | Yes |
| The game's latest build failed moderation before the game ever went live (a rejected update to a live game doesn't change its status) | No |
| You unpublished it, or a moderator took it down | No |
| Removed after a valid copyright notice | No |
visibility is either full (in the catalog, search and recommendations) or limited (playable by direct link, but hidden from discovery). Approved games are full. A moderator may set a game to limited while looking into player reports.
Updating a live game: your game stays
publishedand keeps serving its current version while the update is moderated. When the update is approved, it goes live in place of the old version (visibility and the original publish date are kept). If it's rejected or held for human review, only the new version is affected: players keep getting the current version. Soscanning,pending_reviewandrejectedonly describe a game that hasn't gone live yet. To follow an update, watch the new version's status inGET /v1/me/games/{id}.
Version status
| Meaning |
|
|---|---|---|
| Reserved by |
|
| Automated moderation is running |
|
| Cleared the automated checks and is waiting for a human reviewer. (A version approved after its game was taken down also stays |
|
| Failed bundle validation, automated moderation or human review (or moderation hit an internal error) |
|
| Approved. It is (or was) served to players. |
|
Each version also carries phase, which resolves the ambiguity in status for you — passed, for instance, means "waiting for a human" on a new game but "overtaken by a newer version" on a game that has since shipped an update. Branch on phase.
7. Updating your game
To ship a new build, upload a new version of the same game. Use either:
POST /v1/upload/initwith the existinggameId, orPOST /v1/games/{id}/versions, which takes the same fields plus achangelog(up to 2,000 chars) shown to players as "What's new".
Both return an uploadUrl / uploadId. Then PUT and complete as usual. Both count against the same hourly upload allowance. The CLI (arcadey publish <dir> --game <id>) and the Publisher SDK (publishVersion(gameId, …)) do all of this for you.
curl -X POST https://api.coolgptgames.com/v1/games/$GAME_ID/versions \
-H "Authorization: Bearer $CGG_API_KEY" -H "Content-Type: application/json" \
-d '{"runtime":"html5","changelog":"Boss fight rebalanced; new level 5."}'
# → 201 { "uploadUrl": "…", "uploadId": "…", "expiresAt": 1790000000000 }Each version gets its own moderation pass, and the game stays live on its current version until the update is approved. Your listing, cover, screenshots, ratings, play counts and leaderboards stay with the game across versions.
Achievements across versions. Both endpoints accept a gamification block (declared achievements). Leave it out and the new version keeps the game's current achievements: the economy audit knows about them, and your existing unlockAchievement calls keep working. Send a block and, on approval, it replaces the game's set: achievements missing from the new block are deactivated (an empty list deactivates them all). A changelog can only be sent on games/{id}/versions.
Changing your game without a new build: remote config values, store items and metadata can all be changed without re-uploading or re-moderation. See Remote config & analytics and In-game economy.
8. Preview builds & instant rollback
Private preview
GET /v1/me/games/{id}/preview returns a play link that only you can generate, for a build that isn't public, such as a draft, a version in review, or a rejected build you want to reproduce.
With no parameters, it previews the current version (or, if nothing is live yet, the newest version).
Add
?versionId=…to preview a specific version.The link has a short-lived access ticket and expires about 15 minutes after it's issued; the response's
expiresAtsays exactly when. Request a new one when it expires.A version that failed at
completehas no build to preview (no_build).
curl "https://api.coolgptgames.com/v1/me/games/$GAME_ID/preview?versionId=$VERSION_ID" \
-H "Authorization: Bearer $CGG_API_KEY"
# → { "playUrl": "https://…?t=…", "expiresAt": "…", "version": { "id": "…", "versionNumber": 3, … }, "game": { … } }Preview mode in the dashboard
The dashboard's Preview button opens that build inside the real player, at /dashboard/games/{id}/preview/{versionId}. That matters: the player is the SDK's message partner, so every GameSDK call is actually answered instead of hanging on a parent that isn't listening. Your loading screen, the pause/resume events and the in-game notification area all behave as they do for players.
The page runs in preview mode, where nothing is written:
Call | In preview mode |
|---|---|
| No play session is opened. Preview plays never count toward plays, play time or earnings. A local seed is issued so replay-based games still run. |
| Nothing granted ( |
| Nothing unlocked; the reply's reason is |
| Not recorded |
|
|
| No ad is requested or metered: an |
|
|
|
|
| Kept in a preview-only browser namespace, so a preview can't overwrite a player's real save — or your own |
| Real reads. You're testing against your live config and boards. |
So you can play a build end to end, see exactly what the SDK returns, and leave every number on your live game untouched.
Rollback
POST /v1/me/games/{id}/versions/{versionId}/rollback makes an earlier version current again right away, with no re-moderation. The game becomes published with full visibility.
You can only roll back to a version that was approved and published before (status
live). A version that's still waiting for a human (passed), or one that failed, returns400 not_approved.A game that was delisted or removed after a DMCA notice can't be brought back by rolling back: that returns
409 game_removed. A deleted game returns404.Use it to undo a bad update that was approved.
Achievements follow the rollback. The version you roll back to has its declared achievements re-activated exactly as approval would: its set is upserted and anything it doesn't declare is deactivated. So rolling back to v2 gives you v2's achievement catalog, not the one the bad v3 shipped. A version that was uploaded without a gamification block leaves the catalog unchanged.
await fetch(
`https://api.coolgptgames.com/v1/me/games/${gameId}/versions/${versionId}/rollback`,
{ method: "POST", headers: { Authorization: `Bearer ${process.env.CGG_API_KEY}` } },
).then((r) => r.json());
// → { ok: true, currentVersionId: "…", versionNumber: 2 }9. Unpublishing
DELETE /v1/games/{id} (the Unpublish button in the dashboard) removes the game from the catalog and from your games list:
Its status becomes
delistedand the game page stops loading for everyone, including you.It works on drafts as well, to get rid of an abandoned draft.
The game no longer appears in
GET /v1/me/games, and its manage view and every write to its id (metadata edits, new uploads and versions, preview, rollback and images) return404.The response tells you how long you have to change your mind:
{ "ok": true, "restorableUntil": "…" }. Deleting an already-deleted game is a no-op and doesn't restart the clock.
Undo: restoring a game you unpublished
You have 30 days to put it back yourself.
curl "https://api.coolgptgames.com/v1/me/deleted-games" -H "Authorization: Bearer $CGG_API_KEY"
# → { "restoreWindowDays": 30, "games": [ { "id": "g_…", "title": "…", "deletedAt": "…",
# "restorableUntil": "…", "restorable": true, "notRestorableReason": null } ] }
curl -X POST "https://api.coolgptgames.com/v1/games/$GAME_ID/restore" \
-H "Authorization: Bearer $CGG_API_KEY"
# → { "ok": true, "game": { "id": "g_…", "slug": "…", "status": "published" } }The dashboard shows the same thing as a Recently deleted panel with a Restore button.
GET /v1/me/deleted-gamesneedsread;POST /v1/games/{id}/restoreneedspublish.The game comes back as it was: a game that was published returns to
publishedon the same current version, keeping its slug, listing, ratings, plays and leaderboards. A game deleted mid-moderation is re-derived from its newest version, so it lands onscanning,pending_review,rejectedordraftas appropriate.Restore never lifts a takedown. A game that was already
delistedordmca_removedwhen you deleted it stays down (409 game_removed). Talk to support instead.Past the window you get
409 restore_window_expired; a game that isn't deleted gets409 not_deleted. A game deleted before self-serve restore existed has nothing recorded to restore it to and answers409 restore_unavailable— email support@coolgptgames.com for those.
A moderator can also delist a game (for example after player reports). That shows up as status delisted in your dashboard, while the game still appears in your list. A delisted game can't take new builds or be rolled back (409 game_removed).
10. Copyright (DMCA) from a creator's point of view
Anyone can file a copyright takedown notice against a game through the DMCA form on coolgptgames.com. Filing a notice doesn't remove anything by itself. A person reviews it first.
If the notice is valid, the game's status becomes
dmca_removedand it stops being playable. New uploads and rollbacks for it are refused (409 game_removed), so a removed game can't be republished from your side.You're told when it happens. A removal sends you an email and a dashboard notification naming the claimant and the work they say was copied, with a link to the counter-notice form. - Repeat infringers are terminated: three substantiated strikes close the account.
Don't re-upload removed content under a new game. Duplicate detection and human review both look for it, and it counts toward the strike policy.
Filing a counter-notice
If you believe the removal is a mistake (you own the work, have a licence, or it's fair use), you can file a counter-notice. Under the DMCA, removed content may be restored if the claimant doesn't take legal action within the statutory window.
The form is at coolgptgames.com/dmca/counter-notice (the link in your removal email opens it with the game pre-filled). Behind it is
POST /v1/dmca/counter-notices.GET /v1/me/dmca-removalslists your removed games with the notice each one answers and any counter-notice already filed.Both need a signed-in session: a counter-notice is a sworn statement, so API keys are refused with
403 session_required.A counter-notice is a legal document under 17 U.S.C. §512(g)(3) and the form collects every required element: your full legal name, postal address, phone and email; a description of the material and where it appeared; a statement under penalty of perjury that it was removed by mistake or misidentification; your consent to federal district court jurisdiction and to accept service of process; and your signature (your typed legal name).
One open counter-notice per game (
409 counter_notice_exists), only for a game that really was DMCA-removed (409 not_dmca_removed), up to 5 a day per account.You get an acknowledgement email straight away, and an email with the outcome once a human has decided. If it's accepted, the game is restored.
Anything else about a takedown, an appeal or an account: support@coolgptgames.com. See also the DMCA & Copyright policy on coolgptgames.com.
To avoid problems in the first place: use only assets you made, licensed or that are clearly public domain / CC0. Don't use other games' characters, logos or music. Credit licensed assets in your description.
Calling external hosts (connectHosts)
Games run in a locked-down sandbox and can only reach our own services by default. If your game needs a public HTTPS API (say, a word list or an emoji service), declare the host on upload/init or games/{id}/versions:
{ "gameId": "g_…", "runtime": "html5", "filename": "my-game.zip",
"connectHosts": ["https://api.example.com"] }Rules (a violation fails init with bad_connect_hosts):
Exact HTTPS hosts only. A bare host such as
api.example.comis treated ashttps://api.example.com. Paths are ignored, since the declaration is per host.No wildcards, IP addresses,
localhost, single-word hosts, or internal names (.local,.internal,.lan,.corp…).No coolgptgames.com origins. They're already allowed.
At most 10 hosts per version (
too_many_connect_hosts).The API you call must send
Access-Control-Allow-Origin: *and must not need cookies or credentials. Your game's origin is opaque (null).Declared hosts are checked by moderation on every version. Declare them again each time you upload.
Moderation treats declared hosts as allowed: a literal
fetch("https://api.example.com/…")to a declared host passes the code scan and the play-test. Requests to any undeclared host are still flagged.
How the browser enforces this is covered in How games run. If the data is static, just put it in your zip as a .json file: no network, no CORS, nothing to review.
Troubleshooting checklist
Symptom | Likely cause |
|---|---|
| Upload |
| No |
| A file type that isn't on the allowlist, such as |
| Not a real zip, or a truncated upload. Re-zip and upload again. |
The website refuses your file before uploading | You dropped a raw cart or project file ( |
| The PUT was skipped, failed, or the 15-minute URL expired. Start again from init. |
Storage PUT returns 403 |
|
| Hourly upload limit reached. Wait, and validate locally before retrying. |
| The game was delisted or DMCA-removed. It can't take new builds or be rolled back. |
Instantly | Your zip is byte-identical to another creator's upload. |
Stuck in | A human reviewer will decide. Check the review signals above to avoid it next time. |
Update doesn't show up for players | It's still in moderation (the old version keeps serving until it's approved), or it was rejected. Check the new version's status in |
Assets 404 in the preview | You're using root-absolute paths ( |