Three.js Game Tutorial
Build Three Signal Orbit from an empty Vite project into a playable registered TPG game. This tutorial includes the complete implementation using the renderer-neutral @tpgames/game-kit package and Three.js.
What You Build
Three Signal Orbit has two browser surfaces:
- a host-display surface in
examples/three-game-repo/src/host.ts - a controller surface in
examples/three-game-repo/src/controller.ts
The host-display owns the Three.js scene, camera, renderer, animation frame, and shared orbit state. Controllers send pulse direction and power as player state. Keep Three.js scene objects local to the host-display surface, and synchronize only serializable facts through api.setSharedState() and api.setPlayerState().
Create the Project
Start with the canonical TP Games project and add Three.js. This preserves the tested scripts, CI workflow, validation, and bundled AI development skill:
If the author CLI is already installed, tpgames init three-signal-orbit
replaces the first scaffold command.
This tutorial intentionally replaces the starter surface layout with the checked-in
Three.js 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.
Keep host and controller as separate entries so the registry manifest can launch each TPG surface independently:
Configure the Author Tools
Add the device, bundle, and registry commands to package.json:
Configure Vite to expose both surfaces and the TPG workbench:
Create tpgames.json:
Model Shared and Player State
Put serializable game state in src/game.ts. Shared state belongs to the room. Player state belongs to one controller.
Do not store meshes, materials, vectors, cameras, or renderer handles in TPG state. They are local runtime objects and cannot be replayed safely across host, controller, spectator, or future dedicated logic roles.
Boot the Runtime Bridge
Both surfaces use bootIframeGame() from @tpgames/game-kit. The kit creates the default postMessage bridge internally, infers registry iframe context from the URL hash when the shell launches the game, and still lets local examples provide fallback context for standalone development.
Each iframe runs its own game definition. The host treats surfacesReady as a
repeatable readiness notification, initializes shared state idempotently, and
lets subscription echoes drive the Three.js scene.
The host uses a host-display context. Controllers use a controller context with a participantId fallback for local previews.
Write the Host Runtime
The host connects once, subscribes to lifecycle, participants, shared state, and player state, then seeds the first orbit when all required surfaces are ready or when play starts.
The exact source line api.setSharedState(createOrbitState(1)) is the synchronization point. It publishes the first play step once, then every mounted surface receives the shared-state snapshot through the runtime bridge.
Create the Three.js Scene
Create the renderer and scene after the DOM root exists:
Use local Three.js objects for presentation. In the example, the beacon mesh, orbit ring, marker, hemisphere light, and directional light are all host-display resources. The runtime state tells the host which play step and target angle to render; the scene graph decides how that looks.
Resize and Render
Bind the canvas dimensions to the surface container, not the full browser window. Iframes, shell chrome, and future host-display layouts can change independently.
Keep the animation loop local and derive visual motion from current runtime snapshots:
This keeps frame-by-frame animation out of shared state. Shared state should change when room facts change, not on every rendered frame.
Clean Up WebGL Resources
Three.js examples should show cleanup explicitly because leaked animation frames, resize observers, geometries, and materials are hard to diagnose inside iframe surfaces.
Use this same pattern when returning to the lobby, hot reloading during development, or navigating away from a registry-hosted game surface.
Write the Controller Surface
Controllers publish one player's pulse state. The slider value remains local UI state until the player presses a pulse button.
The host subscribes to player-state updates and uses the latest pulse facts to influence the render loop. The controller can also call runtimeApi.openSettings() and runtimeApi.returnToLobby() so shell-owned lifecycle controls remain available while the game surface is active.
Add the Manifest
Create bundle/manifest.template.json with one required host-display surface and one required controller surface:
Set display to required because Three Signal Orbit needs the host-display WebGL scene. The controller cannot render the shared 3D view on its own.
Test on Multiple Devices Without Uploading
The example's @tpgames/sdk-dev-kit Vite plugin exposes the Three.js display
and controller 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 tpgames.json.
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 Three.js example repo:
The registration script follows the same service path external automation should use:
- upload the zip to
/creator/uploads - submit the draft version
- publish it through
/creator/games/:gameId/versions/:version/publish - 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, submit the draft, 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. After registration,
open the local shell, start a room, choose the registered Three Signal Orbit
game, and verify:
- the host-display iframe renders a WebGL canvas
- controller buttons can be reached with the keyboard
- Pulse left and Pulse right update the controller's live status
- the host pulse count changes after controller input
- returning to the lobby cancels the active game surface and allows a clean restart
TPG repository contributors can mirror the source-example checks from the monorepo root: