# OGS for game developers > How to make a web game OGS-compatible: the OGS app casts once, a TV launcher frames each game's TV page, phones play its phone page, and the game learns who is playing through profile-kit. # Build a game for OGS OGS (Open Game System) puts web games on the living-room TV. A grown-up casts **once** from the OGS app; after that the TV shows one page all evening, the **TV launcher**, and every game plays inside it. Phones and iPads join the couch and play each game's phone page. Your game stays an ordinary web game: it gets a few `postMessage`s and a small library, and it keeps working in a plain browser. > **Building with a coding agent?** Point it at [the Quickstart](https://ogs-docs.pages.dev/quickstart.md) or at > `https://ogs-docs.pages.dev/llms.txt`. Every page here is also plain Markdown: add `.md` to its URL. ## How an evening works 1. **Cast once.** The host opens the OGS app and casts to the TV (Chromecast, or any browser on the TV). The TV shows the launcher and a 6-character **TV code**. 2. **Phones join the couch.** Everyone else joins with the TV code. Each person has an OGS profile: a name and an avatar. 3. **The launcher frames your game.** When someone picks your game, the launcher loads your **TV page** in an iframe and tells it who is on the couch. Swapping games never recasts. 4. **Phones open your phone page.** The app opens your game's `startUrl` in a WebView. Your page asks profile-kit who is playing and skips its own name form. 5. **Home parks your game.** When the couch goes Home or switches games, the launcher keeps your frame loaded but parked, and tells you to go silent. Continue brings the same frame back instantly. ## Your two pages | Surface | Where it runs | What it does with OGS | |---|---|---| | **TV page** | In an iframe filling the TV launcher (test it at 1280×720 and 1920×1080) | Hears `ogs:start` (who's on the couch, a game token), `ogs:suspend` / `ogs:resume` (parked or back); may report the sitting's label | | **Phone page** (`startUrl`) | In the OGS app's WebView on phones and iPads | Reads the OGS profile (name, avatar, game token) through the app bridge; reports the sitting's label | A game with one static TV page lists it as `tvUrl` in its manifest. A room-based game (each room has its own TV URL) declares the room's TV page from the phone page at runtime. Either way the game never casts: OGS does. ## What your game gets - **Who's playing.** On the phone: the player's OGS name, avatar and a **game token**. On the TV: the whole couch (`players`) and a game token for the session. No sign-up, no name form. - **Verified identity.** Game tokens are ES256 JWTs signed by OGS for your game only (`aud` = your appId). Your server checks them with one call, `verifyOgsToken`. - **Sitting labels.** Report "Mission 6" or "Room KQTP" and the OGS app shows it in Playing, so two sittings of your game read apart. - **Pause and resume.** A clear signal when the TV parks your game and when it comes back, so you can stop the music and keep state. - **Casting handled.** No cast SDK, no cast button, no receiver app, no join codes on the TV. - **Several couches in one room** (optional, `multiCouch`): households in different homes play one room of your game. ## What OGS asks of you The short version of [the rules](https://ogs-docs.pages.dev/rules.md): - No cast button and no room code or join QR on an OGS TV. - Nothing covers the TV's focal area; stay inside the safe area. - Silent while parked. - Still playable in a plain browser, where every profile-kit call returns `null` or does nothing. ## Where to go next - [Quickstart for agents](https://ogs-docs.pages.dev/quickstart.md): the step-by-step checklist. - [The game contract](https://ogs-docs.pages.dev/contract.md): the single source of truth. - [profile-kit reference](https://ogs-docs.pages.dev/profile-kit.md) and [Messages and manifest](https://ogs-docs.pages.dev/messages.md): exact APIs. - [Testing your game](https://ogs-docs.pages.dev/testing.md), [Art kit and catalogue](https://ogs-docs.pages.dev/art-and-catalogue.md). --- # 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: ```text 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`): ```bash cd open-game-system pnpm install pnpm --filter @open-game-system/profile-kit build cd packages/profile-kit && pnpm pack --pack-destination /vendor ``` In your game's `package.json`: ```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 no `Content-Security-Policy: frame-ancestors` that 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 ``, ); const frame = await (await page.waitForSelector("#game")).contentFrame(); if (!frame) throw new Error("no game frame"); await expect.poll(() => states(frame), { timeout: 15_000 }).toEqual(["running"]); const post = (msg: object) => page.evaluate((m) => { const el = document.getElementById("game"); if (el instanceof HTMLIFrameElement) el.contentWindow?.postMessage(m, "*"); }, msg); await post({ type: "ogs:suspend" }); await expect.poll(() => states(frame)).toEqual(["suspended"]); await page.waitForTimeout(500); expect(await states(frame)).toEqual(["suspended"]); // nothing (a music loop) wakes it await post({ type: "ogs:resume" }); await expect.poll(() => states(frame)).toEqual(["running"]); await page.close(); }, 30_000); }); ``` Add the same shape for the rest of the TV contract: - **`ogs:start` names the couch.** Post `{ type: "ogs:start", instanceId: "i1", mode: "new", roster: [], token: "", players: [{ id: "p1", handle: "sam", name: "Sam", avatar: "https://example.com/a.png" }] }` and expect `frame.getByText("Sam")` to be visible. Also assert the room code, join QR and cast button are gone. - **`ogs:ready` is said.** Before loading the iframe, collect what the frame posts to the parent: `page.exposeFunction("onGameMessage", …)` plus a `message` listener in the parent page. Expect an `{ type: "ogs:ready" }`, then answer it with `ogs:start`. - **Your sitting label comes back.** After `ogs:start`, expect an `{ type: "ogs:instance", report: { title: "…" } }` from the frame. - **No full-screen button when framed.** The launcher already fills the TV: `expect(await frame.getByRole("button", { name: /full screen/i }).count()).toBe(0)`. The same page opened directly (`page.goto(TV_URL)`) may keep its button. - **Test at TV sizes.** Run the framed page at 1280×720 and 1920×1080 and check that nothing important sits outside the 5% safe area or over the focal area. ## 2. The phone page in a fake WebView The OGS app injects `window.ReactNativeWebView` and answers the page's `BRIDGE_READY` with each store's state (`STATE_INIT`). Fake it with an init script, and give the `profile` store a ready profile whose `token` your test signed: ```ts const fakeWebView = (stores: Record) => ` window.__ogsSent = []; window.ReactNativeWebView = { postMessage(raw) { const msg = JSON.parse(raw); window.__ogsSent.push(msg); if (msg.type === "BRIDGE_READY") { setTimeout(() => { for (const [storeKey, data] of Object.entries(${JSON.stringify(stores)})) window.dispatchEvent(new MessageEvent("message", { data: JSON.stringify({ type: "STATE_INIT", storeKey, data }), })); }, 50); } }, }; `; const ctx = await browser.newContext({ viewport: { width: 390, height: 844 } }); await ctx.addInitScript( fakeWebView({ profile: { status: "ready", profile: { id: "p1", handle: "sam", name: "Sam", avatar: "https://example.com/a.png", token }, }, }), ); const page = await ctx.newPage(); await page.goto(`${BASE}/`); await expect(page.getByLabel("name")).toHaveCount(0); // no name form inside OGS ``` - **Signing test tokens.** Generate an ES256 key pair with Web Crypto, serve its public JWK at `http://localhost:8831/.well-known/jwks.json` from the test (`node:http`), and start your server with that `jwksUrl` (for a Worker: `wrangler dev --var OGS_JWKS_URL:http://localhost:8831/.well-known/jwks.json`). Sign tokens whose claims match [`GameToken`](https://ogs-docs.pages.dev/messages.md#gametoken) with `aud` = your appId. - **Assert the server's view**, not just the page: the player is seated under the token's `name`, a token for another `appId` or an expired one is not. - **Sitting reports** arrive in `window.__ogsSent` as `{ type: "EVENT", storeKey: "ogs", event: { type: "INSTANCE_REPORT", report } }`. Give the fake an `ogs` store too (`ogs: { reported: [] }` next to `profile`): profile-kit waits for the store before it dispatches. ## 3. Still a plain-browser game OGS games must keep working without OGS. Keep (or add) one end-to-end test that plays a short round with no fake bridge and no frame: the name form appears, the TV page shows its own join code, and the round finishes. profile-kit returns `null` and does nothing there, so this mostly guards your own `if (inOgs)` branches. ## 4. Unit tests for your own logic Keep the OGS decisions in small pure functions and test them directly: "given this profile snapshot, show the form or join?", "given this phase, what is the sitting label?", "paused: which sounds stop?". profile-kit's lower-level functions take their bridge and window as arguments (`createSessionSource({ win })`, `createProfileSource({ bridge })`), so you can drive them with fakes. ## 5. In the real app Last, once the seams pass: run the game in the OGS app against the real launcher. Check that phones skip the name form, the TV names the couch, Home silences the TV, Continue brings it back with sound, and the Playing tab shows your label. Say which you did: tests passing, deployed, or verified on a real TV. --- # Art kit and catalogue Every game in the OGS catalogue ships an **art kit**: four images the app and the TV launcher use for the Library, the game page and the launcher's Home. Then it is listed by adding its manifest to the catalogue. ## The art kit | Field | Image | Shape | Size we use | Rules | |---|---|---|---|---| | `art.icon` | Icon | 1:1 | 512×512 PNG | The game at a glance; readable at 48 px | | `art.cover` | Cover | 2:3 | 600×900 JPG | **With the title** lettered in | | `art.logo` | Logo | any, transparent | about 1200 px wide PNG with alpha | The title alone, on transparency | | `art.heroClean` | Clean hero | 16:9 | 1920×1080 JPG | **No text and no HUD**: the launcher draws its own title and buttons over it | Older fields, still in the manifest: - `art.tile` (required): a 16:9 screenshot of the TV page (1920×1080). - `art.hero`: another 16:9 screenshot. - `art.safe` (`scale`, `ox`, `oy`): crops a HUD out of `tile`/`hero` when there is no clean art. Taste, for every image: - Your game's **own art direction**. Don't borrow another OGS game's look. - **No faces on objects** (rockets, planets, props). Characters may have faces; things don't. - No UI, no buttons, no score in `heroClean` and `icon`. - Check the kit next to the other games: the catalogue's contact sheet is [apps/tv/public/art/KIT-SHEET.jpg](https://github.com/open-game-system/open-game-system/blob/main/apps/tv/public/art/KIT-SHEET.jpg). The images live in the OGS repo at `apps/tv/public/art//` and the manifest refers to them by path from that folder's root: ```json "art": { "icon": "/art/space-bakery/icon.png", "cover": "/art/space-bakery/cover.jpg", "logo": "/art/space-bakery/logo.png", "heroClean": "/art/space-bakery/hero-clean.jpg", "tile": "/art/space-bakery/tv.jpg" } ``` ### Theme music (optional) `art.theme` is a 20–40 s seamless loop of your game's music (music only: no effects or voice). The launcher's Home plays it quietly while your game is focused and crossfades as focus moves; every other screen is silent. Ship MP3 (~128 kbps; every browser and the cloud renderer decode it), loudness around −20 dB with peaks below −1 dB, and fade the seam so the loop is gapless, e.g. `"theme": "/art/space-bakery/theme.mp3"`. No theme means Home is silent on your game. ## Submit to the catalogue The catalogue is the list of manifests in [services/api/src/catalogue.ts](https://github.com/open-game-system/open-game-system/blob/main/services/api/src/catalogue.ts), served at `GET /api/v1/catalogue`; the app's Library reads it from there. There is no self-serve form yet: games are added by pull request to [open-game-system/open-game-system](https://github.com/open-game-system/open-game-system). 1. **Red:** add your `appId` to the expected list in [services/api/test/catalogue.test.ts](https://github.com/open-game-system/open-game-system/blob/main/services/api/test/catalogue.test.ts) (`GAMES`), plus whatever your game does differently from the defaults that test assumes: your own domain (`OWN_DOMAIN`), a game for grown-ups only (`ADULT_GAMES`; otherwise a role must suit a kid), or a static `tvUrl`. Run `pnpm --filter @open-game-system/api test` and see it fail. 2. **Art:** add the four images (and `tv.jpg`) under `apps/tv/public/art//`. The catalogue test fails if any kit field is missing or a file does not exist. 3. **Green:** add your manifest to `SEED` in `services/api/src/catalogue.ts`. Every field is checked by `ManifestSchema` ([fields](https://ogs-docs.pages.dev/messages.md#manifest)). 4. Run the repo's gates: `pnpm typecheck && pnpm lint && pnpm test`. 5. Open the pull request. Say in it which of the [rules](https://ogs-docs.pages.dev/rules.md) you checked and how (tests, a real TV), and link your game's seam tests. Don't add games to `apps/mobile/services/game-directory.ts`: that is the old static list. For local development, the API's `CATALOGUE_START_URLS` (JSON `{ "appId": "url" }`) swaps in local `startUrl`s for games already in the catalogue; it never adds games. --- # Rules and taste What every OGS game does, and why. The binding list is [§5 of the contract](https://ogs-docs.pages.dev/contract.md#5-rules); this page explains each rule and how to check it. ## 1. No cast button OGS casts once for the whole evening; the launcher frames your game. A game never starts a cast, never shows a cast button or a "cast to TV" prompt, and never ships a cast receiver for OGS. Inside the OGS app the app ignores a game's own cast actions anyway. **Check:** search the phone page for a cast button and remove it; room games keep only `useCastViewUrl` (see [Quickstart, step 8](https://ogs-docs.pages.dev/quickstart.md#8-room-games-declare-the-tv-page-from-the-phone)). ## 2. No join codes on an OGS TV People join the couch with the launcher's TV code; phones already land in your game. On an OGS TV, show no room code, no join QR, no "join at …" URL. Outside OGS (a plain browser) keep them: that is how people join there. **Check:** framed after `ogs:start` there is no code or QR; opened directly there is. > Planned ([contract §8](https://ogs-docs.pages.dev/contract.md#8-joining-and-invites-planned)): the launcher will draw its own > join QR and an invite card in one corner, and a game may ask for it with `ogs:invite`. Keep that > corner (top-right by default, at most 220×120 px on a 960×540 reference with a 24 px margin) free of > your focal area and HUD. ## 3. Nothing over the focal area The TV is the shared screen everyone watches from the sofa. Whatever the game is about right now (the board, the character, the question) stays uncovered: HUD, scores and toasts live at the edges, small and brief. **Check:** screenshots of every phase at 1280×720 and 1920×1080; nothing overlaps the focal element. ## 4. Inside the safe area TVs crop their edges. Keep every element that matters at least **5%** in from each edge of the TV page, and remember that a focus or hover `scale()` grows an element past that line: scale it away from the edge. **Check:** at 1280×720, 1920×1080 and 3840×2160, every visible element's bounding box is inside the 5% inset. ## 5. Silent while parked Home or another game parks your frame: it stays loaded so Continue is instant, so its sound keeps playing unless you stop it. On `ogs:suspend` (`onOgsPause(true)`) suspend the `AudioContext`, pause media, and stop anything that would start a sound. On `ogs:resume` resume only what was playing. **Check:** the [pause seam test](https://ogs-docs.pages.dev/testing.md#1-the-tv-page-in-a-stand-in-launcher). ## 6. Starts without a tap There is no pointer or keyboard on the TV. The TV page starts, plays sound and animates on load, and is driven entirely from the phones. No "click to start", no full-screen button when framed. ## 7. Still a plain web game Without OGS the game is complete: its own name form, room code and TV link, and full play end to end. Every profile-kit call returns `null` or does nothing there, so keep your own path behind those `null`s. **Check:** the [plain-browser test](https://ogs-docs.pages.dev/testing.md#3-still-a-plain-browser-game). ## 8. Trust only verified tokens Use `verifyOgsToken` on your server for anything that matters: seats, scores, names other people see. A game sees only **game tokens for itself**, never the app's or the launcher's own token. A token tells you a profile id, handle, name and avatar, and on a TV the couch: never friends, other games, device ids or age. Don't ask for more. ## 9. Phones: the OGS profile replaces your name form Inside the OGS app, join under the OGS name and avatar without a form, and hide your own join-code entry. Join once per seat; the app refreshes the token and a new token must not join again. ## Taste - Your game's own look: don't copy another OGS game's art direction. - No faces on objects (rockets, planets, props). - Big, legible type on the TV, read from a sofa three metres away. - Phones and iPads are controllers: big targets where thumbs rest, near the bottom edge.