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 — already pauses when it's swiped away and reflows when it grows, because ShortPlay drives the standard visibilitychange/blur/focus events and resizes the iframe. The SDK is an optional, progressive-enhancement layer on top of that baseline — never a requirement to publish.

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) describes this contract as build(container, accent, dims) returning an object — { pause(), focus(on), resize(w,h), stop() }. That is not what ships. 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; the wording fix to docs/07 is tracked separately.

Lifecycle callbacks

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. Paired with the host also dispatching a real visibilitychange/blur, so an unmodified game (no SDK callback registered) still pauses.
onResume() Resume simulation. Symmetric to onPause — the host also dispatches real visibilitychange/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).

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
reservesEdgeInput boolean no gameplay uses edge/horizontal gestures -> exit gesture must migrate off the edge
escExit boolean no hosted bundle honors Escape while its iframe has focus and requests full-play exit; absent/false means Escape must not be advertised
pauseBehavior enum: pause, keep-running no
designedFor array of enum: phone, tablet, desktop, tv no
compatibleWith array of enum: phone, tablet, desktop, tv no
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. Two fields are worth calling out:

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.