Zip format, versions, and rollback
What your zip needs to contain
index.htmlat the zip root is required — that's the entry point ShortPlay loads (BUNDLE_ENTRY = 'index.html',api/src/upload.ts:65). A zip with no rootindex.htmlis rejected at upload withbundle_missing_index_html(api/src/upload.ts:292).- 50 MB max, checked twice — once on the object as stored, once again while the archive is
being expanded (an archive that would expand past the limit is rejected mid-read, not just at
the outer file size —
api/src/upload.ts:275-276, 355-356). - Everything your game needs — code, art, audio, fonts — ships inside the zip. Once uploaded, your game's CSP won't let it fetch external assets at runtime (see Hosted-game contract → sandbox/CSP posture), so nothing can be loaded from a CDN at play time.
- Optional: a
shortplay.jsonat the zip root with your capability manifest (inputs,orientation, etc.) gives the upload wizard's auto-detect step a head start — without one, ShortPlay heuristically scans yourindex.htmlfor signals (keyboard/mouse/touch keywords) as a starting guess. Either way, you confirm (and can correct) the manifest in the upload wizard's Confirm step before the draft is created — auto-detection is a starting point, never the final word (api/src/upload.ts:187-267).
The upload flow, endpoint by endpoint
POST /v1/uploads/init— start an upload for a game (title + hook + tag); the hook is max 50 characters (counted as visible characters), and a longer one is refused here with400 bad_request, before it counts against your limit (api/src/upload-routes.tsTAGLINE_MAX,api/src/taglineLimit.ts;/completere-checks it for a new game); returns anuploadIdand auploadUrlto PUT your zip to. Rate-limited to 10 uploads per account per hour (api/src/upload.ts:64,api/src/upload-routes.ts:268-329).PUT /v1/uploads/:id/bundle— stream your zip body to that URL (api/src/upload-routes.ts:335-367).POST /v1/uploads/:id/complete— send your confirmed manifest (+ title/hook/tag); the bundle is validated (zip structure,index.htmlpresence, size) and a draft version is staged. Nothing is published yet (api/src/upload-routes.ts:374-).- Without
gameId(your first upload): creates the game and its v1 draft version, so you can preview it right away (GET /v1/games/:id/versions/1/preview, or create a preview link). The game row is written fromtitle,hook,tag,genre,tags,description,accent,orientationandconfirmedManifest. - With
gameId(a new version on an existing game): stages the next draft version (the next version number after the highest existing one) with itsreleaseNotesand yourconfirmedManifest, and points the game row'sbundle_version/bundle_pathat it. It never writes the game's title, hook, tag, genre, description or accent (edit those separately with the game-details endpoint), and it does not change what players get: the confirmed manifest rides on the new version and is applied to the game when that version is published. (If the game has never been live, the manifest is applied to the game row immediately, since nothing in the feed can change.) On a game that is already live it also leavesupdatedAtalone, so a card nothing changed on doesn't look freshly updated. A retried/completefor the same upload is idempotent (it returns the same version, not a duplicate). (api/src/upload-routes.tsexisting-game branch;api/src/upload.tsstageExistingGameUpload.)
- Without
POST /v1/games/:id/publish— promotes a draft (or a specific version, via{ "version": N }in the body) to live. This is the only step that makes a game appear in the feed, and only after the next pool-rebuild cron. Publish also enforces a completeness gate: a game with no hook or no genre tag set in Details is refused with422 details_required— the feed never shows an incomplete listing (api/src/upload-routes.ts:612-786). The response includesurl, the public game link (https://shortplay.io/game/<id>). Omitversion, or sendnull, to publish the newest draft; otherwise a positive integer (a numeric string such as"1"is also accepted) — anything else is a400(error.reason: "invalid_version"); an unknown or other-game version is a404(version_not_found) — neither publishes anything.
Versions
Every upload after the first becomes a new version on the game's ladder — versions are immutable once promoted, and only one version is ever live at a time. You can:
- Publish a specific version —
POST /v1/games/:id/publishwith{ "version": N }promotes that version (or, if it was already live before, re-promotes it) (api/src/upload-routes.ts:647-693). - Roll back —
POST /v1/games/:id/rollbackwith{ "version": N }re-pointslive_versionto a prior version. Rollback does no R2 writes (versions are immutable), so it's instant (api/src/upload-routes.ts:788-824). - See the version history —
GET /v1/games/:id/version-historyreturns every version, its state (draft/published), whether it's the currently-live one, and its release notes (api/src/upload-routes.ts:826-893).
Draft preview links
Want to play a build on your phone, inside the real ShortPlay frame, before (or without) publishing it? Mint a preview link for that build. It is a private, unlisted URL: the game never appears in the feed because of it.
Create one —
POSTto the preview-links route below. You must be signed in and a member of the game's maker page. The version can be a draft or the live one. An optional body{ "expiresAt": <epoch-ms or ISO timestamp> }sets an expiry; with no body the link never expires (api/src/upload-routes.ts:1234-1273,:1752-1762). The response is{ "link": { "token", "url", "expiresAt", "revokedAt", "playCount", "active", ... } }. In the app, the same thing lives on the game's Manage page: each build has a "Share preview" panel with an expiry picker (No expiry, 24 hours, 7 days), a Generate link button, and a Revoke button per link (ManageGamePage.tsx:2197-2255).The URL is always a full, absolute address:
https://shortplay.io/preview/<token>(api/src/upload-routes.ts:1809-1818). Share it exactly as given, with thehttps://shortplay.iopart. Do not shorten it to/preview/<token>; a bare path only works inside the app.Who can open it — anyone who has the link, signed in or not. The token is an unguessable random UUID and is the only credential (
api/src/upload-routes.ts:1431-1460). It opens the real ShortPlay game frame, not a bare iframe (PreviewPage.tsx:373-451). Treat it like a secret link: whoever you send it to can forward it.List links —
GETon the same route (same maker gate) returns each link with itsplayCountandactiveflag (api/src/upload-routes.ts:1252-1257).Revoke one —
DELETEon the route with the token appended returns204(api/src/upload-routes.ts:1275-1297).The routes:
POST /v1/games/:id/versions/:version/preview-links GET /v1/games/:id/versions/:version/preview-links DELETE /v1/games/:id/versions/:version/preview-links/:tokenWhen a link stops working — the preview endpoint answers
410once the link is revoked or past itsexpiresAt, and also if the build or the game no longer exists (game removed or deleted) (api/src/upload-routes.ts:1435-1445). The page then shows "This preview link is no longer active." (PreviewPage.tsx:51-54).
Release notes
Each version can carry release notes (max 2000 characters,
api/src/upload-routes.ts:1586 MAX_RELEASE_NOTES_LEN), attached at completion time and surfaced
back in the version-history response. They're free text — a changelog line for players or your own
record, not machine-parsed.