ShortPlay SDK
ShortPlay.game({ ... }) is REQUIRED for any lifecycle. Calling submitResult, score or event on its own does NOT register your game.
Calling submitResult alone leaves your game unbridged: no init, pause, resume or resize.
Importing the SDK is not enough: the SDK announces itself (a boot hello) when your script loads,
but the host sends your game NO lifecycle command (no build, onPause, onResume, onResize,
onFocus, onStop) until you call ShortPlay.game(...). A bundle that only reports scores is treated as
unbridged: it keeps running by itself, but ShortPlay can only stop it by tearing it down, so when the player exits the card shows its cover
instead of your last frame and the game restarts from scratch. The minimum registration:
import { ShortPlay } from './shortplay-sdk';
ShortPlay.game({
build(container, accent, { w, h }) { /* create your canvas inside `container`, size it w x h, start your loop */ },
onPause() { /* stop your loop; paint ONE paused frame */ },
onResume() { /* restart your loop where you left off */ },
onResize(w, h) { /* resize and repaint NOW (not sent while paused; sent on resume only if the size changed) */ },
});
Make onPause and onResume idempotent: strict alternation isn't guaranteed. A card first mounted as a
neighbour can get onResume with no onPause before it, and onPause can be followed by another onPause.
The full ordered sequence, and what each step needs from your game, is in Hosted-game contract → Lifecycle sequence. A copy-paste game that does all of it is below.
The SDK is optional and pre-1.0. ShortPlay never requires it to publish or to be featured —
the zero-integration baseline (an unmodified HTML5 bundle with no SDK) plays, and reflows when the
host resizes your iframe. Without the SDK it cannot be told to pause: when the feed suspends it (the player
exits full-play, or scrolls to another card) ShortPlay tears it down about 300 ms later and it restarts
when it is shown again. To get a real pause and resume you register with ShortPlay.game(...), which also
fires real visibilitychange/blur/focus events inside your frame whenever ShortPlay pauses or resumes you,
which includes backgrounding the app or locking the phone (when the app is backgrounded or the phone is locked, ShortPlay pauses your game; on return it stays paused until the player resumes, so declare ownsPauseUi: true and call ShortPlay.resumed() from your own pause menu's Resume;
handling visibilitychange yourself is optional,
Background and phone lock). This has been the explicit stance since the first ShortPlay game jam: "not
mentioned in the rules… the SDK is never required"
(docs/125-game-jam-1-plan.md:53-58,109). Import it if it helps you; don't feel obligated to.
What exists today
import { ShortPlay } from './shortplay-sdk';
ShortPlay.game({
build(container, accent, dims) {
// construct your game into `container` at the given accent color + { w, h }
},
onFocus(focused) {},
onPause() {},
onResume() {},
onResize(w, h) {},
onStop() {},
});
ShortPlay.score(1200); // live/ambient score (never reaches a ladder)
ShortPlay.event('level_complete', { level: 3 });
const result = await ShortPlay.submitResult({ score: 1200 }); // the ONE final result, for the ladder
ShortPlay.game(def)— register your game. Required for any lifecycle. Calling this sends areadymessage to the host; the host sendsinit(which calls yourbuild) and every later lifecycle command only after it has receivedready. Until you call it, the host doesn't know your game finished loading, and after about 9 seconds it treats the bundle as unbridged (see the lifecycle sequence). If your game registers late, after that point, it is upgraded to bridged the momentreadyarrives.ShortPlay.score(value)— report a live/ambient score to the platform (it never reaches a ladder; usesubmitResultfor that). Silently dropped if it's not a finite number or exceeds the platform's sanity bound — never throws.ShortPlay.submitResult({ score })— submit the ONE final result of this play session to your game's high-score ladder. Needs ascoringblock in your manifest. See High-score ladders below.ShortPlay.event(name, data?)— report an arbitrary named event with an optional small JSON payload (capped at 16 KB; an unserializable payload is dropped, not thrown).createShortPlay(options?)— the lower-level constructorShortPlayis built on; mainly useful for tests (it accepts an injectablewindow/root).- The lifecycle callbacks (
onFocus,onPause,onResume,onResize,onStop) — all optional, all described in full on Hosted-game contract → Lifecycle callbacks.
Full source, including the security model (source-identity + per-mount-nonce message
authentication — the SDK is DX, not the trust boundary):
app/games-sdk/shortplay-sdk.ts.
Reference game: pause, resume and resize while paused
A complete, copy-paste game that follows every rule in the
lifecycle sequence. It draws a moving ball on a
canvas, paints one "Paused" frame on onPause, keeps that frame on screen, repaints when it is resized
(the reference also repaints if a resize ever arrives while paused) without restarting its loop, and never replays its intro on resume. The docs build runs
this exact code block against the real SDK in a simulated frame and plays the exit and return sequence at
it, so it cannot drift from the SDK.
import { ShortPlay } from './shortplay-sdk';
let canvas: HTMLCanvasElement;
let ctx: CanvasRenderingContext2D;
let w = 0;
let h = 0;
let accent = '#ffffff';
let t = 0; // simulation time: advances only while the game is running
let paused = false;
let raf = 0;
let hasInputFocus = true; // input focus ONLY: it never starts or stops the loop
function paint(): void {
ctx.fillStyle = '#10131a';
ctx.fillRect(0, 0, w, h);
ctx.fillStyle = accent;
ctx.beginPath();
ctx.arc(w / 2 + Math.sin(t / 30) * (w / 3), h / 2, Math.min(w, h) / 10, 0, Math.PI * 2);
ctx.fill();
if (paused) {
ctx.fillStyle = '#ffffff';
ctx.fillText('Paused', 8, 16);
}
}
function frame(): void {
t += 1;
paint();
raf = requestAnimationFrame(frame);
}
function setSize(nw: number, nh: number): void {
w = nw;
h = nh;
canvas.width = w; // assigning width or height CLEARS the canvas, so always repaint after this
canvas.height = h;
}
ShortPlay.game({
build(container, accentColor, dims) {
accent = accentColor;
canvas = document.createElement('canvas');
container.appendChild(canvas);
ctx = canvas.getContext('2d') as CanvasRenderingContext2D;
canvas.addEventListener('pointerdown', () => {
if (hasInputFocus && !paused) t += 30;
});
setSize(dims.w, dims.h);
paint();
ctx.fillText('Ready!', 8, 32); // the intro: drawn ONCE, here, never on resume
raf = requestAnimationFrame(frame);
},
onPause() {
if (paused) return; // idempotent: the SDK also fires visibilitychange and blur
paused = true;
cancelAnimationFrame(raf); // do not rely on rAF: the browser may stop calling it
raf = 0;
paint(); // ONE paused frame; leave it on screen
},
onResume() {
if (!paused) return; // a resume can arrive without a pause before it
paused = false;
raf = requestAnimationFrame(frame); // continue from `t`: no intro, no restart
},
onResize(nw, nh) {
setSize(nw, nh);
paint(); // repaint for the new size NOW, whether running or paused
// A resize never resumes the loop. (ShortPlay sends none while paused; on resume you get one only if the size changed.)
},
onFocus(focused) {
hasInputFocus = focused; // false = "not the one receiving input". Keep running.
},
onStop() {
cancelAnimationFrame(raf); // teardown is imminent: stop, flush anything you must keep
raf = 0;
},
});
Self-check before you publish
Play this sequence at your own game, in a desktop browser and on a phone, and tick every line. Each line is a step from the lifecycle sequence.
- Registered. Your game calls
ShortPlay.game({ build, ... })andbuildruns. If you only ever see scores arrive butbuildnever runs, you are unbridged. - Exit to the feed. Enter full play, exit, and look at the card. Your last frame is still painted (a
paused frame, not blank, not black), and nothing animates. (Every hosted game that was played stays
mounted and paused until the page is left; Play resumes the same run, with no new
build.) - Resume at a new size. While paused, ShortPlay scales your last frame and sends no
onResize. Enter full play again: if the size really changed you getonResizefirst, thenonResume; it must show your game at the new size, not an empty canvas. - Return. Enter full play again. The game continues where it was: no intro, no restart, no lost score.
- Next card, and back. Scroll to the next card, then back. Same result as above.
- Game page. Open your game page, play, exit full play. Same result as "Exit to the feed".
- Preview. Open your
/preview/<token>link, play, exit full play (Escape works on desktop), play again. Same result as "Exit to the feed" and "Return":/previewruns the same lifecycle as the feed. - Background and lock. Switch apps and lock the phone with your game visible. ShortPlay sends
onPauseon hide andonResumeon return (only if you were running), so your loop and audio must stop and restart cleanly. A game you had already exited to the feed stays paused on return. - Not focused is not paused. While your game is visible in the feed but not the one the player is
interacting with, it keeps running (
onFocus(false)must not stop your loop). - Repeat three times. Exit and re-enter three times in a row; nothing accumulates (no duplicate loops, no doubled speed, no stacked listeners).
Console self-check: fire pause, resize and resume yourself
Run this in the top page's DevTools console (not the game frame's) on a page where your game is mounted: a
feed card, your game page or /preview/<token>. It posts the same three messages the shell posts, in the shape the
shell uses ({ sp: 1, nonce, type: 'lifecycle', cmd, dims? }, from app/src/runtime/bridge.ts:219-224,316-324; the SDK
accepts a message only from its parent window carrying the frame's own nonce, shortplay-sdk.ts:489,593). The
nonce is the #sp= part of the frame's src.
const frame = document.querySelector('[data-sandbox-host] iframe');
const nonce = /[#&]sp=([^&]+)/.exec(frame.src)[1];
const send = (cmd, dims) =>
frame.contentWindow.postMessage({ sp: 1, nonce, type: 'lifecycle', cmd, ...(dims && { dims }) }, '*');
send('pause'); // 1. your frame should freeze and show ONE paused frame
setTimeout(() => send('resize', { w: 320, h: 480 }), 1000); // 2. it should repaint at the new size, still paused
setTimeout(() => send('resume'), 2000); // 3. it should continue where it was: no intro, no restart
If you see several [data-sandbox-host] frames (a feed), pick yours with querySelectorAll and an index. This
works on a game that called ShortPlay.game(...); an unbridged game ignores all three. The shell may send its own
resize afterwards, which will put the size back.
High-score ladders: scoring and submitResult()
Status: live in production (the submit and read API, /v1/plays/token and /v1/ladders/*, is
deployed). The ladder UI (the player-facing leaderboard surfaces) is coming, so today a
submitted result is stored and ranked but there is not yet a ladder screen in the app that shows it.
Adopting it now means your game's results are already on the board when the UI lands.
1. Opt in with a scoring block in your manifest
A game with no scoring block has no ladder: submitResult() resolves refused: no_ladder. Declare
it in shortplay.json (or in confirmedManifest at upload):
{
"inputs": ["tap"],
"orientation": "portrait",
"scoring": { "order": "desc", "format": "points", "max": 100000, "label": "Distance", "minRunMs": 5000 }
}
| Field | Required | Meaning |
|---|---|---|
order |
yes | "desc" = higher is better, "asc" = lower is better (e.g. fastest time). |
format |
yes | "points", "time" (the raw value you submit is milliseconds) or "distance" (raw metres). The platform renders it; your game never formats. |
max |
no | Largest plausible raw value. A result above it is refused score_out_of_range. Absent means only the platform ceiling (an absolute value of 1e15). |
min |
no | Smallest plausible raw value (an asc time board has no 0 ms). A result below it is refused score_out_of_range. |
label |
no | Short column noun, 1 to 24 characters. |
minRunMs |
no | Integer 0 to 3,600,000. The server refuses a result from a play session younger than this (too_fast). Default 3000 when absent. |
The block is strict: no other keys, min must be at most max. At upload, a bad block found in the
shortplay.json inside your zip is silently dropped (no ladder; the upload still succeeds), while a bad
block in the confirmedManifest you send to /complete is refused with 400 manifest_invalid naming
manifest.scoring. The manifest field table in the
Hosted-game contract is generated from the same schema.
2. Call submitResult() once, at the end of a run
const r = await ShortPlay.submitResult({ score: metres }); // RAW value in your declared unit
if (r.status === 'posted') console.log(r.best, r.rank, r.isNewBest);
- When: once per run, when the run is over (game over, finish line). Once that call resolves
(any status), your next call is a new run, so an instant restart is fine. A second call while one is
still pending resolves
refused: already_submitted. Send the raw value in the unit yourformatdeclares; the platform handles ordering and display. - It never rejects. A problem is a returned value, so a ladder problem can never crash your game. You may ignore the result entirely.
- Return value (
RunResult):{ status: 'posted', playId, score, best, previousBest, isNewBest, rank, totalPlayers? }: a signed-in player.bestis their all-time best;rankis their all-time rank and isnullunless this was a new best (or they are past the rank cap).{ status: 'private', playId, score, best, previousBest, isNewBest, signInToPost: true }: an anonymous player. The result is kept privately against their device and never appears on a public board;signInToPosttells you signing in would post it.{ status: 'refused', code }wherecodeis one ofno_session(not running inside ShortPlay, e.g. local dev),no_ladder(noscoringblock),score_out_of_range,too_fast,already_submitted,rate_limited,board_stale(the game'sorder/formatchanged since the board was created),ladder_unavailable(offline, or no answer from the host within 15 seconds).
- What is kept: the best result per account per UTC day (and, for signed-in players, their best for all-time, week and month windows; weeks are UTC and Monday-aligned). Submitting a worse score than their best does not lower it.
- Not on the SDK path:
ShortPlay.score(value)is live/ambient and never reaches a ladder.
The first play session starts when the player enters focused full-play (the host mints a server play token
then; a game that is only previewing in the feed has no session, so a submit resolves no_session).
When a result settles, the host mints a fresh token for the next run. The clock runs from the token's
mint, so a run shorter than minRunMs (default 3 seconds) gets too_fast; for a restart run it counts from
the end of the previous result.
Sources for this paragraph: app/src/runtime/runResultRelay.ts.
Sources: app/games-sdk/shortplay-sdk.ts (submitResult, RunResult, LadderRefusal),
api/src/ladder.ts (parseScoring, DEFAULT_MIN_RUN_MS, the submit and board routes),
api/src/upload.ts (sanitizeScoring), data/seed.schema.json (manifest.scoring).
Saves: ShortPlay.save
Saves are opt-in and per game. Games run in an opaque-origin sandbox, so localStorage, cookies and
IndexedDB are not available to your code (see the sandbox posture); the SDK
brokers saves through the ShortPlay page instead. Declare a persistence
block in your manifest. Without it, getSync returns null, get/list reject with unavailable, and nothing
else about your game changes. You need a build made against the current SDK (see
Update the SDK in your game).
API
All of it lives on ShortPlay.save:
| Member | What it does |
|---|---|
ready |
Promise<void>. Resolves once the device snapshot is in your frame (it is delivered before build()), or once the platform has no saves for this game. Never rejects. |
getSync(key) |
Synchronous read of the in-frame copy. Returns the stored JSON value, or null. Valid after ready. |
get(key) |
Async read (a round trip to the platform). Resolves the value or null. Rejects invalid_key, or unavailable when no persistence is declared. |
set(key, value) |
Fire-and-forget. Updates the in-frame copy immediately; the platform persists it. Throws synchronously invalid_key, invalid_value (not JSON-serialisable) or too_large. |
delete(key) |
Fire-and-forget delete. Throws invalid_key for a bad key. |
list() |
Promise<string[]> of stored keys. |
checkpoint() |
A hint that this is a good moment to upload. The platform rate-limits it. Never throws. |
onError(cb) |
Subscribe to async problems (quota_exceeded, rate_limited, unavailable, ...). Returns an unsubscribe function. |
Keys match ^[A-Za-z0-9._:-]{1,64}$. Values must be JSON-serialisable. Save calls never throw into your game
for platform-side problems; those arrive through onError.
Minimal example
import { ShortPlay } from './shortplay-sdk';
await ShortPlay.save.ready;
const progress = (ShortPlay.save.getSync('progress') as { level: number } | null) ?? { level: 1 };
function onLevelCleared(level: number) {
ShortPlay.save.set('progress', { level: level + 1 });
ShortPlay.save.checkpoint(); // optional: a natural boundary
}
ShortPlay.save.onError((e) => console.warn('save problem:', e.code));
Limits
- Per key: manifest
maxKeyBytes, default 1 MiB, hard ceiling 4 MiB. A bigger value throwstoo_large. - Keys per game: manifest
maxKeys, default 32, hard ceiling 32 (the same as the server cap). - Write rate: the platform drops
setcalls past 60 per second per running game (burst 60) and reportsrate_limitedthroughonError. Save at moments that matter, not every frame. - Total per game: 10 MiB across all keys. The device enforces it too: a
setthat would take the game past 10 MiB is refused withquota_exceededthroughonError, and your earlier keys are untouched. - The server's limits for signed-in uploads are the same: 4 MiB per key, 32 keys and 10 MiB per game. If a cloud
upload is still refused for quota,
onErrorreportsquota_exceededonce for that key and the device copy is kept.
Where saves live, and offline behaviour
- Device first. Saves are kept on the player's device by the ShortPlay page (not inside your game's sandbox)
and survive a reload and relaunch. Writes are coalesced: the device copy can trail your
set()by about 2 seconds per key, and everything pending is flushed when the game pauses, exits or the page is hidden. Your game keeps working with no connection. - Signed in: the device copy is also uploaded to the player's account so progress follows them across
devices. Uploads happen on pause, exit and page hide, on
checkpoint()(at most once a minute), and at least every 5 minutes of play while something is unsaved. If the server refuses or is unreachable, the device copy is kept and the upload is retried with backoff. - Signed out: saves stay on the device and are never uploaded. When the player later signs in, that progress is merged into their account.
- Two devices disagree: the higher revision wins, otherwise the newer save. The losing copy is kept as a single backup on the device; there is no player-facing restore screen yet.
- Not verified on every platform. The device store uses IndexedDB in the ShortPlay page. If IndexedDB is unavailable the platform falls back to memory for the session: signed in, the cloud copy still works; signed out, saves last only until the page closes. We have not verified IndexedDB persistence on every native WebView, so test on your target devices before promising players their progress survives.
The SDK is bundled into your build
The SDK is not loaded from ShortPlay at runtime. You import it and your build bundles a copy into your game (see Distribution). A game only gets new SDK features, such as saves, when it is rebuilt against the current SDK and re-uploaded as a new version. Uploading a game built against an older SDK keeps the older behaviour, and publishing a new platform feature does not change games already uploaded.
To update: download the current SDK files again (listed under Distribution), replace the copies in your project,
rebuild, and upload the new zip (Zip format & versions). The SDK reports its own
version to the platform in the ready message as sdkVersion (currently 1.2.0), so a stale bundle is
visible to the platform; the field is optional in the protocol, so an older bundle may report none. The current SDK (1.2.0) includes ShortPlay.save,
ownsPauseUi and ShortPlay.resumed() (added in 1.2.0), so rebuild against the current files. There is no starter template
repo yet (Starter template), so there is no template-side update command; the SDK
files are copied into the project by hand.
Distribution (v0)
There's no npm package yet. For now, the SDK is a small set of copyable TypeScript files — this
docs site serves the exact same files the platform's own first-party games import. The entry point is
shortplay-sdk.ts; it imports three siblings by relative path, so
download all four into the same folder:
shortplay-sdk.ts,
shortplay-players.ts,
controllerInputDispatch.ts and
gamepadShim.ts. Then import the entry point by relative path, the same way
every ShortPlay first-party game does today, e.g.:
// app/games-dev/snake/v1/snake.ts:13
import { ShortPlay } from '../../../games-sdk/shortplay-sdk';
An npm package (@shortplay/game-sdk) is on the roadmap
(docs/07-architecture-and-build-plan.md:142) but not shipped — the copyable file above is the
real, current distribution path.
Roadmap (not yet available)
These are not shipped. Nothing on this site or in the SDK gives you access to them today — they're named here only so you know they're planned, not so you build against them:
- The ladder UI: player-facing leaderboard screens. The submit side is live (see High-score ladders); the screens that show it are coming.
- Multiplayer rooms.
- Phone-as-controller.
These are tracked on the eng-sdk-v1 roadmap line and drafted directionally (unratified) in
docs/128-sdk-multiplayer-device-wave.md — nothing there is available to build against yet.