Surface: games
A sandtray or a dollhouse: two people, in two different places, moving figures around one scene and seeing each other do it. The game mode frames one seat on that scene. It is the only surface on the platform that is inherently multiplayer, and almost everything that makes it different from the others follows from that.
This is one Level 3 surface. The shape of the integration is the same as every other framed surface, with one substitution: the token does not come from the embed-token mint.
What it renders
mode |
gameType |
What each participant sees | The URL the SDK builds |
|---|---|---|---|
game |
sandtray |
a shared three-dimensional tray and a figure library | /embed/games/sandtray |
game |
dollhouse |
rooms, furniture and family figures | /embed/games/dollhouse |
The therapist's frame drives the session — choosing or creating a save, saving, loading — and the patient's frame follows automatically. Both frames render the same live scene; neither is a viewer.
The scene itself never reaches your page. What crosses the boundary is identifiers and counts, as it is on every other surface.
The identifier it needs
A game seat is different in kind from every other framed surface. Its token does not come from POST /v1/partner/embed-tokens; it comes from POST /v1/partner/game-sessions, which mints both seats at once so the two carry the same session key:
const session = await playspace.createGameSession({
gameType: 'sandtray', // or 'dollhouse'
patientId: partnerPatientId, // your PlaySpace patient identifier
origins: ['https://app.yourclinic.com'],
})
// session.game_session_id
// session.practitioner and session.patient each carry { embed_url, token, expires_at }
patientId is required, because a session is two named people. It is a PlaySpace Partner API patient identifier, and it is checked against both your organisation and the acting clinician's own roster.
gameType must match the token's own claim. The frame refuses a seat minted for the other bundle rather than quietly mounting the wrong one.
Each seat also carries a ready-made embed_url, which is the URL above with that seat's token already in the query string — useful when you are handing the patient's seat to a device your own application does not render.
Capabilities
| Capability | What it adds |
|---|---|
games:play |
a seat on a live game session. It is minted by POST /v1/partner/game-sessions rather than by the embed-token mint, so it is not a capability you list in a mintEmbedToken call. |
shell:read is refused outright on the PATIENT seat. The token returned as patient.embed_url is for a child's device and opens the game, never the clinician's workspace. That refusal is deliberate and is not something a capability list can change.
Reading saves back is a server-side job with your own partner credential: listGameSaves, getGameSave, and deleteGameSave, which soft-deletes one.
Mint the token
// playspace/game-sessions.ts — server only.
import { createEmbedClient } from '@playspace-health/embed/server'
const PLAYSPACE_BASE_URL = 'https://agentic-ps-dev.playspace.health'
const HOST_ORIGINS = ['https://app.yourclinic.com']
declare function getDelegatedPartnerToken(practitionerId: string): Promise<string>
function client(practitionerId: string) {
return createEmbedClient({
baseUrl: PLAYSPACE_BASE_URL,
getAccessToken: () => getDelegatedPartnerToken(practitionerId),
})
}
/** One call, two seats on one scene. Keep `game_session_id` — refreshing needs it. */
export async function startSandtraySession(
practitionerId: string,
partnerPatientId: string
) {
return client(practitionerId).createGameSession({
gameType: 'sandtray',
patientId: partnerPatientId,
origins: HOST_ORIGINS,
})
}
/**
* Refreshing a seat mid-session is the SAME call with the session identifier.
* A token minted for a different session is refused by the frame rather than
* re-scoping a session in progress.
*/
export async function refreshSeat(
practitionerId: string,
partnerPatientId: string,
gameSessionId: string,
seat: 'practitioner' | 'patient'
): Promise<string> {
const session = await client(practitionerId).createGameSession({
gameType: 'sandtray',
patientId: partnerPatientId,
origins: HOST_ORIGINS,
gameSessionId,
})
return session[seat].token
}
The access token you hand getAccessToken must be delegated — issued acting as one practitioner. A session belongs to one clinician and one child.
ttlSeconds on createGameSession posts as token_ttl_seconds, which is the one option whose wire name is not a straight transliteration of the camelCase one.
Mount it
Mount each seat where that person is signed in, and hand each frame its own seat's token.
'use client'
// playspace/sandtray-panel.tsx — the CLINICIAN's browser.
import { GameEmbed } from '@playspace-health/embed/react'
import type { ReactElement } from 'react'
const PLAYSPACE_BASE_URL = 'https://agentic-ps-dev.playspace.health'
declare function refreshPractitionerSeat(): Promise<string>
declare function recordSession(gameSessionId: string): void
export function SandtrayPanel({ token }: { token: string }): ReactElement {
return (
<div style={{ height: 900 }}>
<GameEmbed
baseUrl={PLAYSPACE_BASE_URL}
gameType="sandtray"
token={token}
// Sessions run 60 minutes and more; see "Sessions longer than a token".
fetchToken={refreshPractitionerSeat}
onEvent={(event) => {
if (event.type === 'game.session_started') recordSession(event.payload.gameSessionId)
}}
/>
</div>
)
}
Without React, the seat mounts through createGameEmbed(container, { baseUrl, gameType, token, fetchToken }), and handle.destroy() takes it down.
Without the package at all, the seat is an ordinary iframe pointed at that seat's embed_url — the token is already in it:
<iframe
src="https://agentic-ps.playspace.health/embed/games/sandtray?token=EMBED_TOKEN"
title="PlaySpace game"
allow="camera; microphone; fullscreen; display-capture; autoplay; picture-in-picture"
allowfullscreen
referrerpolicy="strict-origin-when-cross-origin"
style="width: 100%; height: 900px; border: 0"
></iframe>
A bare iframe cannot renew its own token, which matters more here than anywhere else: a session outlasts a token by design. Using the API from other languages is the whole path, including the plain JavaScript that pushes a fresh token into a running frame.
Getting the patient's seat to the patient's browser is the part an integration usually gets wrong, because a seat token is a live credential and must not travel in a link you can read. Embed a sandtray is that whole problem worked through, with the opaque-link pattern and the security checklist.
Events it emits
| Event | When it fires | Payload |
|---|---|---|
ready |
the seat mounted and is interactive | { mode } |
game.session_started |
the multiplayer scene is live | { gameSessionId, gameType } |
game.save_created |
a new save row exists | { saveId } |
game.saved |
the active save was persisted | { saveId } |
game.save_loaded |
a saved scene was loaded | { saveId } |
error |
something worth surfacing; branch on code, never on message |
{ message, code?, severity?, retryable?, requestId? } |
game.saved is not deduplicated at all — every persist of the same save is a distinct event, so a session that saves repeatedly produces one event per save for one saveId. Every other event is delivered at most once per subject per frame load.
The save events carry identifiers only: never scene content, never a save's name, never a child's name.
The error codes this surface raises are game.session_failed (the session could not be started), game.save_failed (a scene did not persist) and game.load_failed (a saved scene could not be read), alongside the embed.* token codes the SDK raises on your own page.
Things that will bite you
- Refreshing a seat with a token from a NEW session. Call
createGameSessionagain with the original response'sgame_session_id. A token whose practitioner, patient, role or session key differs is refused by the frame rather than re-scoping a session in progress. - Handing a frame the other seat's token. Each seat is bound to its role. The clinician's frame takes
session.practitioner.tokenand the patient's takessession.patient.token, and swapping them fails rather than degrading. - Putting a seat token in a link. It is a live credential to a session with a child in it. Hand the patient's browser an opaque reference of your own and resolve it server-side.
- Expecting the whole workspace on the patient seat.
shell:readis refused there by design. - Expecting saves to arrive in the browser. Scenes are read back server-side with
listGameSavesandgetGameSave; the frame reports identifiers. - Forgetting the
allowattribute on a hand-rolled iframe. A live session needs camera, microphone and the rest of the list above, and the Permissions-Policy specification denies a cross-origin frame every feature it was not granted — failing before any permission prompt appears, which looks exactly like the user pressing "Block".
Related
- Embed a sandtray is this surface end to end: the session mint, both seats, getting the client's seat to the client's browser, refresh, saves, and the security checklist.
- Level 3: single surfaces is the table of every framed surface, and where
tokenandfetchTokenare explained once for all of them. - Level 4: the Partner API is the no-frame path: minting a session and letting PlaySpace host it, and reading saved scenes back.
@playspace-health/embedcarries the full component, event and error reference.- The endpoint summary covers
POST /v1/partner/game-sessionsand the save operations beside it.