Skip to content
tokenAppsPLAY & COLLECT

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/token are absent, the user is a guest. TokenApps.getUser() returns null.
  • When the token expires, the SDK automatically re-handshakes, and a new session message 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_complete passes only when measured play that day is at least 45 seconds. Fire session_complete right after load and you get ignored — 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 with send() (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 a did field), 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.