Quickstart for agents
A checklist a coding agent (Claude Code, Codex, Cursor…) can follow end to end to make a web game OGS-compatible, new or existing. Each step says what to change and how to know it is done. Work test-first and commit after each green step.
Prompt for your agent
Copy this into your agent, in the game's repository:
Make this web game OGS-compatible. Follow the step-by-step checklist at
https://ogs-docs.pages.dev/quickstart.md and treat https://ogs-docs.pages.dev/contract.md as the
contract (if the two disagree, the contract wins). APIs: https://ogs-docs.pages.dev/profile-kit.md and
https://ogs-docs.pages.dev/messages.md. Everything in one file: https://ogs-docs.pages.dev/llms-full.txt
Rules: the game never casts and shows no cast button; on an OGS TV it shows no room code, join QR or
"join at" URL; nothing covers the TV's focal area; the TV page goes silent on ogs:suspend; the game
still works in a plain browser. Work test-first: write the seam tests from
https://ogs-docs.pages.dev/testing.md before the code they cover. Tell me which steps are done,
which tests prove each one, and anything you could not verify.
Before you start
You need:
- A web game with a page for the TV and a page for phones. One page can be both if it changes layout, but most OGS games have a separate TV page (big shared screen) and phone page (controller).
- Node 20+ and pnpm. A server is optional; you need one only to trust names and seats (step 6).
- A local clone of the OGS repo for profile-kit (step 1):
git clone https://github.com/open-game-system/open-game-system.
Pick an appId now: lowercase letters, digits and hyphens ([a-z0-9-]+), for example space-bakery.
It names your game everywhere: the catalogue, the art folder and the aud of your game tokens.
The checklist
1. Install profile-kit
profile-kit is not on npm yet. Build it in the OGS repo and pack a tarball into your game (it bundles
ogs-protocol and the app bridge; it needs zod and, for the hooks, react):
cd open-game-system
pnpm install
pnpm --filter @open-game-system/profile-kit build
cd packages/profile-kit && pnpm pack --pack-destination <your-game>/vendor
In your game's package.json:
"dependencies": {
"@open-game-system/profile-kit": "file:vendor/open-game-system-profile-kit-0.1.0.tgz"
}
Then pnpm install. After re-packing a newer build under the same file name, use
pnpm install --force.
Done when import { onOgsPause } from "@open-game-system/profile-kit" type-checks.
2. Make the TV page frameable
The launcher loads your TV page in an iframe with allow="autoplay; fullscreen".
- Do not send
X-Frame-Options: DENY/SAMEORIGIN, and noContent-Security-Policy: frame-ancestorsthat excludes the launcher. - Start without a tap: there is no pointer on a TV. Start audio and animation on load.
- Hide any full-screen button when framed (
window.parent !== window); the launcher already fills the TV.
Done when a plain HTML page with <iframe src="YOUR_TV_URL"> shows your TV page playing.
3. TV page: hear the launcher
Create the session source at module load of the TV entry (the launcher posts ogs:start as soon as
the frame loads; profile-kit says ogs:ready so a missed start is re-sent).
// tv entry (module scope)
import { getOgsSessionSource, onOgsPause } from "@open-game-system/profile-kit";
getOgsSessionSource(); // start listening now
onOgsPause((paused) => audio.setPaused(paused)); // ogs:suspend → true, ogs:resume → false
In React, read the couch with the hook:
import { useOgsSession } from "@open-game-system/profile-kit/react";
function TvPlayers() {
const session = useOgsSession();
if (session === undefined) return null; // waiting for the launcher (≤ 300 ms)
if (session === null) return <JoinCodeAndQr />; // not on an OGS TV: keep your own join UI
return <Seats players={session.players} />; // { id, handle, name, avatar }[]
}
ogs:start→sessionwithplayers,token(a game token, or""),instanceId,mode("continue"or"new"), androomfor multi-couch games.ogs:suspend→onOgsPause(true): go silent. Suspend yourAudioContext, pause every<audio>/<video>, stop timers that make sound. Keep the game state; the frame stays loaded.ogs:resume→onOgsPause(false): resume only what was playing before.
A safe audio gate (suspends the shared context on pause, resumes it on Continue only if it was running, and keeps a sound unlocked during the pause silent):
export function createAudioPause(getCtx: () => AudioContext | null) {
let paused = false;
let parked: AudioContext | null = null;
return {
setPaused(next: boolean) {
if (next === paused) return;
paused = next;
if (paused) {
const ctx = getCtx();
if (ctx?.state === "running") { parked = ctx; void ctx.suspend(); }
return;
}
const ctx = parked;
parked = null;
if (ctx?.state === "suspended") void ctx.resume();
},
paused: () => paused,
};
}
Done when the seam test in Testing passes:
every AudioContext is suspended after ogs:suspend and running after ogs:resume, and the TV
names the players from ogs:start.
4. TV page: no join UI inside OGS
When useOgsSession() (or getOgsSessionSource().getSnapshot()) is a session, hide your room code,
join QR, "join at" URL and cast button. OGS draws its own TV code. When it is null (a plain browser),
keep them: the game must still be joinable on its own.
Done when the framed TV page shows no code or QR after ogs:start, and the unframed page still does.
5. Phone page: use the OGS profile
import { useOgsProfile } from "@open-game-system/profile-kit/react";
function Join() {
const profile = useOgsProfile();
if (profile === undefined) return <p>Joining…</p>; // asking the app (≤ 300 ms, ≤ 5 s while it fetches)
if (profile === null) return <NameForm />; // plain browser: your own form, unchanged
// In the OGS app: join at once under the OGS name and avatar, and send the token to your server.
return <AutoJoin name={profile.name} avatar={profile.avatar} token={profile.token} />;
}
- Join once per seat. The app refreshes the token before it expires; a new token must not join again.
- Hide your join code entry: OGS puts the phone in the right game already.
- No React?
getOgsProfileSource()is the same store:getSnapshot()andsubscribe(listener).
Done when in a fake WebView (see Testing) the page joins without a form under the OGS name, and in a plain browser the form is unchanged.
6. Server: verify game tokens
Anything that matters (a seat, a score, a name other players see) must come from a verified token.
import { verifyOgsToken } from "@open-game-system/profile-kit/server";
const claims = await verifyOgsToken(token, { appId: "space-bakery", jwksUrl: env.OGS_JWKS_URL });
if (!claims) return joinAsGuest(); // bad signature, other game, expired, or no token: the plain-browser path
seat({ id: claims.sub, name: claims.name, avatar: claims.avatar, couch: claims.couch?.sid });
- It checks the ES256 signature against OGS's JWKS,
aud === appId, and expiry; anything else isnull. - Make
jwksUrlconfiguration (a Worker var such asOGS_JWKS_URL), so seam tests can serve a local key set. Point it at the OGS API's/.well-known/jwks.json. - In the page,
readGameToken(token)only decodes (for display). Never trust it for anything else.
Done when a test signs a token with a local key and your server seats that player, and rejects a
token for another appId and an expired one.
7. Report the sitting's label
import { reportOgsSitting } from "@open-game-system/profile-kit";
reportOgsSitting({ instanceId, appId: "space-bakery", status: "active", title: "Mission 6" });
Call it whenever the label changes, from the TV page, the phone page, or both: in the app's WebView it
goes over the app bridge, on the TV to the launcher, in a plain browser nowhere. On the TV use the
instanceId from ogs:start. status is one of lobby, active, suspended, waiting,
completed, expired.
Done when the TV seam test receives an ogs:instance message with your title.
8. Room games: declare the TV page from the phone
Skip this if your manifest has a static tvUrl. If each room has its own TV URL, the host's phone
page declares it while inside OGS (cast-kit, packed from the OGS repo like profile-kit):
import { isOGSCastAvailable } from "@open-game-system/cast-kit-core";
import { CastProvider, useCastViewUrl } from "@open-game-system/cast-kit-react";
function DeclareTv({ tvUrl }: { tvUrl: string }) {
useCastViewUrl(tvUrl); // the app forwards it to the launcher, which frames it
return null;
}
export function HostPanel({ tvUrl }: { tvUrl: string }) {
const inOgs = useMemo(() => isOGSCastAvailable(), []);
return inOgs ? <CastProvider><DeclareTv tvUrl={tvUrl} /></CastProvider> : <OwnTvLinkAndQr />;
}
No <CastButton>: the app ignores a game's own cast actions.
9. Rooms: the couch's phones, and other homes
A game with rooms: call reportOgsRoom(roomCode) from the TV page when the room exists, and on the
phone page join ogsRoomFromUrl(location.href) instead of making a room when it is not null. Every
phone on the couch follows the TV: the one that starts the game makes the room, the others open your
start page with ogsRoom=<room> once the TV page reported it (no report, no followers).
Several homes in one room (optional): only if households in different homes should play one room
together. Set multiCouch: true in the manifest, join session.room instead of creating a room when
ogs:start carries one, and group players by claims.couch.sid. Details:
the contract, §7.
10. Art kit and manifest
Make the four images and write the manifest: see Art kit and catalogue. The manifest's fields are in Messages and manifest.
11. Test it
Write the seam tests from Testing your game: the TV page in a stand-in launcher, the phone page in a fake WebView, no full-screen button when framed, and the plain-browser check. Then run the whole game end to end in a plain browser once more.
12. Submit to the catalogue
Open a pull request to the OGS repository with your manifest and art kit: Art kit and catalogue.
Done when
- The seam tests pass, and the game plays end to end in a plain browser.
- In the OGS app: no name form on phones, the TV shows the couch's names, Home silences the TV, Continue brings it back with sound, and Playing shows your sitting label.
- Nothing on the OGS TV shows a cast button, a room code or a join QR.