Hosted-game contract
A hosted game is a standalone HTML5 bundle (any engine — Unity, Godot, Construct, Phaser, PICO-8,
raw canvas) that runs, zip-rooted at index.html, inside a sandboxed iframe on ShortPlay's own
games origin. The zero-integration baseline is real: an unmodified, well-behaved game — one
that never imports the SDK at all — plays, and reflows when it grows because ShortPlay resizes
the iframe. It cannot be told to pause: when the feed suspends it, ShortPlay tears it down and it
restarts when shown again. The SDK is an optional, progressive-enhancement layer on top of that
baseline — never a requirement to publish. But if you use any part of it, call
ShortPlay.game({ ... }): without that call you get no lifecycle at all.
build(container, accent, dims)
If you use the SDK, you register your game with ShortPlay.game({ build, ... }). build is
called once, when the host is ready to construct your game:
interface GameDefinition {
/** Construct the game into `container` at the given accent + initial dims. */
build(container: HTMLElement, accent: string, dims: { w: number; h: number }): void;
/** Full-play focus changed (true = focused). */
onFocus?(focused: boolean): void;
/** Suspend simulation. */
onPause?(): void;
/** Resume simulation. */
onResume?(): void;
/** Stage box changed. Standards-first reflow already happened; this is the explicit hook. */
onResize?(w: number, h: number): void;
/** Teardown imminent. */
onStop?(): void;
}
A note on drift, so you don't chase a stale doc: an earlier internal design doc
(docs/07-architecture-and-build-plan.md) described this contract as build(container, accent, dims) returning an object — { pause(), focus(on), resize(w,h), stop() }. That is not what
ships, and that doc now carries a "superseded by this page" banner. The real, implemented contract is the callback-registration form above — onFocus,
onPause, onResume, onResize, and onStop are optional properties on the object you pass to
ShortPlay.game(...), not methods on a value build returns
(app/games-sdk/shortplay-sdk.ts:97-119). This site documents the code.
Lifecycle callbacks
These callbacks only run if you called ShortPlay.game({ ... }) (SDK — the host sends no lifecycle command to a game that has not registered). The host drives your game's lifecycle by calling these callbacks (all optional) in response to
feed events — a swipe away, a promotion to full-play, a resize as the game grows, teardown when
it's evicted from memory:
| Callback | Called when |
|---|---|
onFocus(focused: boolean) |
Full-play focus changed. focused=false fires when your game is visible but not the one the player is actively interacting with (e.g. a neighboring cell in the feed) — you should keep running, just stop treating input as targeted at you. |
onPause() |
Suspend simulation. The SDK also dispatches a real visibilitychange (hidden) and window blur right after it, so a game that called ShortPlay.game(...) without an onPause still gets the standard events. This only happens when ShortPlay pauses your game; the SDK never turns a browser visibilitychange into onPause (see App to background and phone lock). |
ShortPlay.resumed() |
Not a callback: you call it. Tell the shell the player tapped Resume in your own pause menu after a background or lock pause, so it releases its paused state. When the app is backgrounded or the phone is locked, ShortPlay pauses your game; on return it stays paused until the player resumes. Declare ownsPauseUi: true too, or the shell shows its own dim and Play button over your frame. |
onResume() |
Resume simulation. Symmetric to onPause — the SDK also dispatches a real visibilitychange (visible) and window focus. |
onResize(w, h) |
The stage box changed size. Standards-first reflow (resizing the iframe, which fires your own resize/ResizeObserver handling) already happened by the time this fires — it's the explicit hook for anything that needs the exact new logical dimensions. |
onStop() |
Teardown is imminent. |
Source: app/games-sdk/shortplay-sdk.ts:97-196 (the GameDefinition interface and the
handleInbound switch that drives it).
Lifecycle sequence: what the shell sends, and when
Every command below reaches your game only after you called ShortPlay.game(...) and the host received
your ready. Before that the host sends no lifecycle command, and a game that never registers is
classified unbridged.
Startup
| # | What happens | Your game sees |
|---|---|---|
| 1 | Your script loads and the SDK is constructed. The SDK posts a boot hello. It grants nothing. |
nothing |
| 2 | You call ShortPlay.game(def). The SDK posts ready (with sdkVersion). |
nothing |
| 3 | The host receives ready and sends init (after the save snapshot, if your game has saves). |
build(container, accent, dims) |
| 4 | If the card is focused (full play), the host then sends focus. |
onFocus(true) and a window focus event |
If ready never comes, the shell classifies the bundle unbridged: about 900 ms after your page loads
if the SDK is not in the bundle at all, or at the 9 second ready timeout if the SDK said boot but you
never called ShortPlay.game(...). An unbridged game is not an error: it keeps running, but it can
never be paused, so when the feed suspends it the card tears it down 300 ms later: the card shows its cover
instead of your last frame, and the game starts from scratch when it is shown again. If
ready arrives late the game is upgraded to bridged.
Exit to the feed, game page, next card, background
| Event | What the shell sends your game, in order |
|---|---|
| Exit full play to the feed (back pill, edge swipe, Escape) | 1. blur if the card stops being focused (desktop; a mobile previewPlayable card keeps input focus, so you may see no blur) then 2. pause, then 3. resize when the stage box changes size (the card is not the full-play size). |
| Play again (re-enter full play from the card) | 1. focus if the card regained input focus, then 2. resume, then 3. resize back to the full-play size. build is not called again (a game that was torn down, see the notes below, is rebuilt with a fresh init). |
Game page, after full play (/game/<id>) |
The same as exit to the feed: pause when you leave full play, resume when you re-enter. The frame is reused, not reloaded. |
| Next card (mobile) | The card you leave gets blur (if it had input focus) then pause (it stays mounted while it is within one card of the centre). Two or more cards away it gets stop and its frame is removed. The card you arrive on gets resume (plus focus) if it was already mounted, otherwise a fresh init. |
| Next card (desktop) | The card you leave is not paused: neighbours within one card keep running. If you had exited full play on it, it gets resume instead. Two or more cards away it gets stop and its frame is removed. |
| App to background / foreground, phone lock | The shell sends pause (one per background, even if the app reports it twice). On return it sends no resume: your game stays paused and shows its own pause menu until the player resumes. See Background and phone lock. |
| TV, game page | pause while the game sits behind the game-page chrome, unless your manifest sets pauseBehavior: "keep-running". |
| Teardown | stop, immediately followed by the frame being removed. Flush anything you must keep in onStop. |
Notes on order:
- The three commands in the first row are sent in that order within the one change, but the
resizecan also arrive later, once the shell has measured the new box. - A card that was first mounted as a neighbour can be built without ever being paused, and so can receive a
resumewith nopausebefore it. TreatonPauseandonResumeas idempotent; never assume strict alternation. - Exiting full play on a game that is not
previewPlayablealso pauses it and keeps the same frame mounted (paused), on the game page, on/preview, and on the feed card you exited on. Play resumes the same run: no newbuild. The frame is removed (withstop) when you leave the page, or, on the feed, when the card is two or more cards away. A feed card that is notpreviewPlayableand that you scroll away from and back to (derived from the code, not observed live) shows its cover again, and Play starts a fresh run.
All four situations at a glance
One row per situation, with the ordered calls your game sees. Every row is derived from the code (cites in the docs-site SOURCES file); where a cell is not something ShortPlay guarantees, the cell says what you must handle instead. "SDK adds" means the SDK dispatches those DOM events itself, right after your callback.
| Situation | Ordered calls to your game | Timing |
|---|---|---|
| Leave full play to the feed (back pill, edge swipe, Escape) | 1. onFocus(false), only if the card stops owning input (desktop; a mobile previewPlayable card keeps input focus, so expect none). 2. onPause(), then the SDK adds document.hidden = true, visibilitychange, window blur. No onResize: ShortPlay scales your paused frame to the card instead. |
Both calls are sent in the order shown, in the one change that exits full play. QA measured pause arriving under 100 ms after the exit action on prod (build 22938, 3 runs per browser). That is a measurement, not a guarantee. A game that is not previewPlayable is paused the same way and keeps its paused frame; it is not stopped. |
| App to background and back; phone lock | Hidden: onPause(), then the SDK adds visibilitychange, window blur. Visible again: onResume() (plus visibilitychange, focus), only if the game was running before the hide. No onFocus. |
One onPause per hide, and none if exiting full play already paused you. Whether and when each phone's WebView fires visibilitychange is not verified by ShortPlay on a device (see below). |
| Swipe to the next card (mobile) | The card you leave: onFocus(false) if it had input focus, then onPause(). Two or more cards away it gets onStop() and its frame is removed. The card you arrive on: onResume() (plus onFocus(true)) if it is already mounted, otherwise a fresh build. |
Sent when the active card changes; ShortPlay does not promise a time bound. An arriving card that was only ever a neighbour may get onResume with no onPause before it. |
| Swipe to the next card (desktop) | The card you leave is not paused: neighbours within one card keep running. Two or more cards away: onStop(), frame removed. A card you exited full play on gets onResume() when you return to it. |
Same as above. |
Return to a card you left (play again, or scroll back to it) is the mirror image: onFocus(true) if the
card regained input focus, then (only if the size really differs from the one you were paused at) onResize to the real size, then onResume().
A card you left is "stopped" when it is two or more cards away; within one card of the centre it stays
mounted and paused (a game that is not previewPlayable only if it was played full-screen first; otherwise it was never mounted). The same host drives
/preview: the first row and its return are identical there.
Background and phone lock
When the app is backgrounded or the phone is locked, ShortPlay pauses your game; on return it stays paused until the player resumes.
The shell pauses on either signal: the page becoming hidden (the browser's visibilitychange) or, in the iOS app, the native app-inactive event
(the iOS simulator reports the native event first, then visibilitychange about two seconds later; a real phone lock was not measured). It sends the same pause as exiting full play, once per background.
When the player comes back, the shell sends no resume: your game stays paused and shows its own pause menu.
If your game draws its own pause menu, declare it with ownsPauseUi: true in your ShortPlay.game({...}) definition (read once, at ready).
ShortPlay then paints nothing over your game in full play: your menu is what the player sees. When the player taps Resume in it, call ShortPlay.resumed() so the shell clears its paused flag. Clearing it makes the shell send one resume straight back, in the same tick, as it does whenever its paused flag clears. So make your onResume idempotent: a call that arrives while your game is already running must be a no-op, and must not restart music, timers or the game loop a second time.
If you do not declare it (an older SDK, or an unmodified game), the shell dims the frame with its existing paused dim and shows its own Play button over it; a tap on Play or anywhere on the frame sends resume.
A game already paused by exiting full play is unaffected
(app/src/runtime/SandboxHost.tsx, the bgPaused latch; app/src/runtime/protocol.ts, GameResumedMessage and ready.ownsPauseUi).
- You do not need to handle
visibilitychangeyourself to stop your loop or audio:onPauseandonResumeare enough. Handling it too is optional; make any handler safe to run twice, because the SDK also dispatchesvisibilitychangeright afteronPause/onResume. document.hiddenanddocument.visibilityStateinside the frame aretrue/"hidden"while ShortPlay has you paused or while the real browser is hidden, and they keep following the browser after any number of pauses and resumes (app/games-sdk/shortplay-sdk.ts:846-885).- Platform saves are flushed for you when the page is hidden (see Saves).
- Not verified on a device: what each phone's WebView reports to a game frame on lock, and whether
visibilitychangeis delivered before the app freezes. The shell pauses on the native signal too, so your game is paused either way. Test on your phone.
What the frame is while paused
- Mounted and visible. For a
previewPlayablegame the same iframe stays mounted in the card; the shell draws only its border around the frame and does not cover it or reload it. It is not re-created when you resume. - Your last frame, scaled. While paused, ShortPlay keeps your frame at the layout size it had when you paused and
scales it (contain-fit, centred) into the card; the rest of the card is the theater background. Nothing resizes your
frame, so your canvas is never cleared. If the card's box changes while you are paused (rotation, a window resize),
ShortPlay keeps scaling the same frame and still sends no
resize(app/src/runtime/SandboxHost.tsx:1072-1085returns early while paused; the scale is:1164-1185). - Your canvas is kept, as long as your code does not clear it. A canvas is cleared when you assign its
widthorheight, so a resize needs a repaint. document.hiddenis true inside the frame betweenpauseandresume(the SDK sets it and firesvisibilitychange), and also whenever the real browser is hidden.- Not guaranteed:
requestAnimationFramecallbacks. Do not rely on them while paused.
Rule for a suspended game (the one rule, here and on the SDK page): while paused, ShortPlay scales your last frame; you get onResize on resume only if the size really changed. Resize and repaint in onResize as usual; do not wait for one, and do not rely on one while paused.
What your game must do at each step
| Step | Your game must |
|---|---|
build(container, accent, dims) |
Create your DOM inside container at dims. Draw your first frame. Show your intro here, once. |
onFocus(true) |
Start treating input as aimed at you. |
onFocus(false) |
Input focus only. Stop treating input as yours (ignore taps and keys). Keep running: do not pause, mute, or stop your loop. This is not onPause. |
onPause() |
Stop your simulation and your loop (cancelAnimationFrame). Paint one paused frame and leave it on screen. Do not clear the canvas, show a blank, or restart a menu. Do not depend on requestAnimationFrame running afterwards. Make it idempotent. |
onResize(w, h) |
Resize your canvas or layout and repaint immediately. While you are paused it does not arrive (ShortPlay scales your last frame); on resume it arrives, before onResume, only if the size really changed. Be safe if it arrives before build has made your canvas: a game with saves can be sent a resize while the host still waits for its save snapshot (derived from SandboxHost.tsx:1072-1085, which is not gated on init having been sent; not observed live). |
onResume() |
Restart your loop from where you stopped. Do not replay the intro and do not reset score or level. Make it idempotent. |
onStop() |
Cancel timers and loops, and flush anything you must keep (saves are flushed by the platform on pause and exit). |
Preview and the game page
/preview/<token> (your draft or published preview link) runs the same host lifecycle as the feed and the
game page for exit and return: it mounts the same game stage, so leaving full play sends pause and playing
again sends resume. QA walked this on prod build 22938 on desktop (Escape) and mobile (Chromium and WebKit):
the frame was suspended, got pause, stopped animating, and got resume on re-entry, on the same iframe
(.handoff/state/qa/walk-preview-pause-prod-22938.md). Swipe-to-next-card does not exist on /preview (one game).
On desktop, Escape always exits full play on /preview.
What a card shows when the game is not previewPlayable
The feed card shows the game's cover art (the uploaded cover, otherwise an accent-coloured generated card)
with a Play cue; your game is not mounted on the card at all until the player taps Play
(app/src/feed/shared/artChain.ts:35-50). After the player exits full play the game is paused, not stopped:
its frame stays mounted behind the card (game page, /preview and the feed card you exited on) until the page is left
(app/src/feed/shared/GameStage.tsx:339-362), and Play resumes the same run. This is the behaviour of a
previewPlayable: true game after exit, extended to every hosted game that was played. (Before this change the
frame was removed about a second after exit and Play started a new run.) A previewPlayable: true game additionally
runs live on the card before anyone taps Play.
A complete game that does all of this, and a checklist to test your own: SDK → Reference game and SDK → Self-check.
The capability manifest
Every hosted game ships a manifest, validated against data/seed.schema.json at upload time
(POST /v1/uploads/:id/complete rejects a manifest the schema doesn't accept —
api/src/upload-routes.ts:392-409). This table is generated at build time from that schema —
a standing test fails the docs build if the schema gains a field this table doesn't list, so it
can't silently go stale.
| Field | Type | Required | Description |
|---|---|---|---|
inputs |
array of enum: tap, hold, drag, swipe, keyboard, mouse, tilt, gamepad, multitouch, dpad, remote |
yes | ANY-OF capability tokens: a device can play this game when it satisfies AT LEAST ONE. dpad (four-way pad + select) and remote (minimal OK/Back TV remote) are the TV-native pair -- board eng-tv-input-architecture. Kept identical to INPUT_TOKENS in app/src/data/types.ts (asserted by app/src/data/deviceClassContract.test.ts). |
players |
object { min, max } | no | simultaneous players on one screen. ABSENT = unknown -> render no pill, offer no 2P split (never a guessed 1P). Both bounds are required together because the cover pill's grammar needs them: '2P' (min 2) reads differently from '1-2P' (min 1). min <= max is NOT expressible in draft-07 -- it is enforced at read time by isPlayerRange (app/src/data/players.ts), which treats a nonsense range exactly like an absent one. |
previewPlayable |
boolean | no | can it be truly played in a portrait feed cell with declared inputs? hosted-only meaningful |
orientation |
enum: portrait, landscape, any |
yes | |
minViewport |
object { w, h } | no | DEPRECATED (CPO ruling 2026-08-31): no runtime consumer; tolerated on read, ignored. Do not author. |
reservesEdgeInput |
boolean | no | gameplay uses edge/horizontal gestures -> exit gesture must migrate off the edge |
escExit |
boolean | no | DEPRECATED (CPO ruling 2026-08-31): no runtime consumer (the host exits on Escape regardless); tolerated on read, ignored. Do not author. Was: hosted bundle honors Escape while its iframe has focus and requests full-play exit; absent/false means Escape must not be advertised |
managesPlayers |
boolean | no | the game OWNS its multiplayer lobby (players() contract v1, app/games-sdk/shortplay-players.ts): the platform draws nothing over it and the SDK hands the game the four seats, the join QR + code and slot-tagged input. Absent/false = platform-managed pairing, untagged synthetic input. Mirrors GameDefinition.managesPlayers. |
controllerLabels |
object { 0, 1, 8, 9, move, aim } | no | names for the paired phone's pad keys and stick captions (SDK D2-wire; app/games-sdk/shortplay-players.ts CONTROLLER_LABEL_KEYS). Keys are the W3C button index ('0' = A, '1' = B, '8' = Select, '9' = Start) plus the twin-stick captions 'move' / 'aim'. Max 8 characters each, counted in code points so not a JSON-Schema maxLength: the relay DROPS an over-length label, never truncating; a label that does not fit the key at render time falls back to the default. Absent/empty = the default glyphs. Mirrors GameDefinition.controllerLabels, the dev-time check. |
controllerLayouts |
array of enum: touch, touch+dpad, retro, twin-stick, move-and-aim |
no | which paired-phone controller layouts the game accepts, best fit FIRST (Mega 2, app/games-sdk/shortplay-players.ts CONTROLLER_LAYOUTS). The phone offers ONLY these, defaults to the first, and the player can switch; a retro/twin-stick layout contributes the gamepad token. Absent = no declaration: the phone keeps its touch/d-pad derivation from inputs. |
pauseBehavior |
enum: pause, keep-running |
no | |
designedFor |
array of enum: phone, tablet, desktop, tv |
no | |
compatibleWith |
array of enum: phone, tablet, desktop, tv |
no | |
scoring |
object { order, format, max, min, label, minRunMs } | no | OPT-IN high-score ladder declaration (docs/154 section 2). ABSENT = no ladder: submitResult is refused no_ladder and score() never reaches a ladder. Maker-declared (rides the version manifest). Mirrors Scoring in app/src/data/types.ts (asserted by app/src/data/scoringContract.test.ts); the submit route (api/src/ladder.ts parseScoring) is its consumer. |
persistence |
object { enabled, maxKeyBytes, expectedTotalBytes, maxKeys, legacyLocalStorage } | no | OPT-IN cloud saves (docs/153 section 3). ABSENT = no saves: the SDK's save.* is unavailable and the host sends no snapshot. NOT an input capability: it never gates device fit. Declared, not trusted: the Worker enforces the ceilings and the player total regardless. Mirrors Persistence in app/src/data/types.ts (asserted by app/src/data/persistenceContract.test.ts). |
mobileGuess |
enum: likely, unlikely, unknown, null |
no | for link games: heuristic from description text (e.g. 'TAP' -> likely) |
deterministic |
boolean | no | ROADMAP: supports state/input-log replay (enables clip capture, robust saves, anti-cheat) |
contentFlags |
array of string | no | content classes the feed deranks on; e.g. horror. Open string array (extensible), not an enum -- Curator-confirmed semantics 2026-09-01. |
inputs and orientation are required; everything else is optional. Five fields are worth calling
out:
scoring— opt in to a high-score ladder (order,format,min/max,label,minRunMsdefault 3000). Live in production; the ladder UI is coming. Full reference andsubmitResult(): SDK → High-score ladders.players—{ "min": N, "max": M }simultaneous players on one screen (integers,1 <= min <= max). It is validated and stored on upload (fromshortplay.jsonorconfirmedManifest), and drives the cover pill and the 2P split; a nonsense range in the zip'sshortplay.jsonis dropped, and one inconfirmedManifestis refused with400 manifest_invalidnamingmanifest.players(api/src/upload.tssanitizePlayers;api/src/upload-routes.tssemanticManifestViolations).reservesEdgeInput— set thistrueif your gameplay uses edge/horizontal gestures (e.g. a swipe-to-move mechanic that touches the screen edges). On mobile, the platform's own exit gesture is a left-edge accelerant by default; when your game reserves that input, the platform migrates the exit affordance off the edge so the two don't collide. It's mobile-only — on desktop, Escape is the universal exit regardless of what your game does (docs/07-architecture-and-build-plan.md:168).pauseBehavior—"pause"(default) means your game suspends like any normal app when it loses focus/visibility."keep-running"opts out of that suspension (a game whose "part of the scroll" is the point — e.g. an ambient/idle piece that should keep animating in a feed neighbor cell).persistence— opt in to saves. See below.
persistence (opt-in saves)
{ "enabled": true } turns on the ShortPlay.save API for your game; absent (or enabled: false), every
save.* read is unavailable and the platform sends your game no snapshot. Optional fields:
maxKeyBytes— per-key cap in bytes, default 1048576 (1 MiB), maximum 4194304 (4 MiB).maxKeys— keys per game, default 32, maximum 64.expectedTotalBytes— informational: your expected total save size (up to 10485760).legacyLocalStorage— recorded in the schema, but the localStorage stand-in it refers to is not built; setting it does nothing today.
persistence is not an input capability and never affects which devices your game is offered on. The platform
enforces the ceilings regardless of what you declare. API, limits and device/cloud behaviour:
SDK → Saves.
Escape / exit forwarding
When a player's keyboard focus is inside your game's iframe, the host never sees the keydown — so
without help, "Escape to exit full-play" would go dead the moment someone clicks into your game.
The SDK forwards an in-frame Escape keypress to the host as an { type: 'exit' } message; the host
exits full-play for the game that owns it. You never need to handle Escape yourself — this is
automatic once you import the SDK (app/games-sdk/shortplay-sdk.ts:210-217). The manifest's
escExit field is an advertising flag: set it true only if your bundle actually honors this (a
bundle that doesn't import the SDK should leave it false/absent).
Paired-controller input (a phone paired to a desktop session)
When a player pairs a phone to a desktop full-play session ("Pair your phone"), the phone's taps
are relayed to the desktop and delivered into your game's frame as a { sp: 1, nonce, type: 'input', token, phase, x, y, … } message (app/src/runtime/protocol.ts's GameInputMessage) —
the same channel real local taps use once they cross the sandbox boundary. Delivery is
automatic; you do not have to write ANY code to receive it, and you cannot opt out. Coverage
depends on how your bundle is built:
- You import the SDK (
ShortPlay.game(...)): input arrives as a REAL syntheticpointerdown/pointerup/keydownevent dispatched at your game's own DOM — indistinguishable from a real local tap to your existing listeners. Nothing to add. - You hand-roll your own
postMessagebridge instead of the SDK: the platform injects an independent input listener into your served document at serve time (a sibling to the Escape-exit shim) — it does not depend on your ownmessageguard acceptingtype:'input'. If your own listener's guard only accepts one message shape (e.g.type === 'lifecycle'), that is fine and expected: the platform's listener is separate from yours and still delivers the tap as a real DOM event. You still don't need to add anything, but if you do want to read the raw message yourself (e.g. for a fully custom control scheme), the schema isGameInputMessageabove. - Your bundle is a raw upload with no platform integration at all (no SDK, no
postMessagecode): the platform's injected listener is still the only thing that can turn a paired tap into a DOM event for you — same as the hand-rolled case above. This requires your document to have actually loaded (a genuine network/CSP failure that prevents your document from loading at all is the one case nothing can deliver input into).
In short: every hosted game receives paired-controller input as a real DOM event, with zero
required integration work, the same way Escape-to-exit is automatic. (Mechanism, for the curious:
games-worker/src/inputShim.ts, injected into every served uploaded-game HTML document exactly
like the exit shim — games-worker/src/exitShim.ts.)
The sandbox / CSP posture — what a game cannot do
Your game runs in a cross-origin, sandboxed iframe: sandbox="allow-scripts" without
allow-same-origin, which gives it an opaque origin — no access to ShortPlay's cookies,
storage, or any other game's frame. On top of that, the served document carries a strict
Content-Security-Policy (games-worker/src/index.ts:119-142):
default-src 'none';
script-src <games origin(s)> 'unsafe-inline' 'unsafe-eval';
style-src 'unsafe-inline';
img-src <games origin(s)> data:;
connect-src <games origin(s)>;
frame-ancestors <the ShortPlay app + native shells>;
In practice this means:
- No external network calls.
connect-srconly allows the games origin itself — your game cannot fetch a third-party API, an ad SDK, or any external asset. If you need to talk to ShortPlay's own platform (scores, ladder results, events), that goes through the postMessage bridge (the SDK'sscore()/submitResult()/event()/save.*), notfetch. Saves: SDK → Saves. - Reading pixels back from your own images. Your game's requests carry
Origin: null, and the games origin answersAccess-Control-Allow-Origin: null. A plain<img>draws fine but taints the canvas (getImageData,toDataURLand WebGL texture uploads then throwSecurityError). Load such images withfetch()+createImageBitmap, or setimg.crossOrigin = 'anonymous'before settingsrc. Only your own bundled files are affected by this; the CSP above still blocks everything else. (games-worker/src/index.tsattestationCorsHeaders;app/tests/e2e/games-worker-null-origin-own-assets.spec.ts.) - No external stylesheets or images. Everything your game needs (art, fonts, styles) ships inside your zip.
- No reading other games or the host page. The opaque origin means
window.parentcannot be introspected, and no game can see another game's frame. - The SDK is not the security boundary. Even with the SDK imported, the host independently
validates every message by source identity + a per-mount nonce + strict schema — the SDK is
developer convenience, not something a game could use to bypass the sandbox
(
docs/07-architecture-and-build-plan.md:126-136).
Orientation, inputs, and how the feed uses them
See Overview → What the feed does with your manifest for the
device-fit-before-ranking summary, and the manifest table above for the full inputs/
orientation/designedFor/compatibleWith field definitions.