Skip to content
ShortPlay Developer Docs

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:

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

What the frame is 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:

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:

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:

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:

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.