TokenApps Platform SDK — Formal Specification (SDK_SPEC) v1.4
Document status: This document is normative. The SDK Guide is a tutorial covering the same material; where the two differ, this document wins. Per the conventions of normative documents, it is written in prescriptive language. The keywords MUST / MUST NOT / SHOULD / MAY are used in their RFC 2119 sense.
Subjects: the SDK file
https://tokenapps.io/sdk/v1.js· the TokenApps player (host) · REST/api/game/*,/api/apps/*,/api/ads/*. Last updated 2026-08-25.
1. Versioning Policy
- The SDK file path carries the major version:
https://tokenapps.io/sdk/v1.js. It matches the envelope'sv: 1. - Changes within v1 are always backward compatible. Permitted changes: adding new message types, adding optional fields to existing payloads, adding new SDK methods. Prohibited changes: removing existing fields, changing their meaning or type, and adding required fields to existing messages.
- The game MUST ignore unknown message types and unknown payload fields (ignore them and keep running). The same applies to the host — this rule is what lets both sides update independently.
- Backward-incompatible changes ship only as
v2.js+v: 2. v1 remains maintained after v2 ships. - The SDK exposes
TokenApps.version(in the form "1.4.0"). When branching is needed, however, feature detection (typeof TokenApps.requestContinue === "function") is RECOMMENDED over version comparison. - This document's version (v1.x) records only the additive history within v1. v1.0 =
sessions, saves, TAP (TokenApps Points); v1.1 = rewarded ads (
request_ad/ad_result); v1.2 = the continue sheet (request_continue/continue_result); v1.3 = in-game goods (get_products/request_purchase/inventory).
2. Transport Layer and Envelope
The game runs inside a sandboxed iframe. External HTTPS frames retain their own origin (allow-same-origin); platform-hosted relative frames have an opaque origin. All
communication with the platform travels over a single postMessage channel. The envelope
is always:
{ "__tokenapps": 1, "v": 1, "type": "<message name>", "payload": { … } }
targetOriginis"*"in both directions for compatibility with external and opaque origins. Authentication is by window identity, not an origin claim:- The host MUST process only messages where
event.source === iframe.contentWindow. - The SDK MUST process only messages where
event.source === window.parent.
- The host MUST process only messages where
- Messages without
__tokenapps: 1, or whosetypeis not a string, MUST be silently discarded.
3. Trust Model (Normative)
| # | Rule | Level |
|---|---|---|
| T1 | The game only requests. Every determination — TAP awards, amounts, caps, ad rewards — is made by the server. Values the game sends (event names, scores, claims of completed viewing) are not trusted without verification | Structural |
| T2 | The game cannot receive platform account identifiers. player.id is a pseudonym fixed to the (user, game) pair |
MUST NOT |
| T3 | A game token is invalid outside its own (user, game) save slot | Structural |
| T4 | The game MUST be fully playable in the guest state. Forcing sign-in or blocking guests is grounds for review rejection | MUST |
| T5 | The score (score) is display-only and does not lead to TAP or rewards (prize provisions of Korea's Game Industry Promotion Act (게임산업법)) |
Structural |
| T6 | Ads: unavailable is a normal response. The game must always maintain a path that proceeds without ads. A game that makes play or content contingent on ad viewing is grounds for review rejection |
MUST |
| T7 | Play time is measured by the host. The game has nothing to do and nothing it can tamper with | Structural |
4. Message Specification (Complete)
4.1 Game → Host
| type | payload | Meaning | Response |
|---|---|---|---|
ready |
{} |
Session request. Retransmission = token refresh request (the SDK sends it automatically after receiving a 401). Until the first session arrives the SDK re-sends it five times, from 0.4 s doubling — so a game that is ready before the host page is listening does not lose its handshake. The host answers every ready with a session (MUST) |
session |
track |
{ event: string } |
Reports a gameplay event. event is at most 64 characters (the host discards the excess) |
points |
score |
{ score: number } |
Score submission (the host normalizes negatives to 0 and floors fractions) | score_ack |
open_purchase |
{} |
Request to open the platform's TAP-purchase sheet. If the platform declines, nothing happens | none |
request_ad |
{ placement: string } |
Rewarded-ad request. placement is at most 32 characters |
ad_result |
request_continue |
{} |
Request for the continue sheet (TAP / ad / decline) | continue_result |
get_products |
{} |
Request for this game's server-registered product list. Available to guests | products |
request_purchase |
{ sku: string } |
Purchase request. sku matches ^[a-z0-9_-]{1,64}$ — no price field exists |
purchase_result |
match_find |
{ ref: string, mode?: string, kind?: "auto" | "async" | "sync", timeout?: number } |
Request an opponent. ref is the SDK's id for one find() and ties every later message of this match together. kind: "sync" a live opponent only, "async" a duel against a recorded best, "auto" (default) live first, becoming async when nobody comes within timeout seconds (5–120, default 45). The only mode today is "duel" |
match_result |
match_send |
{ ref: string, data: any } |
A game message in a live match. JSON-serialized ≤ 4 KB, ≤ 8 per second — anything over is dropped with a ratelimited event. The host relays it to the opponent without reading it |
none (the opponent's match_event message) |
match_report |
{ ref: string, score: number, outcome?: "win" | "lose" | "draw" } |
Report the result. Once per match. outcome is for live matches only; omit it and the server compares the two scores |
match_outcome |
match_leave |
{ ref: string } |
Cancel the wait or leave. An async duel or a live match not yet started is a plain cancel; a live match in play is a forfeit (loss) | none |
4.2 Host → Game
| type | payload | When it occurs |
|---|---|---|
session |
See §5 | Upon receiving ready. Sent again on sign-in state changes and token refreshes |
points |
{ event, status, delta?, balance? } — status: "awarded" | "duplicate" | "capped" | "ignored" | "signin_required" |
After track is received, once the server's determination is in |
score_ack |
{} |
Upon receiving score |
ad_result |
{ placement, status, reward?, balance?, reason? } — status: "rewarded" | "dismissed" | "unavailable" | "failed" |
When the final outcome of request_ad is settled (including the player's choice) |
continue_result |
{ status, cost?, balance?, reason? } — status: "paid" | "rewarded" | "declined" | "unavailable" |
When the final outcome of request_continue is settled |
products |
{ items: [{ sku, name_ko, name_en, price_points, kind }] } — kind: "consumable" | "durable" |
Upon receiving get_products |
purchase_result |
{ status, sku?, balance?, reason? } — status: "purchased" | "already_owned" | "declined" | "unavailable" | "failed" |
When the final outcome of request_purchase is settled (including the player's confirmation) |
match_result |
{ ref, status: "searching" } · { ref, status: "matched", match: Match, reused?: boolean, fallback?: "async" } · { ref, status: "unavailable", reason } — reason: "signin_required" | "live_unavailable" | "capped" | "suspended" | "rate_limited" | "not_found" | "failed" | "unavailable" |
On match_find. searching means the request is in the live queue and the match will start with match_event ready. matched means an async duel is ready at once. fallback: "async" means a live match was asked for but live matches are off on this deployment |
match_event |
{ ref, event, data } — event: "ready" | "message" | "opponent_left" | "closed" | "ratelimited" |
During the match. ready's data { id, kind, seed, you, opponent } is where the match starts (kind: "async" when the wait expired into a ghost duel). message is what the opponent sent, opponent_left is { reason: "left" | "disconnected" }, closed is { reason } |
match_outcome |
{ ref, status, outcome?, ratingDelta?, opponentScore?, verified?, rating? } — status: "finished" | "disputed" | "already_reported" | "expired" | "not_started" | "not_found" | "rejected" | "failed", outcome: "win" | "lose" | "draw" | null |
After match_report, once the server's verdict is settled. For a live match that is after the opponent has reported (or forfeited) too. disputed means the two reports disagreed and the match closed without a result. In an async duel verified: false means there is an outcome but the rating did not move |
ad_result, continue_result, and purchase_result each arrive exactly once per request
(MUST). Ordering follows request order. Even when the player chooses an ad inside the
sheet and watches it, the game receives only a single continue_result — no ad_result
is sent.
5. TypeScript Type Definitions (Reference Implementation)
/** postMessage envelope */
type Envelope<T extends string, P> = { __tokenapps: 1; v: 1; type: T; payload: P };
/** Host → game: session */
type SessionPayload = {
signedIn: boolean;
locale: "ko" | "en";
/** The following four exist only when signed in and token issuance succeeded. Treat their absence as guest. */
player?: { id: string; handle: string | null; displayName: string | null };
token?: string; // Bearer. For /api/game/* only
expiresIn?: number; // seconds
api?: string; // API origin, e.g. "https://tokenapps.io"
};
/** Host → game: points */
type PointsPayload = {
event: string;
status: "awarded" | "duplicate" | "capped" | "ignored" | "signin_required";
delta?: number;
balance?: number;
};
/** Host → game: ad_result */
type AdResultPayload = {
placement: string;
status: "rewarded" | "dismissed" | "unavailable" | "failed";
reward?: number; // points awarded when rewarded
balance?: number; // updated balance when rewarded
reason?: string; // reason for unavailable/failed (table in §7.2 below) — display-only, do not branch on it
};
/** window.TokenApps */
interface TokenAppsSdk {
ready(): void;
track(event: string): void;
submitScore(score: number): void;
openPointsPurchase(): void;
requestRewardedAd(placement: string): Promise<AdResultPayload>;
requestContinue(): Promise<{
status: "paid" | "rewarded" | "declined" | "unavailable";
cost?: number;
balance?: number;
reason?: string;
}>;
getProducts(): Promise<
{ sku: string; name_ko: string; name_en: string | null;
price_points: number; kind: "consumable" | "durable" }[]
>;
requestPurchase(sku: string): Promise<{
status: "purchased" | "already_owned" | "declined" | "unavailable" | "failed";
sku?: string;
balance?: number;
reason?: string;
}>;
getInventory(): Promise<
{ sku: string; kind: string; count: number; firstAt: string }[]
>;
getSession(): SessionPayload | null;
getUser(): SessionPayload["player"] | null;
canSave(): boolean;
save(data: unknown): Promise<{ ok: true; updatedAt: string }>;
load(): Promise<unknown | null>;
on(type: "session" | "points" | "score_ack" | "ad_result" | "continue_result"
| "products" | "purchase_result",
fn: (payload: unknown) => void): void;
}
Rejections from save()/load(): Error("guest") = guest, Error("expired") = token
expired (the SDK has already sent a re-handshake — retry after the next session
message), Error("save_failed_<status>") = any other HTTP error.
/** What match.find() resolves: a handle from the moment the request is taken; fields fill at `ready`. */
export type TokenAppsMatch = {
id: string | null; // null until ready
kind: "async" | "sync" | null; // null until ready. sync = a live opponent, async = a record
mode: string;
/** Seed both sides build the same board from — initialise the game's randomness from this and nothing else. Null until ready. */
seed: number | null;
you: { slot: number; handle: string | null; rating: number } | null;
/** An async opponent is a record: score is the target. Null when nobody has scored here yet. */
opponent: { slot: number; handle: string | null; rating: number; score?: number } | null;
expiresAt: string | null;
state: "searching" | "ready" | "closed";
/**
* searching { elapsed } every second while waiting
* ready { id, kind, seed, you, opponent } the match starts — on a later tick than find() resolving
* message (the opponent's send value) · opponent_left { reason } · result (report's verdict)
* closed { reason } · ratelimited { dropped }
*/
on(event: "searching" | "ready" | "message" | "opponent_left" | "result" | "closed" | "ratelimited",
fn: (payload: unknown) => void): () => void;
send(data: unknown): void; // live only — JSON ≤ 4 KB, ≤ 8 per second
report(result: { score: number; outcome?: "win" | "lose" | "draw" }): Promise<MatchOutcomePayload>;
leave(): void; // a live match in play: forfeit (loss)
};
export type MatchOutcomePayload = {
status: "finished" | "disputed" | "already_reported" | "expired" | "not_started"
| "not_found" | "rejected" | "failed" | "closed";
outcome?: "win" | "lose" | "draw" | null; // null when disputed
ratingDelta?: number;
opponentScore?: number | null;
verified?: boolean;
rating?: number;
};
6. REST Specification
The save API allows CORS from any origin (Bearer-only, no cookies), so it is callable even from an opaque origin. The ads API is called by the host page — the game handles ads exclusively through the messages in §4.
6.1 GET /api/game/token — Token Issuance (host only)
Cookie-authenticated, no CORS. The game cannot call this endpoint — tokens arrive only
via the session message. Query ?slug=<app slug>.
| Status | Response |
|---|---|
| 200 | { token, expiresIn, player: { id, handle, displayName } } |
| 401 | { error: "unauthorized" } — not signed in |
| 404 | { error: "not_found" } — no playable app with that slug |
| 503 | { error: "not_configured" } — demo mode (the game runs as a guest) |
6.2 GET · PUT /api/game/save — Save Slot
Authorization: Bearer {token} required. One slot per game per user.
| Method | Request | Success | Errors |
|---|---|---|---|
| GET | — | 200 { data: <value | null>, updatedAt } |
401 invalid_token |
| PUT | { "data": <any JSON> } (envelope required) |
200 { ok: true, updatedAt } |
401 invalid_token · 413 too_large (serialized size over 64KB) · 400 data_required |
Upon receiving a 401 the SDK automatically retransmits ready. The game SHOULD wait for
the new session and then retry.
6.3 POST /api/ads/offer — Ad Offer Issuance (host only)
Request: { slug, placement }. Cookie-authenticated.
| Status | Response |
|---|---|
| 200 | { available: true, offerId, watchSeconds, reward, creative } or { available: false, reason } |
| 400 | { error: "slug_and_placement_required" } |
| 404 | { error: "not_found" } |
| 503 | { error: "not_configured" } |
Ineligibility is not an error; it is 200 + available: false. reason:
signin_required · placement_not_allowed · not_monetizable (Basic-tier app) ·
too_soon (global minimum interval) · daily_cap (per-user daily limit) ·
app_daily_cap · point_cap (daily TAP ceiling reached — zero-reward views are blocked
at the source).
6.4 POST /api/ads/complete — Offer Claim (host only)
Request: { offerId }. Cookie-authenticated. The server verifies the offer's signature,
owner, expiry, and watch time against the server clock. The client-side countdown is
not evidence.
| Status | Response |
|---|---|
| 200 | { status: "awarded", delta, balance } · { status: "duplicate", balance } (reused offer — no award) · { status: "capped", … } |
| 400 | { error: "offer_required" } |
| 401 | { error: "unauthorized" } |
| 422 | { error: "invalid_offer" } (forged, expired, or another user's offer) · { error: "too_early" } (watch time not met) |
When an ad network is integrated in the future, an S2S callback takes this endpoint's
place, and the idempotency-key discipline (ad:{uid}:{nonce}) is retained as-is.
6.5 POST /api/apps/{slug}/continue — TAP-based Continue (host only)
Request: { requestId: UUID } (issued by the host — one sheet interaction = one spend).
Cookie-authenticated. The unit price is a server constant (CONTINUE_COST_POINTS) and
cannot be altered by the client. The deduction is performed only by the spend_points SQL
function (per-user advisory lock + balance check + idempotency key) — deducting below the
balance and racing double-spends are structurally impossible.
| Status | Response |
|---|---|
| 200 | { status: "spent", cost, balance } · { status: "insufficient", balance } · { status: "duplicate", balance } |
| 400 | { error: "request_id_required" } |
| 401 | { error: "unauthorized" } |
| 403 | { error: "not_monetizable" } — Basic-tier app (fully de-monetized, TAP sinks included) |
| 404 | { error: "not_found" } |
6.6 GET /api/apps/{slug}/products — Product List (host only)
The data is public (a price list), but the host page fetches it on the game's behalf and
relays it via the products message — there is no CORS surface on the game side. The
exposed fields are exactly the products payload in §4.2 (no internal ids). This list
is the sole source of truth for prices. Whatever the game's UI says, billing uses these
values.
6.7 POST /api/apps/{slug}/purchase — Goods Purchase (host only)
Request: { sku, requestId: UUID } (host-issued). Cookie-authenticated — unreachable from
the sandboxed iframe in the first place, and called only after the player presses confirm
on the platform sheet. Settlement runs through a single SQL function,
purchase_game_item: per-user advisory lock → idempotency key → duplicate-ownership
rejection for durables → balance check → ledger deduction + inventory record, all in
one transaction. No path exists where TAP leave without a record (or the reverse).
| Status | Response |
|---|---|
| 200 | { status: "purchased", sku, kind, balance } · already_owned · insufficient · duplicate (retransmission — already charged once) · not_found (unknown/inactive sku) |
| 400 | { error: "bad_request" } |
| 401 | { error: "unauthorized" } |
| 403 | { error: "not_monetizable" } — Basic-tier app |
| 404 | { error: "not_found" } — app not found |
6.8 GET /api/game/inventory — Owned Goods (Bearer)
Authorization: Bearer {token}. Same access model as /api/game/save (CORS open to all
origins, the token is the sole credential, scoped to the (user, game) pair).
| Status | Response |
|---|---|
| 200 | { items: [{ sku, kind, count, firstAt }] } — durables are owned once; consumables carry the cumulative purchase count |
| 401 | { error: "invalid_token" } |
6.10 POST /api/match/find — Start a Duel (host only)
Request { app: string, mode?: "duel", kind?: "auto" | "async" | "sync", timeout?: number },
authenticated by the player's session cookie. Guests 401, unknown game 404.
kind: "async"— at once,200 { status: "matched", match, reused }. An unexpired async match in the same game is returned instead of a new one (reused: true). More than 300 per day:429 { status: "unavailable", reason: "capped" }.kind: "sync"/"auto"— joins the live queue. Paired at once:200 { status: "matched", queueId, match }(match.kindis"sync",match.statusis"pending"); otherwise202 { status: "queued", queueId, expiresAt }. Aftertimeoutseconds (5–120, default 45) the server turns the wait into an async duel. One ticket per person at a time — calling again in the same game returns the same ticket, and a player already seated in a live match gets that match back. More than 10 per minute:429 rate_limited; an account suspended for disputes:403 suspended.- When live matches are off on this deployment (no Realtime signing key),
"auto"answers with an async duel (fallback: "async") and"sync"answers503 { status: "unavailable", reason: "live_unavailable" }.
The opponent in an async duel is the closest-rated other player's best record in this game, frozen at the moment the match is created. An async match must be reported within 6 hours. Live pairing starts within a rating gap of 100 in the same game and mode, widening by 50 for every 10 seconds waited.
6.11 POST /api/match/{id}/result — Report a Duel (host only)
Request { score: number, outcome?: "win" | "lose" | "draw" }. Response { status, result? }
where result is { outcome, ratingDelta, opponentScore, verified, rating }. Once per
match — a second report returns already_reported with the first result.
- Async duel: the server sets
verified: trueand moves the rating (Elo, K = 24) only when the leaderboard value this player recorded via thescoremessage since the match began is at least the reported score. The opponent only lent a record, so their rating does not move. - Live match: the first report answers
{ status: "pending" }. When the second arrives the server compares the two. If each report'soutcome(or, when omitted, the one read from the two scores) agrees with the other — win/lose or draw/draw — the match isfinishedand both ratings move (Elo, K = 32). If not, it closesdisputedand nothing moves. Threedisputedin a row with the same opponent suspends both players from live matches for a day.verifiedis recorded but does not gate a live result — the two players' agreement is the check. - Leaving mid-match (
leave, or no heartbeat for 20 s without a report) settles the match for the side that stayed, with both ratings moving at half weight (K = 16). If both sides are gone it closesabandonedwith no change. A side still unreported 30 minutes after the start counts as gone.
6.12 POST /api/match/{id}/leave · GET /api/match/{id} (host only)
leave cancels an async duel or a live match not yet started (no rating movement) and is a
forfeit in a live match in play (§6.11). GET is the polling path for the match state and
result. A live match's seed stays null until both sides are ready and it is active.
Both answer 404 to anyone who is not in the match.
6.13 Live Match Routes (host only)
Live matches are run by the player shell (the page embedding the game), which connects to Supabase Realtime directly. The game never sees that connection, its token or any id — it only exchanges the postMessage traffic of §4.
| Route | Use |
|---|---|
POST /api/match/token |
Realtime credentials { url, key, jwt, exp, topic }. jwt is a 15-minute user token, topic is user:{id}. The shell fetches a new one 5 minutes before expiry. 503 when live matches are off |
GET /api/match/queue/{id} |
Ticket state { ticket: { status, matchId, createdAt, expiresAt } } — status: "waiting" | "matched" | "cancelled" | "fallback". The polling path for a shell that missed the user:{id} notice |
DELETE /api/match/queue/{id} |
Give up waiting. If paired in the meantime: 409 { status, matchId } — the caller then leaves that match |
POST /api/match/{id}/ready |
Sent after joining the match channel. The second ready starts the match: { status: "active", seed, startedAt }. Both must be ready within 30 s of pairing or the match is abandoned |
POST /api/match/{id}/beat |
The heartbeat, every 5 s while in play. The answer { status } doubles as the status poll |
Both channels are private; RLS decides who may join.
user:{id}— readable by that user only. The database sendsmatched { queueId, matchId }andfallback { queueId, matchId }(the async duel made when a wait expired). Clients cannot write to it.match:{id}— readable and writable by the two seated players only, while the match ispendingoractive. Presence keys areslot-1/slot-2, game messages are broadcastmsg { d }, and status changes arestatus { matchId, status, seed?, players[] }sent by the database.
6.14 Duel (Matchmaking) Conformance Requirements
- Initialise the game's randomness from
ready'sseedand nothing else (MUST). The same seed must produce the same board. Build the board so that screen size and refresh rate do not change its difficulty (SHOULD). - Do not start the round before
ready(MUST). Branch onkindfor live vs async. - Never stake TAP on a duel (MUST). A result moves a rating and a record only; earning stays the session-completion rule of §8. Rewarding scores or wins is what the domestic 게임산업법 prize rule prohibits.
- Submit the same score with
submitScore()beforereport()(SHOULD) — that is what makes an async reportverified. - Keep
send()within 8 per second and 4 KB (MUST). Anything over is dropped. - On
opponent_left, wrap up the round andreport()(SHOULD). - The game must remain playable solo when
find()is refused (MUST).unavailableis a normal answer.
6.9 Goods Sale Conformance Requirements
- Products are pre-registered on the platform (via the operations console). The game refers to SKUs only, and no message carries an amount field — the specification provides no way to assert a price at all.
- The game MUST grant goods only on
"purchased"or"already_owned". It MUST NOT grant based on its own bookkeeping or local flags. - On restart, owned goods MUST be restored via
getInventory(). Local state is only a cache. - After
declinedorunavailable, the game MUST proceed normally — a purchase is an option, not a wall. - Verification when the game has its own server (the same role as server-side receipt
verification on ONE Store or Google Play): do not trust the client's
purchase_resultas-is; instead, have the client send thetokenit received in the session up to the game server, have the game server callGET {api}/api/game/inventorywith that token to confirm ownership, and grant only then (SHOULD). Because the token is valid only for that (user, game) pair, the game server gains exactly that much authority and no more. A dedicated S2S receipt API is reserved for a future increment.
7. Rewarded Ad Conformance Requirements
7.1 Game Side (review criteria)
- The game MAY request ads. Only request TAP (placements) that the server policy
allows for the genre are accepted — currently for
games:revive,bonus_points. - On
unavailable,dismissed, andfailedalike, the game MUST maintain a path on which play can proceed. A design where "you must watch an ad to continue" is rejected in review. - The game MUST branch only on the
statusofad_resultand SHOULD NOT branch onreason(reason is for diagnostics and display, and new values may be added). - Only rewards the server has written to the ledger are valid. The game is free to layer its own currency on top, but MUST NOT counterfeit platform TAP with its own display.
7.2 Platform Side (what the implementation guarantees)
- Viewing always passes through an explicit player choice screen. Ad cards carry an "Ad" label.
- Offers are single-use (idempotency key), expire after 120 seconds, and are bound to the issuing user.
- Frequency limits: a global 180-second interval · 5 per user per day · 2 per (user, app)
per day · a pre-check against the daily TAP ceiling. The numbers may be adjusted as a
business decision (constants that go through code review,
lib/ads.ts).
8. Play Measurement and the Award Gate (No Game Involvement)
- The host (GamePlayer) measures play time with a 15-second heartbeat. The server credits at most 75 seconds per heartbeat, so the measurement can never exceed wall-clock time. Time while the tab is hidden is not credited.
- The daily award for
track("session_complete")passes only when host-measured play that day is ≥ 45 seconds. Below that,points.status = "ignored"— the award is idempotent, so retransmitting after actual play grants it once at that point. - The game has nothing to do in this section. The SDK contract does not change either.
This section exists to answer the question "why is
session_completeignoredwhen fired right after load?"
9. Guest (Signed-out) Specification
Platform update (2026-09-17): automatic farming settles after 60 seconds of host-measured play without an SDK event. It shares the SDK completion path's daily award key: 15 TAP per account/game/UTC day, subject to the shared 150 TAP daily earning cap. Both paths cannot pay twice. See developer handoff and Mobile & PC requirements.
- If
session.playeris absent, the session is a guest session.canSave() === false, andsave()/load()reject withError("guest"). - A guest's
track()receivespoints.status = "signin_required". The event itself is still recorded for measurement. - A guest's
request_adreceivesad_result.status = "unavailable"(reason: "signin_required"). - T4 restated: the game MAY explain these three reductions, but it must not block a guest's play.