TokenApps Game SDK Guide
Updated 2026-09-18: SDK installation and game-only embedding are separate deliverables. Implement the game-only embed guide, then register its deployed URL. A homepage with SDK installed can still overflow inside the player.
This is the guide to the only channel through which a game running inside TokenApps (embedded in a sandboxed iframe) talks to the platform. It is written so you can hand it to game developers as-is. The normative document is the SDK Specification — where this guide differs, the specification wins. There are three components:
- SDK:
https://tokenapps.io/sdk/v1.js(the file the game includes) - Host: the TokenApps player page (the platform-side bridge)
- REST:
/api/game/*,/api/apps/*(saves, inventory, products)
0. Trust Model — Games Request, the Server Decides
The game is an untrusted web page running in an iframe with no installation. The entire specification rests on this premise:
| Principle | What it means |
|---|---|
| The game cannot assert outcomes | track() sends only an event name. Whether TAP (TokenApps Points) are awarded, how many, and the caps are all decided by the server |
| The game cannot see platform accounts | The player.id a game receives is a pseudonym unique to that game. The same user gets a different id in every game, so games cannot cross-reference users with each other |
| A token works for exactly one (user, game) pair | Even if a token leaks, it cannot touch anything beyond that user's save slot in that game |
| Guests always exist | Users who arrive without signing in, demo mode, failed token issuance — all of these are guests. The game must run for guests too (only saving is disabled) |
1. Getting Started
Include the SDK in your game's HTML, and call ready() once you are set:
<script src="https://tokenapps.io/sdk/v1.js"></script>
<script>
TokenApps.on("session", function (s) {
// Arrives again whenever the sign-in state changes or the token is refreshed
if (s.player) {
console.log("Player:", s.player.id, s.player.displayName);
TokenApps.load().then(function (save) {
startGame(save); // null means first play
});
} else {
startGame(null); // Guest — run without saves
}
});
TokenApps.ready();
</script>
2. Sign-in (Identity) Specification
Send ready and the host answers with a session message:
{
"signedIn": true,
"locale": "ko",
// Present only when signed in:
"player": {
"id": "vN3kQ8rT1uY5wZ9aB2cD4e", // pseudonymous id unique to this game (different per game, permanently fixed)
"handle": "publsih", // null if not set
"displayName": "퍼블리시" // null if not set
},
"token": "…", // Bearer token for calling /api/game/*
"expiresIn": 43200, // seconds (12 hours)
"api": "https://tokenapps.io" // API origin
}
- It is safe to key local state and leaderboards on
player.id— within the same game it stays the same forever. - If
player/tokenare absent, the user is a guest.TokenApps.getUser()returns null. - When the token expires, the SDK automatically re-handshakes, and a new
sessionmessage arrives.
3. Storing Data — save / load
Each player account has one save slot per game. It follows the player across devices. External frames retain their origin, but browser privacy settings can block storage. Guard localStorage access and use SDK saves for cross-device persistence.
// Save — any JSON value, up to 64KB serialized
TokenApps.save({ level: 7, coins: 120, inventory: ["sword"] })
.then(function (r) { console.log("Saved", r.updatedAt); })
.catch(function (e) {
if (e.message === "guest") { /* Not signed in — explain that saving is unavailable */ }
if (e.message === "expired") { /* Wait for a new session message, then retry */ }
});
// Load — null if nothing has ever been saved
TokenApps.load().then(function (save) { … });
// Can we save right now?
TokenApps.canSave(); // boolean
4. TAP Events
TokenApps.track("session_complete");
TokenApps.on("points", function (p) {
// { event, status: "awarded"|"duplicate"|"capped"|"ignored"|"signin_required",
// delta?, balance? }
});
- The whitelist of awarding events, their unit amounts, and the daily caps live on the
server. Events not on the list are quietly
ignored— you are free to send anything; awarding is the server's call. - Scores go through
TokenApps.submitScore(n)— display-only and never converted into TAP (prize provisions of Korea's Game Industry Promotion Act (게임산업법) — background in Review Policy §4). - Play time is measured by the host — the game has nothing to do. The player page
accumulates actual play time on the server via 15-second heartbeats, and the daily award
for
session_completepasses only when measured play that day is at least 45 seconds. Firesession_completeright after load and you getignored— the award is idempotent, so sending it again after actual play grants it once at that point. Sending it where a session naturally ends (round over, and so on) causes no problems at all.
4.5 Rewarded Ads
Since 2026-09-17 the player also farms automatically after 60 seconds of host-measured play, without an SDK event. Both paths share one daily award (15 TAP per account/game/UTC day; shared earning cap 150 TAP). See the updated handoff and Mobile & PC checklist.
An optional feature. The game can request an ad at whatever point it chooses. Whether to show one and how much to pay is decided by the platform, and viewing always passes through an explicit player choice screen.
// Only placements the server allows for the genre are accepted. For the games genre: "revive", "bonus_points"
TokenApps.requestRewardedAd("revive").then(function (r) {
switch (r.status) {
case "rewarded": revivePlayer(); break; // r.reward P awarded by the server
case "dismissed": // The player chose not to watch
case "unavailable": // No ad right now (normal!)
case "failed": offerNonAdPath(); break; // All three go to the no-ad path
}
});
Iron rule — unavailable is a normal response, not an error. Guests, frequency caps,
the daily TAP ceiling, Basic-tier apps, and disallowed placements all come back as
unavailable. The game must always keep a path that proceeds without ads — a "watch
an ad to continue" design is rejected in review. For the detailed reason codes and
frequency rules, see SDK_SPEC.md §7.
4.6 Continue Sheet
An optional feature. To offer a continue at game over, we recommend this sheet over calling ads directly. The platform presents a three-option sheet — continue with TAP / continue by watching an ad / stop — and reports only the outcome. Pricing, deduction, and ad handling are all the platform's responsibility.
TokenApps.requestContinue().then(function (r) {
if (r.status === "paid" || r.status === "rewarded") revivePlayer();
else showNormalGameOver(); // declined / unavailable
});
Reference implementation: the "Continue" button in Tower Drift. A "Play again" option must always remain available regardless of the sheet's outcome.
4.7 Selling In-game Goods
An optional feature. You can sell items, themes, and boosts for TAP. The rules are the same as the app store's: products and prices are registered on the platform (register them yourself in the Products panel under My games), and the game speaks only in SKUs. The confirmation sheet, deduction, and record-keeping are all handled by the platform.
// Product list (guests can query it too — displaying the shop is always allowed)
TokenApps.getProducts().then(function (items) { renderShop(items); });
// Purchase request → platform confirmation sheet → result
TokenApps.requestPurchase("golden_tower").then(function (r) {
if (r.status === "purchased" || r.status === "already_owned") unlockGold();
// declined / unavailable → nothing happens. The game just proceeds.
});
// Restore ownership on restart — trust this, not local storage
TokenApps.getInventory().then(function (items) {
if (items.some(function (i) { return i.sku === "golden_tower"; })) unlockGold();
});
Two iron rules: ① grant only on a purchased/already_owned response — never on the
game's own judgment. ② restore via getInventory() — the platform's records are the
source of truth. Reference: Tower Drift's "🏆 Golden Theme" button.
4.8 Duels (Matchmaking)
From v1.4 a game can add duels through TokenApps.match. There are two kinds.
- Live (
kind: "sync") pairs the player with someone playing the same game right now. Both play the same board from the same seed, and game messages sent withsend()(progress, say) reach the opponent as-is. - Async (
kind: "async") is a run against the best record of the closest-rated other player in the same game. The opponent does not need to be online.
The default, kind: "auto", looks for a live opponent first and switches to an async duel if
nobody comes within 45 seconds, so a player never leaves empty-handed. The game only has to
branch on kind at the ready event.
const match = await TokenApps.match.find({ mode: "duel", kind: "auto", timeout: 45 });
// rejects Error("signin_required") for guests, Error("busy") while another find() is waiting
match.on("searching", ({ elapsed }) => ui.showWaiting(elapsed)); // every second while waiting
match.on("ready", ({ kind, seed, opponent }) => {
rng.seed(seed); // same seed = same board
if (kind === "async" && opponent) ui.showTarget(opponent.handle, opponent.score);
if (kind === "sync") ui.showOpponent(opponent.handle);
startRound();
});
match.on("message", (m) => { if (m.t === "p") ui.opponentProgress(m.v); }); // live only
match.on("opponent_left", () => finishRound()); // they left, or have been gone 20 s
function onProgress(v) {
if (match.kind === "sync") match.send({ t: "p", v }); // up to 8 per second, 4 KB each
}
async function finishRound() {
TokenApps.submitScore(myScore); // leaderboard first (this backs the report)
TokenApps.track("session_complete"); // earning stays the usual rule
const r = await match.report({ score: myScore }); // the server judges
ui.showResult(r.outcome, r.ratingDelta); // "win" | "lose" | "draw" | null (disputed)
}
In a live match report() resolves once the opponent has reported too. The server compares
the two reports and settles only when they agree; when they disagree the match closes
disputed and no rating moves. A player who leaves mid-match (leave(), or a connection gone
for more than 20 s) is recorded as the loser. The connection, its token and the channels are
handled by the platform outside the game, so the calls above are all a game needs.
Five rules. Seed randomness from ready's seed only. Don't start the round before ready.
Never stake TAP on a duel (a result moves the rating only; earning stays the
session-completion rule). Keep send() within 8 per second and 4 KB. Keep the game playable
solo when find() is refused. Reference: Tower Drift's "⚔️ Duel" button.
Spec: §6.10–6.14.
5. Direct postMessage and REST Integration
Engines for which including the SDK file is cumbersome — Unity or Godot WebGL, for
example — can implement the postMessage protocol directly instead.
The message envelope is always { __tokenapps: 1, v: 1, type, payload }.
| Direction | type | payload |
|---|---|---|
| Game → host | ready |
{} — session request (re-request = token refresh request) |
| Game → host | track |
{ event: string } |
| Game → host | score |
{ score: number } |
| Game → host | open_purchase |
{} |
| Game → host | request_ad |
{ placement: string } |
| Game → host | request_continue |
{} |
| Game → host | get_products |
{} |
| Game → host | request_purchase |
{ sku: string } |
| Host → game | session |
See §2 |
| Host → game | points |
See §4 |
| Host → game | score_ack |
{} |
| Host → game | ad_result |
{ placement, status, reward?, balance?, reason? } — see §4.5 |
| Host → game | continue_result |
{ status, cost?, balance?, reason? } — see §4.6 |
| Host → game | products |
{ items: […] } — see §4.7 |
| Host → game | purchase_result |
{ status, sku?, balance?, reason? } — see §4.7 |
Call the save API directly with the token from the session (CORS is open to all
origins — safe because it uses only Bearer, no cookies):
GET {api}/api/game/save
Authorization: Bearer {token}
→ 200 { "data": <saved value | null>, "updatedAt": "…" }
PUT {api}/api/game/save
Authorization: Bearer {token}
Content-Type: application/json
body: { "data": <any JSON> } ← always wrap it in the data envelope
→ 200 { "ok": true, "updatedAt": "…" }
→ 401 invalid_token (expired — resend ready, then retry)
→ 413 too_large (over 64KB)
The data envelope is deliberate headroom — slot and version fields can be added later
without breaking existing games.
6. Platform Internals
This section is for reference — not needed for game development, but useful during security review.
- Token issuance is
/api/game/token(cookie-authenticated, no CORS), so the game cannot mint tokens; it can only receive them. The payload is{aud:"game", uid, app, exp}, HMAC-signed — a different shape from the session cookie (which has adidfield), so the two cannot be swapped for each other. player.id= the first 22 characters of HMAC(secret,player:{uid}:{appId}). Fixed per (user, game) pair, unlinkable across games, irreversible.- Saves are a single slot with
game_saves (user_id, app_id) PK, a 64KB check constraint, and RLS lockdown (service role only). - In demo mode token issuance returns 503 → the game runs as a guest.