Skip to content

Widget embed ​

The loader at GET /v1/widget.js (widget/loader/playflow.js) is a small, dependency-free script. It reads its configuration from the script tag's data-* attributes, fetches widget config, and mounts a sandboxed iframe.

Minimal anonymous free-play snippet ​

html
<div id="playflow-widget"></div>
<script
  src="https://api.example.com/v1/widget.js"
  data-project-id="proj_demo"
  data-api-base="https://api.example.com"
  data-target="#playflow-widget"
  async
></script>

For identified users or reward-bearing play, request a session token from POST /v1/server/session-token with a session:issue API key on your backend, then add data-session-token="SESSION_TOKEN" to the script tag.

Data attributes ​

AttributeRequiredDefaultPurpose
data-project-idyes—Project tenant id
data-api-basenoorigin of the loader script itselfAPI origin — set this explicitly only if the widget must call a different host than the one that served widget.js
data-targetno— (loader creates its own wrapper right after the <script> tag)CSS selector resolved once for an existing mount node. Each instance appends its own wrapper inside it, preserving sibling DOM, even when two instances use the same target
data-session-tokenno—Session token from /v1/server/session-token; omit for anonymous free-play
data-external-user-idno—Stable user id for A/B exposure
data-mountnoinlineinline or modal (taller iframe)

Display modes ​

The snippet is the same for every project; the server decides what the iframe runs, and the iframe keeps the same portrait size (420×760 logical) whatever it shows.

  • Wheel only (entry_mode: "wheel_only"): the iframe opens the published site wheel through the shared release, without a lobby, game engine or active game template.
  • Game for Play set (play_mechanic, lobby mode): the iframe loads the shared game client release from /v1/game-assets/shell/<release>/… and the game named by play_mechanic; the server picks the level on every start. Projects that ran match3_v1 as their active template have it set automatically.
  • No game for Play (play_mechanic empty): the legacy path — the iframe runs the active template directly.

GET /v1/lobby reports the mode as entry_mode (lobby, legacy or wheel_only); see Lobby.

How it loads ​

  • Exposure (/v1/widget/exposure) lets the backend hold out a control cohort. Without a token or user id, the loader defaults to showing the widget.
  • Every exposure call records a widget.exposure event, at most once per user per UTC day and for both cohorts. This is the only retention signal the control arm can produce — it never mounts the widget, so it never emits play.* — and D1/D7 in the pilot case metrics are computed from it. Keep the loader on the page for control users; removing it destroys the comparison.
  • Config (/v1/widget/config) returns theming and play_url. For a project with play_mechanic, the iframe loads the shared client release with modulepreload; otherwise it loads the active template's client. Wheel-only config includes entry_mode: "wheel_only" and a wheel play URL independently of the active template. For the full mode contract, read /v1/lobby.
  • Game releases (GET /v1/game-assets/shell/{release}/{file}) use prebuilt Brotli or gzip variants selected by Accept-Encoding. Files with variants always carry Vary: Accept-Encoding, including uncompressed responses. Release URLs are immutable and cached for one year. Direct requests for .br or .gz files return 404. The legacy client retains gzip compression.

Клиент Match3 поддерживает правила supported_game_versions: [1, 2]. Сервер выбирает версию по позиции дорожки: первая использует v1, позиции 2–15 — v2. Начатые партии восстанавливаются по своей закреплённой версии. Изменять код встраивания для этого не требуется. Старый клиент, который объявляет только [1], получает 409 client_outdated до создания партии v2.

Номер уровня, сложность tier, новый элемент new_element, цели и звёзды передаёт сервер. После позиции 15 игрок повторяет позиции 11–15 без промонаграды. level.cycle_round хранит круг текущей партии; у обычных и исторических партий поле отсутствует. Подробности формата целей и прогресса — в контракте игровых партий.

Reliability: retries and token refresh ​

The game client (inside the iframe) retries transient failures on its own — network drops, 429/503/504, and read timeouts — up to 3 attempts with backoff, honoring Retry-After when the server sends one. A rule violation (400/403/404/409/413) is never retried blindly: the server's answer would not change.

When a request comes back 401, the client asks the loader for a fresh token over the same postMessage channel used for the initial handshake, then retries the original request once with the new token. What happens next depends on how the session started:

  • Anonymous free-play (no data-session-token): the loader re-issues a session for you, transparently, using the same anonymous id it already holds. The player never notices.
  • data-session-token provided: the loader has no way to mint a new one — it was your backend that issued it — so it answers with no token, and the player sees a clear "session expired" message instead of a silent retry loop. Issue tokens with an expiry long enough to cover a play session, or don't set one at all if you don't need per-user identity.

Removing an instance ​

Once the loader script executes, that script element exposes retentionPlayHandle.destroy(). Keep a reference to the particular script; each handle owns only its wrapper, iframe, requests and listeners.

js
var loaderScript = document.getElementById('my-retentionplay-loader');
loaderScript.retentionPlayHandle.destroy();

