Skip docs navigation
Public Examples
Tutorial
Component-driven host and controller surfaces with explicit hook cleanup around TPG shared state.

React Game Tutorial

Build React Prompt Relay from an empty Vite project into a playable registered TPG game. This tutorial uses the complete checked-in React example without relying on private web-shell imports.

What You Build

React Prompt Relay has two surfaces:

  • a host-display surface in examples/react-game-repo/src/host.tsx
  • a controller surface in examples/react-game-repo/src/controller.tsx

The organizer controller owns prompt advancement and room controls. The TV is a passive display. Every controller submits one answer per prompt. Shared prompt facts travel through api.setSharedState(), while each controller's answer travels through api.setPlayerState().

Create the Project

Start with the canonical TP Games project, then add React. This preserves the tested scripts, CI workflow, validation, and bundled AI development skill:

The starter uses Vite 7; React plugin 5 is its compatible major. Keep these versions aligned when upgrading the example.

If the author CLI is already installed, tpgames init react-prompt-relay replaces the first scaffold command.

This tutorial intentionally replaces the starter surface layout with the checked-in React example. Copy the example's root host.html and controller.html in place of host.html and controller.html, then remove vite.config.mjs before creating the vite.config.ts shown below. Do not keep both Vite config files. Copy the example's entire src directory, including its view and store modules; the sections below explain how those modules fit together.

Keep host and controller as separate browser entries so the registry manifest can point each TPG surface at its own HTML file:

Configure the Author Tools

Add the physical-device, bundle, and registry commands to package.json:

Keep React's plugin and add the TPG workbench in vite.config.ts:

Create tpgames.json; both the bundle command and device manifest use this identity:

Model Shared and Player State

Put serializable game state in src/game.ts. Shared state should contain facts every surface needs, and player state should contain facts owned by one controller.

React state is still useful for local UI, but store React component state separately from TPG shared state. Inputs, optimistic labels, and temporary status messages can stay in useState; room facts that the host, controllers, and future spectators must agree on belong in the runtime API.

Boot the Runtime

Both surfaces use bootIframeGame() from @tpgames/game-kit. The helper owns the iframe postMessage bridge setup, infers the shell/referrer origins, and then boots @tpgames/runtime-game with the surface context.

Each iframe runs its own game definition. The organizer uses the repeatable surfacesReady readiness hook to initialize only missing shared state; renderers react to the later subscription echo instead of assuming a setter updates local getters immediately.

Each surface passes a defineSimpleGame() implementation to bootIframeGame(). The host uses a host-display context; controllers use a controller context. Game authors do not need to import or instantiate the postMessage bridge directly for normal iframe surfaces.

Keep the TV Passive

src/host.tsx subscribes to shared prompt state, participants, and player answers. It renders PromptRelayHost from src/host-view.tsx, with no room controls and no shared-state writes. Filter answers by answer.round === sharedState.round so a new prompt starts with an empty answer board.

Initialize and Advance from the Organizer

The controller subscribes to api.subscribeContext() and uses api.context().isAuthority for organizer controls. Authority can change while the iframe is running. Do not infer it from participant order or a hard-coded surface.

The complete src/controller.tsx keeps a pending-round latch, checks authority, and initializes only after started when shared state is missing. It passes api.getSharedStateSnapshot().revision as expectedRevision when publishing the next prompt. Repeated lifecycle events cannot advance two rounds, and an obsolete writer cannot overwrite a newer prompt. Only the shared-state subscription updates the rendered prompt; an accepted mutation acknowledgement does not mean the local getter has received its new value yet.

Submit and Confirm Player Answers

Game frames do not allow native form submissions. Use an explicit button and handle Enter on the labeled input; do not rely on a form submit event inside the sandbox.

The controller view owns the text input and receives callbacks for submit, settings, and organizer controls. Each answer includes the current round:

Show a sending state until the player's own subscription confirms the answer. If result.status is rejected or the request fails, show an actionable error and allow a retry. Do not display “Answer saved” before the confirmed state arrives. Key the input view by round so the next prompt starts with an empty input.

The runtime sends a controller only its own player-state updates; host-display and logic surfaces remain privileged readers, while spectators receive none. Still check participantId before updating local UI so the component keeps an explicit ownership boundary.

api.openSettings() opens the player's shell settings. Only the authority controller gets api.endGame() and api.returnToLobby() controls; the shell also enforces that boundary. The example's pure views can be rendered independently with injected state and callbacks.

Keep Rendering Separate from the Runtime

src/store.ts provides a small external store and a useSyncExternalStore hook. Each surface owns one store and connects runtime subscriptions once, outside React rendering. React subscribes through the store and automatically removes its listener when the view unmounts. Keep the runtime bootstrap outside React Strict Mode effects so repeated component renders do not reattach it.

The host-view.tsx and controller-view.tsx components receive state and actions through props. Render those components directly for deterministic loading, organizer, player, sending, saved, and rejected stories. An iframe reload creates a new runtime and receives the room's canonical state.

Add the Manifest

Create bundle/manifest.template.json with one host-display surface and one controller surface:

Keep the manifest entries aligned with the files emitted into dist/. The registry serves those entries directly when the shell launches the published game.

Test on Multiple Devices Without Uploading

The example's @tpgames/sdk-dev-kit Vite plugin exposes both surfaces to the normal TPG room flow. Install cloudflared, then run:

The command starts Vite and a temporary Cloudflare Quick Tunnel, then opens the host room on https://play.tp.games. Scan the room QR with two or more phones to test real controller identity, transport, and input against your current source. No registry upload, API key, or bundle build is required.

To use a tunnel you already manage, keep its origin port aligned with the CLI:

A Quick Tunnel exposes the local Vite server and source modules publicly. Treat the generated host link as private because it contains the temporary development-manifest descriptor; share only the room QR or controller invite. Keep .env files, credentials, and private assets outside Vite's served root, and stop the command and tunnel when finished. Use --no-open when you only want the private host link printed.

Bundle the Game

The example uses the public tpgames bundle command to render the manifest and zip the Vite build from its declarative tpgames.json contract.

The output should include:

Register and Publish

For local interactive testing, start the registry and browser approval shell from a TPG repository checkout in two terminals:

Then register from the React example repo:

The registration script follows the same service path automation should use:

  1. upload the zip to /creator/uploads
  2. submit the draft version through /creator/games/:gameId/versions/:version/submit
  3. publish it through /creator/games/:gameId/versions/:version/publish
  4. fetch the published manifest to verify launch metadata

Use the default upload mode to stop after upload, --mode submit to stop after review submission, or --mode publish --yes for the full publish path.

Registration delivers a built game version; it does not create a developer account. Browser login stores a session only for the exact registry origin. Use TPG_API_KEY only for CI or another unattended environment, keep it in the environment rather than arguments or project configuration, and grant only the scopes required by the selected mode.

You can also upload the zip through the Creator Portal, inspect the draft, submit it for review, and publish the version through the portal. Reviewer approval remains available for manual review flows.

Test Locally

Use npm run dev for the multi-surface workbench while iterating, then use npm run dev:devices for a real room without uploading. Run the bundle and registration scripts against the local registry before treating the game as publishable. A complete React game is ready when the shell can discover it, launch the host and controller surfaces, accept controller answers, advance to a new prompt, and return to the lobby without importing private shell modules.