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:
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).
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 (saving state, scores), that goes through the postMessage bridge (the SDK'sscore()/event()), notfetch. - 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.