Give your embed script that id before using this example. For SPA cleanup that may run before the loader script executes, use SDK destroy(). Destroy is idempotent: it aborts owned fetches, ignores late responses, sends retentionplay:close version 1 to the iframe's exact API origin, and removes owned DOM and message listeners. Incoming messages must come from that instance's iframe and API origin; a supplied project id must match. An accepted retentionplay:event with event: "closed" also destroys the instance. Focus returns to the original connected element only if the current focus is inside the widget when closing; focus moved elsewhere is left alone.

When an external button mounts the SDK widget, its activation can leave focus on that button while the iframe loads. Use the normal Tab path to enter the iframe; mounting alone does not require a programmatic focus change.

Theming ​

Set the project primary color in admin. The loader applies it as the --playflow-primary CSS variable and a subtle iframe accent. To switch games, select a game in the admin Game section — no page changes needed. The admin shows its build status and supported modes, and warns about players with unfinished games before switching. Existing progress is preserved. Admin Game selection writes play_mechanic; a legacy template clears it. The same switch is available through PATCH /v1/admin/projects/{id}.

The Match3 preview uses recorded server responses. Click Play, then Show move to replay them; preview creates no game runs, sends no commands to the API, and changes no real progress or rewards. Its simple entry screen is the current client entry screen; Lobby describes the server API. Wheel controls require a ready built client and the appropriate published table; publication and the project display switch are separate actions.

Open preview from the project admin. Its iframe uses GET /v1/admin/widget-preview?project_id=...&mode=preview on the admin origin, with the existing HTTP-only admin cookie. The server requires a current session, verified two-factor authentication and ownership of that project. A different owner receives 404. Public /v1/play?mode=preview receives 403 preview_requires_admin; an unauthenticated preview loader is no longer supported. No admin or player token belongs in the iframe URL.

Sandbox and security policy (CSP) ​

The iframe uses sandbox="allow-scripts allow-same-origin allow-forms".

Your page. The loader script, its config calls and the iframe all use the API origin, so your site's own CSP must allow that origin in script-src, connect-src and frame-src.

The play document. GET /v1/play sends its own policy as an HTTP header (a <meta> policy cannot carry frame-ancestors):

default-src 'self'; script-src 'self' 'nonce-<per response>'; style-src 'self' 'unsafe-inline';
connect-src 'self'; img-src 'self' data: blob:; worker-src 'self' blob:;
frame-ancestors <your allowed_origins>;
  • frame-ancestors lists the project's allowed_origins. An empty list is accepted only for free_play projects (then any site may embed the widget); for any other game_mode an empty list becomes frame-ancestors 'none' and the browser blocks the iframe.
  • Authenticated owner preview (/v1/admin/widget-preview, mode=preview) additionally allows the admin origin. That permission does not widen play CSP.
  • Lobby documents use the wasm profile, adding 'wasm-unsafe-eval' to script-src. Wheel-only, legacy and preview documents use base, shown above. Neither profile adds 'unsafe-eval' for JavaScript.
  • All three lobby run mechanics use that same wasm document profile. A mechanic change within it can load the selected service-hosted module without replacing the iframe. A genuinely different profile requires a new play document; an existing HTTP policy cannot be upgraded by client code.
  • Scripts run only with the per-response nonce, and the game talks only to its own origin. Game code and assets are served from the API domain; customer-provided external URLs are not used.
  • Images load from the service origin. data: and blob: support local graphics and the Phaser loader; the document does not permit arbitrary HTTPS image hosts.

Initial bootstrap messages must come from the iframe's actual parent and carry the exact project id and protocol version. Empty or opaque (null) parent origins are rejected. Token renewal also checks the origin accepted during bootstrap and the request id; a sibling frame or a similar domain suffix cannot reply.

Network recovery ​

The client keeps a command's id and body unchanged across retries. A response lost after the server commits therefore does not spend another move or finish a level twice. A 20-second offline interval pauses retries until the browser is online. Temporary 429/503 responses have at most three attempts; Retry-After is respected with a 30-second ceiling. A 401 triggers one token renewal through the parent loader; anonymous renewal preserves the same player. Supplied tokens must be renewed by the integrating application rather than an anonymous session.

The saved run id remains after a temporary read failure. In two tabs an obsolete sequence resynchronizes with the server instead of repeating a stale move. Destroying the widget cancels retries and pending token renewal; late responses cannot revive it. These guarantees do not make the browser the source of game progress: the server and Postgres remain authoritative.

Lobby skeleton ​

For a project with play_mechanic in lobby mode, /v1/play renders the background and a disabled Play button before JavaScript runs. Public bootstrap.lobby contains the background, accent, configuration revision, CSP profile and allowed_parent_origins. The lobby document profile is wasm; wheel-only uses base. The allowed origins match the project's frame-ancestors; an unrestricted free-play document has an empty event allowlist. The shell remembers the bootstrap message's origin as parentOrigin. The event channel targets only that origin if allowed. Lobby, run result and close events use this channel; they are informational and do not grant rewards.

Troubleshooting ​

SymptomFix
CSP frame-ancestors violationAdd the exact site origin to allowed_origins (empty is allowed only in free_play)
Blank iframeCheck console; verify allowed_origins and play_url
403 origin_forbiddenAdd the exact site origin (scheme + host + port)
Anonymous onlyProvide a valid data-session-token from your backend when per-user progress is required
Wrong gameCheck play_mechanic, or the active template on the legacy path

Internal & integration documentation