Skip to content
ShortPlay Developer Docs

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

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.

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

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

Where saves live, and offline behaviour

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:

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.