Game SDK Guide
Before choosing a game architecture, read the Game Capability Matrix. Browser rendering support is separate from transport, authority, persistence, and scale guarantees. The matrix also defines the exact reviewed manifest mapping to iframe sandbox and Permissions Policy grants.
For the full author workflow, including packaging, upload, review, and publish, see the TPG Game Authoring Guide.
TPG games are built around a strict runtime contract instead of arbitrary uploaded apps.
Runtime model
logic: authoritative game logic runtimehost-display: the main shared screencontroller: player-controlled surfacesspectator: optional read-only or low-authority surfaces
In the current platform shell, logic runs on the host by default. The contract is intentionally shaped so the logic runtime can later move to a dedicated service without changing the game-facing API.
Lifecycle and surface readiness
Lifecycle and surface readiness are separate signals.
Lifecycle hooks describe the shell-owned game stage:
bootreadystartedpausedendeddisposed
Surface-readiness hooks describe whether every required iframe surface can receive runtime events:
surfacesLoading(api, readiness)runs while at least one required surface is pendingsurfacesReady(api, readiness)runs whenreadiness.allReadyis true
The same game definition runs independently inside every mounted logic, host-display, controller, and spectator surface. Module variables are therefore local to one iframe; they are not room state.
The shell may replay lifecycle and readiness envelopes after iframe load,
reconnect, participant changes, or readiness changes. Every hook can run more
than once, including consecutive calls with the same value. Hook work must be
idempotent. Guard authoritative mutations with api.context().isAuthority and
check current durable state before initializing or advancing it.
Authoring with @tpgames/sdk
Shell controls
The runtime API now exposes:
reportLoadingendGamereturnToLobbyopenSettingsreportAnalyticsgetSettingsroomparticipantsmegetSharedState/setSharedStategetSharedStateSnapshotgetPlayerState/setPlayerStategetPlayerStateSnapshotbroadcastsendToParticipantsubscribeSettingssubscribeLifecyclesubscribeLoadingsubscribeContextsubscribeParticipantssubscribeSharedStatesubscribePlayerStatesubscribeMessagescontext
subscribeContext(listener) immediately receives the current SurfaceContext, then receives each
effective room, presentation, participant, or authority change. The runtime updates context()
before calling the listener, so authority checks inside the callback always use the same snapshot.
Keep the returned unsubscribe function when the subscription is shorter-lived than the surface;
otherwise the iframe teardown disposes the runtime with the surface.
Controller identity can be provisional during bootstrap. Once participant state is available, use
api.me()?.id ?? api.context().participantId as the current controller id. api.me() reflects the
canonical live participant record, while context().participantId is the fallback before that
record arrives. Recompute the value from subscribeParticipants and subscribeContext instead of
caching the bootstrap fallback.
Game analytics
Games can report creator-facing milestones through reportAnalytics. These
events are advisory telemetry: the runtime validates and forwards them through
the bridge without changing room state, transport behavior, or session
authority.
Allowed game-defined event classes:
milestone.reachedfunnel.stepround.completedoutcome.recordedmechanic.used
Use stable names such as tutorial-complete or prompt-vote. Dimensions must
be string, number, or boolean values; metrics must be finite numbers. Do not
include player-identifying fields such as player ids, names, email addresses,
IP addresses, device ids, or precise locations.
State and message decision guide
Choose the narrowest channel whose delivery semantics match the data:
Do not use transient messages for facts that must survive reconnect. Do not use
sendGameAction as a general game-to-game message bus; SDK-native games should
prefer typed shared/player state and runtime messages.
Shared state is authority-only. Non-authority surfaces receive a typed
not-authority rejection before the bridge is touched. Player state is
participant-owned: controllers and spectators can update their own record,
while authority may update any participant.
Player-state reads are filtered at the runtime boundary. Host-display and logic surfaces receive every participant record, each controller receives only its own record, and spectators receive none. Put room-wide public facts in shared state; do not copy private controller intent into shared state or broadcast messages.
State setters return a discriminated result:
appliedmeans the authority committed the mutation at the returned revision.acceptedis the compatibility result when a legacy event-only bridge accepted the send but cannot acknowledge reduction.rejectedincludes an actionable reason, current revision, and message.
getSharedStateSnapshot() and getPlayerStateSnapshot() return the last
inbound value and revision. Pass that revision as expectedRevision for
optimistic concurrency. A stale request is rejected without overwriting newer
state. Getters remain unchanged for compatibility, and inbound subscription
echoes remain the canonical local view.
Accessible canonical updates
Treat the inbound shared- or player-state subscription as the accessibility boundary for confirmed gameplay changes. Do not announce an optimistic controller intent before the authority echoes the canonical result.
- Keep lifecycle and connection instructions in their own stable status region.
- Render a second, initially empty
role="status"region for concise gameplay results such asCount 12orPong received in 18 milliseconds. - Coalesce rapid canonical updates before changing that region so assistive technology receives the latest useful result instead of an unbounded queue of intermediate values.
- For an ordered transcript, use a named
role="log"with polite additions and hydrate existing history without replacing or re-announcing the whole list. - Leave focus on the control that initiated a successful action. Associate validation failures with
their field through
aria-invalidandaria-describedby.
Counter, Echo Lab, and Room Chat are the first-party reference implementations for these patterns.
Higher-level authoring primitives
@tpgames/sdk now also exposes a higher-level authoring layer for common party-game patterns:
defineSimpleGame<TSharedState, TPlayerState>()wraps the lower-level runtime API with typed state helpersapi.controllers()andapi.controllerIds()expose controller collections directlycreateDeadline()builds round and vote timers without hand-rolled arithmeticsetPhase()handles explicit phase transitionssyncPlayerValues()keeps per-player collections aligned with the active participant list
Dev harness
@tpgames/sdk-dev-kit provides an in-memory bridge and standalone harness for author testing without the full web shell.
The old browser-based /sdk-harness route is now internal-only and is not part of the public shell.
Use @tpgames/sdk-dev-kit as the default fast feedback loop before you boot a full room or run Playwright coverage.
Validation games in the shell
Room Chatverifies broadcast messaging through the host logic layer. Its authority policy trims surrounding whitespace, accepts at most 240 Unicode code points per message and five accepted messages per participant in ten seconds, retains at most 40 ordered messages, and evicts older entries before canonical state exceeds 48 KiB. Rejections remain bounded and identify the sending participant so controller surfaces can show the responsible player how to recover.Echo Labverifies targeted request/response behavior and ping/pong round trips. Echo messages trim surrounding whitespace and accept at most 240 Unicode code points at both the controller and authoritative boundaries.Counterverifies authoritative shared state mutation.Spotlight Voteis the first showcase package with lobby → prompt → vote → results phase logic and scoring.
Testing guidance
- unit-test pure game reducers and derived state
- browser-test host/controller communication across separate clients
- use
@tpgames/sdk-dev-kitto test lifecycle, participants, shared state, and transient messages without the browser shell - keep game logic transport-agnostic so the same game can run over local channels, WebRTC, or WebSocket adapters