# TokenApps developer documentation (full corpus) SDK runtime: https://tokenapps.io/sdk/v1.js --- # Getting Started — Integration Kit (start) # Getting Started — Game SDK Integration Kit > Updated 2026-09-18: SDK installation and game-only embedding are separate deliverables. Implement the [game-only embed guide](/dev/embed), then register its deployed URL. A homepage with SDK installed can still overflow inside the player. Updated 2026-09-17: [developer handoff](/dev/sdk) · [Mobile & PC requirements](/dev/devices). The player now farms automatically after 60 seconds; the existing SDK completion path retains its 45-second gate. Both share one daily award. Host heartbeats run every 15 seconds. The [submission form](/submit) exports a developer brief. > Hands-on instructions written to drop straight into each game's repository. The full > specification is the [SDK Specification](/dev/spec); a complete working example is > the reference game **Tower Drift** (`tokenapps.io/embed/demo-game/index.html` — it is > actually live in the catalog). Following this document alone completes the integration. ## 0. Three Things to Know First 1. **Measurement is already happening.** Play time and return rates are measured by the platform outside the game. Even without any of the integration below, the game keeps running as it does now and metrics keep accumulating. What integration unlocks is **TAP (TokenApps Points)-earning integration, account saves, and ad rewards**. 2. **The game only requests.** Awards, caps, and whether an ad appears are all decided by the server. Sending something wrong does not become fraud, and manipulation gains nothing. 3. **Guest is the default.** The game must work fully for users who arrive without signing in. Encouraging sign-in is the platform's job (via an invitation card after play) — forcing sign-in inside the game is rejected in review. ## 1. Required Integration — 5 Minutes Finish just this step and TAP integration works. In the game HTML's `` or at the end of ``: ```html ``` Then one line at **the point where a round naturally ends**: ```js TokenApps.track("session_complete"); ``` | Game | Recommended "end of a round" | |---|---| | Chart Run (차트런) | On entering the results screen of one trading-simulation run | | TPC Coin Pusher | At the end of the daily session (coins exhausted, results tallied) | | Baedal Rush: Riders | At the end of a delivery round (success or failure alike) | That is all. One thing to watch: **if measured play that day is under 45 seconds, the server responds `ignored`** (an anti-fraud gate). Fired right after load it is ignored, but the award is idempotent, so when it is sent again at the end of the next round after actual play, it is granted normally at that point — the game has nothing extra to handle. When opened outside TokenApps (a direct visit on your own site), the SDK quietly does nothing — no branching code is needed. ## 2. Recommended — Showing Award Results ```js TokenApps.on("points", function (p) { if (p.status === "awarded") showToast("+" + p.delta + "P earned!"); else if (p.status === "duplicate") {} // Today's share already granted — stay quiet else if (p.status === "signin_required") {} // Guest — the platform handles the invitation }); ``` The platform shows its own toast as well, so you can skip this, but layering in-game presentation on top (effects, sound) makes the experience feel much better. ## 3. Optional — Account Saves Browser privacy settings can block localStorage even when an external frame retains its origin. Guard access and use SDK saves for cross-device progress: one slot per game per user, 64KB. ```js TokenApps.on("session", function (s) { if (TokenApps.canSave()) { TokenApps.load().then(function (save) { applySave(save); /* null = first play */ }); } }); // When saving: TokenApps.save({ level: 7, bestScore: 1200 }); ``` For Chart Run, a summary of practice history; for TPC Coin Pusher, cumulative records; for Baedal Rush: Riders, stage progress — those are the natural candidates. Guests have `canSave() === false` — just skip it. ## 4. Optional — Rewarded Ads Request at whatever point the game chooses; the platform presents the choice screen and the ad in its own UI and reports only the result. No ad SDK or account is needed on the game side. ```js TokenApps.requestRewardedAd("revive").then(function (r) { if (r.status === "rewarded") revivePlayer(); // The server has already granted the points else showNormalGameOver(); // dismissed/unavailable/failed all land here }); ``` - Allowed placements: `"revive"` (continue), `"bonus_points"` (bonus spot). - **Iron rule: `unavailable` is a normal response.** For guests, frequency caps, and the daily TAP ceiling, no ad appears. Always keep a path that proceeds without ads — "watch an ad to continue" is grounds for review rejection. You can follow the continue-button handling in the reference implementation (Tower Drift) as-is. ## 4.5 Optional — Selling In-game Goods To sell items or themes for TAP: register the products (SKU, name, price, type) yourself in the Products panel under [My games](/me/apps). The game-side code is three lines — `getProducts()` to display the shop, `requestPurchase(sku)` to buy (confirmation and payment happen on the platform sheet), and `getInventory()` to restore on restart. Grant only on a `purchased`/`already_owned` response, and restore from the inventory — for details, see [SDK Guide §4.7](/dev/guide). ## 5. Integration Checklist Open the game on TokenApps (`tokenapps.io/ko/app//play`): - [ ] Complete a round as a guest → the game works normally, and the platform invitation card appears right after completion - [ ] Sign in, play at least 45 seconds, and complete a round → a "+15 TAP" toast - [ ] Complete a second round the same day → no award (duplicate), the game runs fine - [ ] (If saves are integrated) Save → sign in from another browser → progress carries over - [ ] (If ads are integrated) Continue → choice screen → watch → revive + TAP; "No thanks" → falls through naturally to the normal game over - [ ] Opening the game directly on your own domain produces no errors ## 6. Remaining Work Assignments | Game | Code location | Integration owner | To do in this repository | |---|---|---|---| | Tower Drift | `/embed/demo-game/` | Done (reference) | — | | Chart Run | cgame.tokenpost.kr | Chart Run dev team | Hand over this document | | TPC Coin Pusher | pusher.tokenpost.kr | That team | Hand over this document + **the blocking issue below** | | Baedal Rush: Riders | (own repository) | That team | Hand over this document | **TPC Coin Pusher blocking issue (observed 2026-08-24)**: There is no frame-blocking header, yet inside TokenApps's sandboxed iframe the game renders nothing at all (no console errors either — presumably frame-environment detection or a third-party-cookie dependency). It has therefore been demoted to **external link only** in the catalog for now. Let us know once it is fixed to render in an iframe embed — we will restore the play_url and reopen embedded play (measurement and TAP earning included). Please frame questions against the [SDK Specification](/dev/spec). Where the specification and this document differ, the specification is correct. --- # SDK Developer Guide (single file) (sdk) # tokenApps SDK — developer handoff v1.4 Updated: 2026-09-18. Runtime: `https://tokenapps.io/sdk/v1.js` (1.4.0). This guide describes the deployed code; [the specification](/dev/spec) covers the full wire contract. ## What to deliver 1. A public HTTPS **player URL** that opens the game itself, without a landing page, navigation, FAQ or a second scrollbar. 2. Mobile / PC / tablet support, touch / keyboard / mouse controls, orientation and supported game languages. 3. One responsive build, or mobile and PC review URLs with a primary URL that routes correctly. The host currently uses one `play_url`; it does **not** select the submitted device URLs automatically. 4. SDK status, tested browsers with versions, known issues, build version and a developer contact. 5. Icon, cover and up to three screenshots. Use the [submission form](/submit) to enter the details and download a Markdown brief. Existing owners can update the same information in [My games](/me/apps). The private brief is visible to its owner and operators, not the public listing. Unchecked tests mean unverified, not passed. See [Mobile & PC](/dev/devices) for the implementation checklist and target viewports. ## Game-only screen: required alongside the SDK **SDK integration does not turn a website into an embedded game.** It connects identity, scores and saves; the game team owns routing, layout, canvas sizing and input. 1. Reuse the existing game in a deployed `/embed` route (or equivalent). Exclude website navigation, hero, FAQ and footer; preserve the board, HUD, pause, sound, restart and touch controls. 2. Fit the actual iframe width **and height**. Reserve HUD/control space, observe the remaining board container, use contain scaling, update rendering resolution and input coordinates, and preserve the current round during resize. 3. Keep SDK handlers persistent and register them before `ready()`. Start guest play independently of SDK/network success. Do not add nested iframes or a second login flow. 4. Test inside frames, including 310x360, 380x640, 558x160, 834x220, 1340x560 and 1894x875 CSS px. No unintended scrollbars **and no clipped controls**. Report unsupported configurations rather than hiding failures with overflow. 5. Deploy first, then register the exact game-only URL. `/embed` is not auto-created, and mobile/PC review URLs do not automatically replace the primary player URL. Read the [detailed embed implementation](/dev/embed) for Next.js/Vite routing, HTML/CSS, canvas scaling, CSP, diagnostics and a copyable AI coding request. Try the [runnable HTML reference](/sdk/examples/embed.html); it sends no score or reward events. ## Minimal integration A game must boot for guests and when the platform is unavailable. Do not make gameplay wait indefinitely for a session event. This sketch uses your own game functions: ```html ``` Register listeners before `ready()`. The runtime retries its handshake until the first session arrives. `session.player.id` is a per-game pseudonym, not a platform account identifier. Do not implement a second OAuth flow inside the frame. Platform sign-in currently offers Google and Privy; new TokenPost sign-in/linking is disabled. Legacy `player.verified` is compatibility data, not a requirement for play. ## Automatic farming and SDK events | Path | Current rule | |---|---| | Automatic player farming | After at least 60 seconds measured by the host; no SDK call required | | `track("session_complete")` | Existing integration path; at least 45 seconds of host-measured play that UTC day | | Payout | 15 TAP per game per account per UTC day, subject to the shared daily earning cap (currently 150 TAP) | | Duplicate protection | Both paths use the same daily award key. They cannot pay twice | | Scores / duel results | Rankings only; never determine TAP payout | The host heartbeats every 15 seconds while visible. Games do not send elapsed time or reward amounts. The server decides eligibility and caps. A returning session may already have earned its reward. Keep gameplay usable on `ignored`, `duplicate`, `capped` and `signin_required`. ```js TokenApps.on("points", function (result) { // SDK event response only. Automatic farming is displayed by the host. if (result.status === "awarded") showPoints(result.delta); }); ``` ## Save, load and browser storage External HTTPS frames retain their origin through `allow-same-origin`; platform-hosted relative frames do not. Browser privacy settings can still deny cookies, localStorage or IndexedDB. Guard storage access and keep an in-memory fallback. Never assume third-party storage works. `TokenApps.canSave()` checks availability. Signed-in players can call `load()` and `save(json)` for one cross-device slot per game (up to 64KB serialized). Catch `guest` and `expired`; a new session can arrive after expiry. A repeated session event must not overwrite an active run with an older save. ## Optional capabilities | Capability | Calls / outcomes | |---|---| | Leaderboard | `submitScore(number)`, then `score_ack` | | Rewarded ad | `requestRewardedAd("revive")`; grant only on `rewarded` | | Continue | `requestContinue()`; continue on `paid` or `rewarded` | | Products | `getProducts()`, `requestPurchase(sku)`, `getInventory()` | | Matchmaking (v1.4) | `match.find()`, wait for match `ready`, then `send()` / `report()` / `leave()` | Ads, purchases and matchmaking may be unavailable. Keep a solo/replay path. Prices belong to the platform; restore purchases using inventory and grant only on `purchased` / `already_owned`. A duel changes rating, not TAP. Use feature detection for optional APIs. `TokenApps.on()` registers a callback; v1.4 has no `off()` method. Register once in a persistent integration module; avoid accumulating subscriptions every time a React view mounts. ## Embed and release checks - Permit `https://tokenapps.io` in the game's HTTP CSP `frame-ancestors`. Remove conflicting `X-Frame-Options: DENY/SAMEORIGIN` on the player document. - Fit the **iframe viewport**, not the monitor. Resize without resetting the run. Fullscreen and rotation are optional enhancements. - Provide touch controls for every mobile action. No hover-only or keyboard-only mobile path. - Test guest boot, blocked storage, audio unlock, background/resume, replay and unavailable SDK features. - Test inside the tokenApps player on real supported browsers, not only in a standalone tab. - An automatic header embed check does not certify input, layout, audio or SDK behavior. [Detailed device checklist](/dev/devices) · [Feature guide](/dev/guide) · [Specification](/dev/spec) · [Submit](/submit) --- # Game-only Embed Implementation (embed) # Game-only embeds: implementation and handoff Updated: 2026-09-18. Compatible with SDK runtime v1.4.0. This is a layout and delivery guide, not a new SDK protocol version. ## SDK integration and embed readiness are separate The SDK connects identity, scores, saves and optional platform features. Loading it does **not** remove a website header, create `/embed`, resize a canvas, translate game content or remove scrollbars. The game team implements the embedded presentation; tokenApps loads the registered player URL. If a full homepage is registered, its hero, navigation, rankings, footer and FAQ appear inside the iframe too. Making the host wider cannot remove those sections. Cross-origin rules prevent tokenApps from rewriting the game's DOM or CSS. | Responsibility | Game project | tokenApps | |---|---|---| | Game-only route and responsive game | Implement and deploy | Load the supplied URL | | Board, HUD, touch controls and game dialogs | Fit inside the frame | Provide available frame space | | Identity, scores and saves | Call the existing SDK | Authenticate and process requests | | Farming | Keep play usable; no local TAP payout | Measure eligible play and settle rewards | | Review | Supply evidence from supported devices | Review the build and listing | ## 1. Deliver a real game-only URL Recommended structure: ```text https://game.example.com/ Public website: introduction, SEO, FAQ https://game.example.com/embed Game board + HUD + essential controls ``` These are illustrative addresses. `/embed` must be implemented and deployed; adding the path or `?embed=1` to an existing URL does nothing unless the game handles it. A different path is fine. If `/` already serves only the game, keep it. Reuse the existing game component, engine and save format. Do not create a second copy of the game logic. Keep pause, sound, help, restart, score, remaining lives/coins and touch actions available. Open secondary help or rankings in a bounded dialog when needed instead of adding long sections below the board. Do not place a second iframe around the game: the SDK communicates with its immediate parent. Load the SDK in the document that tokenApps directly embeds. ## 2. Separate the website layout For React/Vite, route `/embed` directly to the shared game component without the marketing layout. Configure the deployed server's SPA fallback so direct navigation and refresh on `/embed` work. For plain HTML, an `embed.html` document using the same game module is sufficient. For Next.js App Router, keep the root layout minimal and move website chrome into a route group: ```text app/layout.tsx html, body, shared providers; no site header/footer app/(website)/layout.tsx Website header, navigation, footer app/(website)/page.tsx Public homepage at / app/embed/page.tsx Shared game client component components/Game.tsx Existing game engine and controls ``` Route groups do not add a URL segment. Do not leave another `app/page.tsx` competing for `/`. Put browser-only engine initialization in a client effect, never during server rendering. Clean up the engine, observers, input listeners and animation frames on unmount. React development Strict Mode may run setup/cleanup again; avoid duplicate canvases and game loops. Scope embed CSS to its route or root. Do not globally hide scrolling on the public website. See the [Next.js route group reference](https://nextjs.org/docs/app/api-reference/file-conventions/route-groups). ## 3. Allocate space before scaling the board The iframe's viewport is the available size. It is smaller than the browser window because tokenApps has its own header and controls; its size changes on rotation and fullscreen. Do not subtract tokenApps header heights inside the game, use `screen.width`, or assume a fixed 900px height. Start with a game-only document containing three rows: ```html
Score, lives and pause
``` ```css /* Only in the game-only document; every framework wrapper needs a height. */ html, body, #root { width: 100%; height: 100%; margin: 0; } body { overflow: hidden; overscroll-behavior: none; } #embed-root { position: fixed; inset: 0; box-sizing: border-box; display: grid; grid-template-rows: auto minmax(0, 1fr) auto; gap: 8px; min-width: 0; min-height: 0; padding: max(8px, env(safe-area-inset-top)) max(8px, env(safe-area-inset-right)) max(8px, env(safe-area-inset-bottom)) max(8px, env(safe-area-inset-left)); } #board-space { position: relative; min-width: 0; min-height: 0; } #board { position: absolute; inset: 0; display: block; width: 100%; height: 100%; touch-action: none; } .game-hud, .game-controls { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } .game-controls button { min-width: 44px; min-height: 44px; } @media (max-height: 360px) { #embed-root { gap: 4px; } .optional-description { display: none; } } ``` The middle row gets the space left after the real HUD and buttons. `min-height: 0` prevents a grid/flex child from forcing the document taller. `overflow: hidden` is a final containment rule, **not** a fix for controls below the screen. Check every essential control remains visible and usable. On very short landscape frames, simplify the HUD or move controls beside the board. Do not shrink touch targets below 44 CSS pixels to preserve decorative text. If a configuration is unsupported, show a visible rotate/resize instruction with an exit path and disclose the limitation; do not claim that configuration passed. ## 4. Resize the renderer, not the game state Observe `#board-space` with `ResizeObserver`; its content size excludes the HUD. For a fixed logical board: ```js const scale = Math.min(availableWidth / designWidth, availableHeight / designHeight); const offsetX = (availableWidth - designWidth * scale) / 2; const offsetY = (availableHeight - designHeight * scale) / 2; ``` Use **contain**, allowing empty margins. `cover` crops the board. For canvas 2D, set drawing-buffer width/height to CSS dimensions multiplied by a capped device pixel ratio, then apply a rendering transform. Changing canvas width/height resets its context; redraw existing state rather than creating a new round. Recompute on frame resize and pixel-ratio changes. Disconnect observers/listeners on teardown. Convert pointer input back to logical coordinates using the canvas bounding rectangle, offsets and scale: ```js const x = (event.clientX - rect.left - offsetX) / scale; const y = (event.clientY - rect.top - offsetY) / scale; // Ignore taps in the letterbox margins, outside [0, designWidth] x [0, designHeight]. ``` For WebGL/Three.js, resize the drawing buffer and update the camera projection; for Phaser/Pixi, use the installed engine version's resize/scale APIs. Do not replace engine sizing with CSS-only stretching. Keep physics, timers, score and random seed intact while resizing. The [ResizeObserver reference](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver) describes observing element dimensions. Avoid resize loops: resize the renderer, not the observed container based on its own output. ## 5. Preserve the SDK connection Load `https://tokenapps.io/sdk/v1.js` once in the game document. Register session listeners before `ready()`. Start the game once for guests even if the SDK fails to load or the host never responds. A session event can repeat after login or token refresh; it must not reset an active run. SDK v1.4 has no `off()` method. In a React application use one persistent integration module: register one SDK callback, then fan out to a local subscriber set whose subscriptions your components can remove. Do not register another `TokenApps.on()` handler on every mount. The package README includes this pattern. Keep existing score/save/reward calls at their real gameplay boundaries. Changing routes must not submit a score or reward. Do not call `track("session_complete")` on page load, resize or every render. Platform farming is host-managed and shared with the existing completion award. There is no SDK `resize()`, `setEmbedMode()` or host auto-height message in v1.4. Do not invent calls or request that tokenApps grow the iframe to fit an entire homepage. Implement sizing in the game. An embed query flag is presentation state, not proof of authentication; use the SDK session for identity. ## 6. Allow framing on the deployed game Return this HTTP response directive for the game document, merged into the project's existing CSP: ```http Content-Security-Policy: frame-ancestors 'self' https://tokenapps.io ``` Remove conflicting `X-Frame-Options: DENY` or `SAMEORIGIN` on that document. `frame-ancestors` must be an HTTP header; a meta tag is not sufficient. Add exact approved staging/partner origins only when needed. Check the final response after redirects; a CDN or hosting rule can override app headers. Preserve the other CSP directives, allowing the SDK script and any API connections your game uses. CORS and framing permission are separate. Inspect the browser console for blocked frames/scripts/connect requests. See [MDN frame-ancestors](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy/frame-ancestors). ## 7. Run the reference and test your build Open the [runnable sizing example](/sdk/examples/embed.html). It demonstrates a canvas, HUD, touch/keyboard input, contain scaling, SDK session subscription and guest boot. It is a sizing reference, not a replacement game, and sends no scores or reward events. Save its HTML to inspect or adapt the source. Test your deployed URL in an iframe before submission. A local wrapper exercises layout but does not supply a tokenApps SDK session. For an external HTTPS game, this matches the player's current sandbox permissions: ```html ``` Use an allowed test origin in the game's framing policy. Do not weaken the production policy just to make a wrapper work. Verify the final build in the actual tokenApps preview/player as well. Test these **iframe dimensions in CSS pixels**, not just browser window dimensions: 310x360, 380x640, 558x160, 834x220, 740x780, 1340x560 and 1894x875. These are stress cases, not a stable host size contract. Test both guest and signed-in host UI, which may leave different heights. Report unsupported small frames honestly. In browser developer tools, select the **game frame's execution context**, then run: ```js const root = document.documentElement; console.table({ frameWidth: innerWidth, frameHeight: innerHeight, pageWidth: Math.max(root.scrollWidth, document.body.scrollWidth), pageHeight: Math.max(root.scrollHeight, document.body.scrollHeight), }); ``` Pass only when the page has no unintended overflow **and** all controls are visible and clickable. Hidden clipping can pass a scrollbar check while still failing the game. Check internal scroll containers too. Intentional help/settings dialog scrolling is acceptable; gameplay should not require scrolling. Complete boot, play, pause, resume, game over and replay; then rotate/resize mid-round and verify the same score/state continues. Test touch, keyboard focus, audio unlock, blocked storage, SDK/network failure, guest-to-login transition and denied fullscreen. Include real supported iOS/Android devices; desktop emulation alone does not certify them. ## 8. Register the URL after deployment 1. Deploy `/embed` and verify direct access, refresh, framing headers and gameplay. 2. Submit that exact URL as the primary player URL. Existing games need their registered player URL updated through the review/operator workflow; downloading or saving a device brief does not change it. 3. Mobile/PC URLs in the brief are review references only. If using separate builds, the primary URL must route to the right build and preserve embed mode. 4. Recheck the actual tokenApps play page. SDK status "Integrated" and a successful header check do not certify responsive layout. Do not enter `https://tokenapps.io/app/your-slug/play` as the game's player URL: that embeds the host player inside itself. ## Troubleshooting | Symptom | Inspect and fix | |---|---| | Website header/FAQ inside the player | Wrong entry URL or website layout inherited by `/embed` | | Scrollbar persists after SDK installation | Inspect the game's root and overflowing containers; SDK does not manage layout | | No scrollbar, but buttons are missing | Overflow was hidden before fitting HUD/board/controls | | Board fits but taps hit the wrong location | Undo contain scale and offsets when mapping pointer coordinates | | Game resets on rotation/login | Engine recreated by resize or repeated session callbacks | | Blank frame or "refused to connect" | Final URL, HTTPS, CSP, X-Frame-Options, console and runtime errors | | Works in a tab but stalls inside tokenApps | Storage exceptions, nested iframe, unavailable SDK/auth dependency | | `/embed` works after navigation but refresh is 404 | Missing deployed route or SPA fallback | ## Copy this into your AI coding tool ```text Update this existing game for tokenApps embedding. Inspect the current routing, layouts, engine, SDK initialization and deployment configuration first. 1. Implement a real /embed route (or an equivalent documented game-only URL). Reuse the existing game logic, assets and save schema. Preserve the public site. 2. Exclude website header/nav/hero/footer/FAQ and long ranking sections from embed. Keep board, score, lives/coins, pause, sound, restart and all required controls. 3. Fit the iframe's available width AND height. Allocate HUD and controls first, use ResizeObserver and contain scaling for the board, update canvas/WebGL resolution and input coordinates. No CSS stretching or hidden clipped buttons. 4. Support mobile touch and PC keyboard/mouse as claimed. Keep touch buttons >=44 CSS px. Handle short landscape frames, rotation and fullscreen rejection. Preserve the current round on resize. Report unsupported configurations. 5. Keep SDK v1.4 integration. Register persistent handlers once before ready(). Guest boot must work without SDK/network/storage. Repeated session events must not restart play. No nested iframe, duplicate SDK or invented resize API. 6. Preserve real score/save/completion behavior. Do not add TAP rewards or send completion on load/resize. Use platform identity; no second embedded OAuth flow. 7. Deploy the route. Configure HTTP frame-ancestors for https://tokenapps.io and remove conflicting framing headers on this document. Preserve other CSP rules. 8. Verify iframe sizes 310x360, 380x640, 558x160, 834x220, 740x780, 1340x560, 1894x875. No unintended page scroll, no clipped controls. Test boot -> play -> pause -> resume -> game over -> replay, resize mid-round, guest/login, blocked storage, audio and denied fullscreen. 9. Return the deployed game-only URL, changed files, device/browser/build versions, PC/mobile screenshots, passed/failed/not-tested results and remaining issues. Do not claim real-device testing without performing it. Read https://tokenapps.io/dev/sdk and https://tokenapps.io/dev/embed first. Reference source: https://tokenapps.io/sdk/examples/embed.html ``` [SDK handoff](/dev/sdk) · [Mobile & PC checklist](/dev/devices) · [Submission form](/submit) --- # SDK Guide (guide) # TokenApps Game SDK Guide > Updated 2026-09-18: SDK installation and game-only embedding are separate deliverables. Implement the [game-only embed guide](/dev/embed), 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](/dev/spec)** — 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: ```html ``` ## 2. Sign-in (Identity) Specification Send `ready` and the host answers with a `session` message: ```jsonc { "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. ```js // 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 ```js 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](/dev/policy)). - **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](/dev/sdk) and [Mobile & PC checklist](/dev/devices). 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. ```js // 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](/dev/spec). ## 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. ```js 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](/me/apps)), and the game speaks only in SKUs. The confirmation sheet, deduction, and record-keeping are all handled by the platform. ```js // 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. ```js 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](/dev/spec). ## 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": , "updatedAt": "…" } PUT {api}/api/game/save Authorization: Bearer {token} Content-Type: application/json body: { "data": } ← 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. --- # SDK Specification (Normative) (spec) # TokenApps Platform SDK — Formal Specification (SDK_SPEC) v1.4 > **Document status**: This document is **normative**. The [SDK Guide](/dev/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's `v: 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: ```jsonc { "__tokenapps": 1, "v": 1, "type": "", "payload": { … } } ``` - `targetOrigin` is `"*"` 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`. - Messages without `__tokenapps: 1`, or whose `type` is 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) ```typescript /** postMessage envelope */ type Envelope = { __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; 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; 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_")` = any other HTTP error. ```ts /** 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; 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=`. | 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: , updatedAt }` | 401 `invalid_token` | | PUT | `{ "data": }` (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.kind` is `"sync"`, `match.status` is `"pending"`); otherwise `202 { status: "queued", queueId, expiresAt }`. After `timeout` seconds (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"` answers `503 { 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: true` and moves the rating (Elo, K = 24) only when the leaderboard value this player recorded via the `score` message 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's `outcome` (or, when omitted, the one read from the two scores) agrees with the other — win/lose or draw/draw — the match is `finished` and both ratings move (Elo, K = 32). If not, it closes `disputed` and nothing moves. Three `disputed` in a row with the same opponent suspends both players from live matches for a day. `verified` is 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 closes `abandoned` with 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 `leave`s 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 sends `matched { queueId, matchId }` and `fallback { 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 is `pending` or `active`. Presence keys are `slot-1` / `slot-2`, game messages are broadcast `msg { d }`, and status changes are `status { matchId, status, seed?, players[] }` sent by the database. ### 6.14 Duel (Matchmaking) Conformance Requirements - Initialise the game's randomness from `ready`'s `seed` and 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 on `kind` for 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()` before `report()` (SHOULD) — that is what makes an async report `verified`. - Keep `send()` within 8 per second and 4 KB (MUST). Anything over is dropped. - On `opponent_left`, wrap up the round and `report()` (SHOULD). - The game must remain playable solo when `find()` is refused (MUST). `unavailable` is 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 `declined` or `unavailable`, 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_result` as-is; instead, have the client send the `token` it received in the session up to the game server, have the game server call `GET {api}/api/game/inventory` with 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`, and `failed` alike, 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 `status` of `ad_result` and SHOULD NOT branch on `reason` (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_complete` `ignored` when 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](/dev/sdk) and [Mobile & PC requirements](/dev/devices). - If `session.player` is absent, the session is a guest session. `canSave() === false`, and `save()`/`load()` reject with `Error("guest")`. - A guest's `track()` receives `points.status = "signin_required"`. The event itself is still recorded for measurement. - A guest's `request_ad` receives `ad_result.status = "unavailable"` (`reason: "signin_required"`). - T4 restated: the game MAY **explain** these three reductions, but it must not block a guest's play. --- # Mobile, PC & Developer Handoff (devices) # Mobile, PC & developer handoff > Updated 2026-09-18: SDK installation and game-only embedding are separate deliverables. Implement the [game-only embed guide](/dev/embed), then register its deployed URL. A homepage with SDK installed can still overflow inside the player. Updated: 2026-09-17. Applies to the v1.4 SDK and the current tokenApps player. ## Deliverables to request | Deliverable | What the developer must provide | |---|---| | Primary URL | HTTPS game-only entry point, directly playable in an iframe | | Device support | Mobile, PC, tablet; mark unsupported devices honestly | | Build delivery | One responsive build, or separate mobile / PC test URLs | | Player orientation | Landscape or portrait for the primary player frame | | Input | Touch for mobile, keyboard / mouse for PC; optional gamepad | | Languages | English first where supported; list all actual game languages | | Integration | Not started / in progress / integrated, plus individual SDK features | | QA | Tested browser versions, device/OS, completed checks and known issues | | Artwork | Icon, cover, up to three real gameplay screenshots | | Contact | Developer email, build version/date and target fix dates in notes | Open the [registration form](/submit) and use **Download developer brief** even before signing in. It exports the current answers and a checklist to send to a developer. Downloading does not send messages. Submit the finished form when ready. Owners of existing games use **Mobile & PC requirements** in [My games](/me/apps). Operators see the private brief in the app detail panel. ## One entry point, multiple devices Prefer a responsive player URL. If the game has separate builds, provide both URLs for review and route from the primary URL yourself. Submitted mobile/PC URLs are not automatically wired into the platform player. Avoid routing purely by user agent: desktop windows can be narrow, tablets can have keyboards, and users can rotate mid-game. Base layout on the available frame size and input capability. Keep saved progress compatible between builds, or document differences. The host records one orientation per app today. A mobile portrait + PC landscape combination needs a primary build that adapts within the chosen player container. Report device-specific orientation requirements in notes; there is no per-device orientation selector in the live player yet. ## Fit the frame and remove unintended scroll The iframe is smaller than the browser window because tokenApps has its own controls. Never size the game from `screen.width` or a fixed desktop height. Use the actual container size. This is a starting point for a **game-only** document; do not apply it to an entire website containing FAQ or account pages: ```html
``` Use `ResizeObserver` on `#game-root` to call your engine's resize method. Update canvas drawing-buffer dimensions for devicePixelRatio separately from its CSS size; keep input coordinates and the camera in sync. Cap rendering resolution on slower devices. Changing canvas width/height resets its context, so redraw without recreating game state. For a fixed-ratio board, calculate a contain scale (letterboxing is acceptable). HUD, pause, score, retry and touch controls must remain inside the visible area. Do not hide an oversized canvas behind `overflow:hidden` and call it responsive. Internal menus may scroll intentionally; the play surface should not create a second page scrollbar. ## Input, audio and lifecycle - Use pointer events for mouse and touch. All mobile actions need visible touch controls; no hover-only action. - Limit `touch-action:none` and gesture prevention to the game surface. Do not globally disable zoom or native form interaction. - Keep touch targets at least 44×44 CSS px with space between them. - Unlock audio on tap/click. Handle failed `play()` / suspended AudioContext without blocking the game. - Pause appropriately on `visibilitychange` and resume safely. Avoid counting hidden time yourself. - Test resize, orientation change, browser toolbar expansion, device rotation and fullscreen rejection. Fullscreen is optional; a normal iframe must remain playable. - Guard localStorage, cookies and IndexedDB with fallbacks. Third-party frames can retain their origin but browser policies can block storage. - Do not restart a round on every SDK `session` event. Login and token refresh can repeat it. ## QA matrix These are review targets, not a promise that the host always gives these exact dimensions. Test the actual iframe bounds, including shorter heights. | Class | Viewports to exercise | Browsers | |---|---|---| | Small phone | 320×568, 390×844 | iOS Safari, Android Chrome on actual supported devices | | Phone landscape | 844×390 | Rotation, visible HUD, no unwanted scroll | | Tablet | 768×1024, 1024×768 | iPad Safari / Android Chrome as claimed | | PC | 1366×768, 1920×1080, narrow resizable window | Chrome, Edge, Firefox, macOS Safari as claimed | For each claimed device: boot → play → pause/resume → game over → replay. Also verify guest play, blocked storage, SDK unavailable, login during play, cloud save after reload, and optional features declining/unavailable. For each result record device, OS, browser version, build version and date. Emulation is useful for layout; it is not proof of real-device audio or fullscreen support. ## Farming, assets and release The host measures time and settles automatic farming at 60 seconds. The existing SDK completion path has a 45-second threshold and shares the same daily award. Never add a second local TAP wallet or promise a reward from a client timer. See [SDK handoff](/dev/sdk). Upload PNG/JPEG/WebP only: each file at most 2MB and combined submission images at most 2.5MB. Recommended icon 512×512 and cover 1600×900 (16:9); include mobile and PC gameplay screenshots where supported. Do not bake tiny labels into artwork. Unfinished SDK work can be reported honestly; it does not automatically block submitting for review. Approval and publishing are separate. A successful HTTP embed check only checks headers, not whether the game fits or can be controlled. ## Copyable request ```text Please deliver: - Primary HTTPS player URL (game-only) - Mobile / PC / tablet support and unsupported cases - Responsive build or device URLs, routing handled by primary URL - Primary orientation, touch + keyboard/mouse controls - SDK 1.4 integration status and feature list - Guest play, blocked storage, no unintended scroll, resize/rotation, audio checks - Device / OS / browser / build versions, known issues and fix dates - English game name/copy, supported languages, icon/cover/gameplay screenshots - Developer contact email Documentation: https://tokenapps.io/dev/sdk Device checklist: https://tokenapps.io/dev/devices Submission / downloadable brief: https://tokenapps.io/submit ``` --- # Assets & Listing Guide (assets) # Asset and Listing Guide Submission form: 2MB per image, **2.5MB combined** across icon, cover and up to three screenshots. Recommended cover source: 1600×900. How your game looks in the catalog is determined by the assets and text you register. This document covers the specifications for the icon and cover image, and where and how the title, tagline, and description appear. > **PDF for your art team**: a diagram edition of this guide (production > specs, how assets are displayed, GOOD / DO NOT examples) you can hand > over as-is. [English PDF](/guides/tokenapps-asset-guideline-en.pdf) · > [한국어 PDF](/guides/tokenapps-asset-guideline-ko.pdf) ## 1. Icon The image shown in the most places: catalog lists, search results, the center of tiles that have no cover, favorites, and more. | Item | Specification | |---|---| | Ratio | 1:1 square (other ratios are center-cropped) | | Recommended size | 512×512px or larger (a 1024×1024px source is recommended) | | Formats | PNG, JPG, WebP | | File size | 2MB or less | | Background | **Fully opaque.** Icons sit on light/dark theme plates, so any alpha (transparent) area lets the theme background bleed through — visibility drops, and the icon looks different on every screen | | Corners | **Square-corner source.** The platform applies rounding uniformly at display time (mask + outline). Pre-rounding the source makes your curve disagree with the platform mask and leaves empty gaps at the corners | Production tips: - **Keep the key elements (symbol, brand mark, character) within the central 80%** — at 512px that is the central 410×410px safe area. Icons are also shown at small sizes (24–64px), so edge detail won't be visible and corner masking can clip it. Graphics that spill past the safe area read as visually off-balance. - Keep text to a minimum. The game name is always displayed separately next to or below the icon. - A screenshot simply scaled down turns muddy at small sizes — we recommend dedicated art centered on a symbol or character. How to register: upload in the icon step of the [submission wizard](/submit). After listing, the signed-in owner can replace it anytime under [My games](/me/apps) — addresses are content-hash based, so the new image appears on every screen the moment it's replaced. ## 2. Cover (Tile Art) The landscape image used as the background of game tiles on the home and category pages. With a cover, the tile is filled with an actual scene from your game; without one, it falls back to the icon plus a gradient. **Tiles switch to a live game preview on mouse hover**, so the cover is responsible for the "static first impression." | Item | Specification | |---|---| | Ratio | 16:9 | | Recommended size | 1280×720px or larger | | Formats | PNG, JPG, WebP | | File size | 2MB or less | Production tips: - **Put the main graphic in the center, and keep the bottom 25% clear.** The game name and tagline are overlaid, along with a gradient, at the bottom of the tile — text or a logo placed there gets clipped or washed out. If you include a logo, top or center placement works best. - We recommend actual game scenes (art based on in-game screenshots) — they lead naturally into the live game preview on hover. Scenes that don't exist in the game are grounds for rejection in review. - **Don't pack in detail.** On mobile the cover renders at less than half size — the more text it carries, the less anything reads. One strong scene communicates best. - **Avoid pure-white or pure-black flat backgrounds.** The service renders in both light and dark themes, and in one of them the image boundary disappears into the page background. - **Avoid content that is only valid for a period** (seasonal events, update teasers) — it becomes an image you have to keep replacing. How to register: upload in the cover step of the [submission wizard](/submit), or replace it anytime after listing under [My games](/me/apps). ## 2.5 Screenshots (optional) Shown as a horizontal gallery at the top of the game's detail page. If the cover is the first impression, screenshots are the evidence of what a player will actually see. | Item | Specification | |---|---| | Count | Up to 3 | | Ratio | 16:9 recommended (other ratios are center-cropped) | | Formats | PNG, JPG, WebP, 2MB or less each | - **Unedited game frames work best** — a gameplay moment, a results screen, the core UI; three is plenty. - Unlike the cover there is no name overlay, so the bottom-margin rule does not apply. How to register: upload in the screenshots step of the [submission wizard](/submit), or replace all three at once anytime after listing with the "Screenshots" button under [My games](/me/apps). ## 3. Text Listing | Item | Length | Where it appears | |---|---|---| | Game name | Up to 80 characters (30 or fewer recommended) | Every screen. Truncated to a single line on tiles and lists | | Tagline | Up to 160 characters | Bottom of tiles, lists, search results | | Description | Up to 6,000 characters | The game detail page and the game info block on the play page | - **The tagline needs only one of Korean or English.** An empty language is substituted with the other. That said, filling in both improves exposure quality in each language market. - The description is shown as-is on the play page — write the controls, the goal, and the features in separate paragraphs. Line breaks are preserved. - Under the review criteria, a description that doesn't convey what the game is will receive a change request. See [Review Policy §2](/dev/policy). ## 4. What the Spec Card Shows The spec card on the play page is generated automatically from your registration info: | Item | Source | |---|---| | Developer | The domain of the game URL (clicking goes to the original site) | | Recommendations | Player recommendation count (tallied automatically) | | Released | Catalog publication date (recorded automatically) | | Hosting | TokenApps-hosted / externally hosted (determined automatically) | | Platforms | The supported platforms entered at registration | | Languages | The supported languages entered at registration | ## 5. What Not to Do - Putting other stores' badges, award marks, or ranking claims into your images - Using scenes that don't exist in the actual game (exaggerated promotional art) as the cover - Using images or fonts whose copyright hasn't been cleared — this is grounds for rejection in review - Excessive text on the icon and cover — the platform always displays the name and tagline separately --- # Developer Account Guide (account) # Developer Account Guide TokenApps has no separate developer signup. The TokenApps account you play with is your developer account — the moment you submit a game, that account becomes its owner. ## 1. Creating an account Any TokenApps sign-in method works: - **Google** — sign in with your Google account. New TokenPost sign-in/linking is disabled. - **Social login / wallet connection** — standard sign-in via Privy Every method carries the same permissions. For team submissions we recommend submitting from an account the team can manage together — ownership belongs to the submitting account (§3 below). ## 2. What the account can do | Capability | Where | |---|---| | Submit a game | [Submission wizard](/submit) | | See your submissions and their status | [My games](/me/apps) | | Replace icon and cover (live immediately) | [My games](/me/apps) | | Register and price products (SKUs) | [My games](/me/apps) → Products | | Request name/tagline/description edits (approval-gated) | [My games](/me/apps) → Edit listing | | Your game's metrics (players, sessions, D1) | [My games](/me/apps) → Metrics | | TAP (TokenApps Points) balance and activity | My page | Products are self-service in the Products panel; name, tagline, and description edits are filed under "Edit listing" and apply after operator approval (artwork and products are instant). Changes that alter what the game is may be rejected — see [Review Policy §6](/dev/policy). ## 3. Ownership - A game's owner is **the account that submitted it**. Only the owner sees and manages the game under [My games](/me/apps). - For ownership transfers (staff changes, acquisitions), contact cs@tokenpost.kr — the operations team processes them after identity verification. - Multi-member roles and permissions are in preparation. Until then, share the owning account within your team. ## 4. Security - Sign-in sessions are managed with server-signed cookies, and TokenApps never stores your password — authentication is handled by each provider (Google, social, wallet). - The player identifier a game receives is a per-game pseudonym, so developers can never cross-reference users with each other — the full model is in [the specification §3](/dev/spec). - If you suspect your account is compromised, contact cs@tokenpost.kr immediately; we can lock management functions on your games. ## 5. Settlement information (upcoming) When monetization (ad revenue share) opens, this account will gain a settlement setup step — business/individual status, a payout bank account or USDC address, and tax documentation. Terms and process will be announced with the ad network integration; nothing is collected before then. ## 6. Winding down - To unlist a game, contact cs@tokenpost.kr. - Account deletion requires deciding what happens to owned games first (transfer or unlisting). Personal data handling follows the [Privacy Policy](/privacy). --- # Review Policy (policy) # Review Policy This document explains the process and criteria for listing a game on TokenApps. The operations team actually plays your game and makes the final decision; automated AI pre-checks are used only as reference material to assist the reviewers. ## 1. Process ``` Submit (/submit) → Review → Basic launch → Full launch 1–3 days metrics accrue full exposure ``` **① Submit** — In the [submission wizard](/submit), register the name, URL, description (at least one of Korean or English), category, icon, and cover. Five minutes is plenty, and your text in progress is saved as a draft. The icon and cover can be replaced anytime after listing under [My games](/me/apps). **② Review** — The operations team plays the game directly at the submitted URL and judges it against the criteria in §2 and §3 below. If something needs work, we'll let you know along with the reason for the rejection. **③ Basic launch** — Approved games go public in the Basic stage first. They're exposed in the home "New Games" section and via direct links, and play, playtime measurement, and TAP (TokenApps Points) accrual all work. What remains closed is full display in recommendations and categories, and monetization (ads and items). Use this stage for your team's internal QA and for building an initial audience. **④ Full launch** — Once play metrics (player count, average session length, next-day return rate) accumulate, the operations team promotes the game to Full. It's displayed across every exposure surface, and ad and item monetization opens. Promotion is a human decision informed by the metrics — it does not happen automatically. If you're curious about your numbers, ask us anytime. ## 2. Approval Criteria - **It runs**: The game must actually run at the submitted URL. If it's embedded, the screen must render inside TokenApps' sandboxed iframe (see the technical requirements in §5). - **Guest play**: Users who arrive without signing in must be able to play the game fully. Forcing your own login or the platform login inside the game results in rejection. Sign-in prompting is the platform's job, handled with a post-play invitation card. - **Description quality**: There must be a description that lets players understand what the game is. The description is shown as-is in the game info block on the play page, so the better it's written, the more your game benefits. - **Category fit**: The category you choose must match what the game actually is. ## 3. Rejection Reasons Submissions are rejected for any of the following: - **Gambling-like mechanics** — gambling with cash or cash-equivalent value at stake, or structures that imitate gambling - **P2E cash-out** — structures that convert in-game rewards into cash or crypto assets (a potential violation of Korea's Game Industry Promotion Act (게임산업법)) - **Adult content**, or content that promotes hate or violence - **Suspected copyright or trademark infringement** — clones of existing titles, assets used without permission - **Forced ads** — designs where watching an ad is required to play or progress. Ads must always be the player's choice, and the game must proceed normally after the SDK's `unavailable` response - **Security threats** — malicious code, phishing, or attempts to manipulate the platform's measurement and award systems Borderline cases are not rejected outright — we ask for changes instead. For structures where the call is genuinely unclear (for example, probability-based elements), asking us before you submit will speed up the review. ## 4. Reward Rules and Legal Constraints This section does not limit your design freedom as a game studio — it is the line the platform itself must hold because of the prize provisions of the Game Industry Act: - TAP awards currently operate **per session** ("this player completed a round of this game today"). Platform-TAP rewards tied to in-game performance — score, wins/losses, ranking — are not granted until the gambling-regulation review is complete; performance-linked rewards in closed, non-redeemable TAP is a roadmap item that can open together with that legal review. Performance-linked rewards in a game's own currency are always the game's own design decision. - Scores submitted via `submitScore()` are for display and rankings, and are never connected to TAP. - **Funding rewards sit on the same principle.** Users back a game's funding campaign with closed TAP; if the goal is reached the reward pool flows back in contribution order, and if it is missed every backed TAP is refunded in full. No cash moves at any step, which keeps campaigns outside securities-style crowdfunding regulation — cash funding will not open without its own legal review. - No path of any kind exists by which cash or crypto assets are paid out to players. (Developer revenue settlement is separate, and opens together with the ad network integration.) ## 5. Technical Requirements - **HTTPS** is required. - **Embedded games**: The `X-Frame-Options` header and CSP `frame-ancestors` in your responses must not block framing, and the game must actually render inside the sandboxed iframe. Even without those headers, code sometimes detects the frame environment and refuses to render, so before submitting, verify directly at the play-page URL shape (`tokenapps.io/app//play`). Diagnosis steps for a blank screen are in the [FAQ](/dev/faq). - **Working in mobile browsers is recommended.** Supported platforms are listed on the spec card in the game info. - **SDK integration is not a review requirement.** Registration, play, and measurement all work without it. However, TAP accrual, saves, ads, and items open only for integrated games — we recommend starting with the 5-minute integration in [Getting Started](/dev/start). ## 6. Operating After Listing - **Product (SKU) registration and price changes, Full promotion inquiries, metrics inquiries**: cs@tokenpost.kr - **You are free to update your game.** As long as the URL stays the same, no re-review is needed. However, changes that alter what the game fundamentally is (a genre pivot, a change to the monetization structure) are subject to re-review, so please tell us in advance. - If a policy violation is found, the listing may be suspended after prior notice (urgent security issues such as malicious code are acted on immediately, with notice afterward). The appeal process is handled over email. --- # FAQ (faq) # Frequently Asked Questions > Updated 2026-09-18: SDK installation and game-only embedding are separate deliverables. Implement the [game-only embed guide](/dev/embed), then register its deployed URL. A homepage with SDK installed can still overflow inside the player. We've collected the TAP (TokenApps Points) where integrations actually get stuck. If your question isn't here, send it to cs@tokenpost.kr — you'll get an answer, and it will be added to this document as well. ## Integration ### Do I have to integrate the SDK to list my game? No. Registration, exposure, play, and playtime measurement all work without the SDK. Measurement is performed by the platform's player page outside the game, so no game-side code is needed at all. What the SDK unlocks are the next steps: play-based TAP accrual, account saves, rewarded ads, the continue sheet, and selling in-game items. Pick only the ones you need — the minimal integration is one script tag plus two function calls. Just follow the "5-minute integration" section in [Getting Started](/dev/start). ### Does the SDK throw errors when the game opens outside TokenApps, on our own site? No. The SDK only operates when a parent window (the TokenApps player) is present; without one it stays quietly idle. Calling `ready()` or `track()` simply does nothing — no errors occur. This means **you can deploy the same build to your own site and TokenApps at the same time.** There is no need to add branching code that checks "was this opened in TokenApps?" If you do want to tell the two apart, you can — by whether a `session` message arrives. If one arrived, you're inside TokenApps. ### We have a Unity / Godot / Cocos WebGL build and including v1.js is difficult. The SDK file is only a convenience layer wrapped around the postMessage protocol, so if your engine can handle postMessage directly, implementing the protocol without the SDK behaves exactly the same. The message envelope always takes this form: ```json { "__tokenapps": 1, "v": 1, "type": "ready", "payload": {} } ``` The full message table (12 types in both directions) and the REST spec are in [Specification §4–6](/dev/spec). For Unity, instead of `Application.ExternalCall`, we recommend calling `window.parent.postMessage(...)` from a jslib plugin, receiving responses with `window.addEventListener("message", ...)`, and forwarding them to your game objects with `SendMessage`. ### Where do I test? You can test on the real play page (`tokenapps.io/app//play`) immediately after submission → approval (Basic). The Basic stage is reachable by direct link, so you can use it as-is for your team's internal QA. If you want to verify your integration before approval, the source of the [reference game](/app/tower-drift/play) is a working example of the entire contract — you can carry the same calls over verbatim. ### Do I install the SDK from npm? No — one script tag is the official install: ```html ``` No build tool, bundler, or package manager is involved, the v1 path is backward-compatible, and it always serves the latest v1.x (updates require no work on your side). A single static HTML game and a Vite/React project integrate the same way. In bundler projects, use `window.TokenApps` directly — if you want types, copy the TypeScript block from the [specification](/dev/spec). The repository contains v1.4 types and a loader in packages/sdk. As of 2026-09-17 it is not published on public npm. Use the CDN script or consume the repository package locally. ### Can I integrate with an AI coding tool? (MCP) We recommend it. If you are building your game with Claude Code, Cursor, or a similar tool, connect the TokenApps docs MCP server instead of pasting documentation: ```bash claude mcp add --transport http tokenapps https://tokenapps.io/api/mcp ``` Once connected, the AI can read the spec, guide, and FAQ directly (`list_docs` → `read_doc`) and search them (`search_docs`) — close to a one-sentence "integrate the TokenApps SDK" experience. The server is read-only and needs no authentication. For tools without MCP support, hand them [/llms.txt](/llms.txt) (the docs index) or [/llms-full.txt](/llms-full.txt) (the full corpus). ## TAP ### I sent `session_complete` but I'm getting `status: "ignored"`. The most common cause is the **45-second gate**. The platform measures playtime on its own, and if measured play for that day is under 45 seconds, it holds back the `session_complete` award. This is a safeguard against abuse where the event is fired the moment the game loads. There is nothing to handle on the game side. Awards are idempotent at once per day, so sending the event multiple times is safe, and once the user has actually played 45 seconds or more, the award goes through naturally when the next round ends. Sending it once at the end of every round is the standard pattern. Other `ignored` cases: you sent an event name that isn't on the whitelist. Currently the only award-eligible event is `session_complete`; other names are recorded for measurement only. ### Can I build a structure that awards more TAP for higher scores? Not at the moment. Rewards tied to performance (score, wins/losses, ranking) may conflict with the prize provisions of Korea's Game Industry Promotion Act (게임산업법), so this is blocked outright at the platform level. Awards are made solely on the session-level fact that "this player completed a round of this game today," and scores sent via `submitScore()` are used for display and rankings only. See [Review Policy §4](/dev/policy) for the full background. ### What are the TAP rate and the daily cap? They are server-side policy values, so we intentionally don't fix them in the documentation (they may be adjusted based on business decisions). Your game doesn't need to know the values — just handle the `points` response: ```js TokenApps.on("points", function (p) { // p.status: "awarded" (granted; includes p.delta and p.balance) | "duplicate" (today's award already made) // | "capped" (user's daily cap reached) | "ignored" | "signin_required" }); ``` `capped` and `duplicate` are normal responses too, not errors — passing over them silently, or showing a light notice, is the appropriate handling. ### What is Funding? Can my game run a campaign? Funding lets users back a game with closed TAP. If a campaign reaches its goal, the reward pool is distributed to backers in contribution order (more, and earlier, ranks higher); if it misses, every backed TAP is refunded in full. There is no cash payment or cash-out anywhere in the flow. For a game, it is a pre-launch demand test and an early-fan channel. Campaigns are currently opened by the platform on a curated basis — if you would like one for your game, tell us at cs@tokenpost.kr. Self-service campaign creation from the developer console is on the roadmap. ## Saves ### localStorage doesn't work. External HTTPS frames retain their origin, but browser privacy policies can block storage. Guard localStorage, cookie and IndexedDB access and keep an in-memory fallback. `TokenApps.save()` / `load()` is the standard storage in this environment. Data is stored on the user's account, so it carries over when they switch devices, with one slot per game and up to 64KB serialized. Guests having no saves is part of the spec — check with `canSave()` and skip silently. Examples are in [SDK Guide §3](/dev/guide). ### What happens if a save exceeds 64KB? It is rejected with `413 too_large` and the existing save is kept. A save is a summary of progress, not a complete log — if you need replays or detailed records, we recommend keeping them on your own game server and storing only a lookup key in the save. ### Can I use multiple save slots? Currently it's one slot per game. If you need multiple slots, build the structure inside the saved value yourself: `save({ slots: { a: {...}, b: {...} } })`. The save API's `data` envelope is deliberately designed with room for this kind of extension. ## Ads · Continue · In-Game Items ### `requestRewardedAd()` keeps returning `unavailable`. In most cases this is normal behavior. All of the following situations come back as the single `unavailable` response: | Situation | Notes | |---|---| | Guest (signed-out) player | An account is required to grant rewards | | An ad was already watched within the last 3 minutes | Global minimum interval | | The user's daily view limit was reached | Caps per user and per (user, game) | | The user's daily TAP cap was reached | A pre-check that prevents 0 TAP views outright | | The app is in the Basic stage | Monetization stays closed during the soft launch | | A placement that isn't allowed | For the games category, currently `revive` and `bonus_points` | There's no need to respond differently per reason — maintaining one path where the game proceeds without the ad covers all of them. This principle is also a review requirement. ### The continue sheet vs. calling ads directly — which should I use? For game-over continues, we recommend `requestContinue()`. The platform presents a three-option sheet — "Continue with TAP / Watch an ad to continue / Give up" — so the player gets a wide set of choices, and your game code ends with a single result branch. `requestRewardedAd()` is the lower-level API for other spots (bonus items, benefits other than revival). You can use both APIs in the same game. ### How do I register items (SKUs)? Can't the game set prices? Registration is self-service. Open your game's Products panel under [My games](/me/apps) and register or edit the SKU (lowercase letters, digits, `-`, `_`), the name (Korean and/or English), the price (in TAP), and the type (consumable = repeat purchase / durable = owned once) yourself. Price changes apply to future purchases only, and selling opens at Full launch — registering ahead of time is fine. Setting prices from the game side is **structurally impossible.** No purchase-related message has an amount field, and the payment sheet and billing use only the price registered on the server. To display prices in your game UI, fetch them with `getProducts()` and render those — they always match the server. ### I want to verify purchases on my game server. You're right not to trust the client's `purchase_result` as-is. The recommended pattern plays the same role as receipt validation on ONE Store or Google Play: 1. The client sends the `token` it received in `session` to your game server. 2. Your game server calls `GET {api}/api/game/inventory` with that token as Bearer authentication. 3. Check the owned-items list in the response, then grant with server authority. The token is valid only for that (user, game) pair, so the authority your game server gains is exactly that much and no more. See [Specification §6.8–6.9](/dev/spec). ## Review · Visibility ### My game renders as a white screen inside TokenApps. Check these two things, in order: 1. **Frame-blocking headers** — If the response carries `X-Frame-Options: SAMEORIGIN/DENY` or a CSP `frame-ancestors` restriction, the browser blocks the frame itself. Remove the header or allow tokenapps.io. 2. **Frame-detection code** — If the screen is still white with no such headers, the code often checks something like `window.top !== window` and aborts rendering. Depending on third-party cookies (requiring a login session to enter) produces the same symptom. The fastest check before submitting is to verify directly at the URL shape you'll receive after approval, `tokenapps.io/app//play`. If the screen stays empty for more than 6 seconds, the player automatically shows an "Open in new tab" prompt, but that is only an emergency escape hatch — embedded rendering is the review standard. ### My game was approved but doesn't appear in home recommendations or categories. Right after approval, your game is in the **Basic stage**. It's exposed in the home "New Games" section and via direct links, and play, measurement, and TAP all work. Once play metrics (player count, average session, return rate) accumulate, the operations team promotes it to Full — from then on it's displayed across recommendations and category surfaces, and ad and item monetization opens. The full process is in [Review Policy §1](/dev/policy). ### When does revenue settlement become available? It opens together with the ad network integration. The settlement structure (revenue event recording → monthly close → payout by bank transfer or USDC) is finalized in design, and terms such as the commission rate and minimum payout will be announced at integration time. If you need to discuss things in advance, contact cs@tokenpost.kr. ### How do I check the SDK version? `TokenApps.version` returns a string in the form "1.4.0". That said, when you need to branch, we recommend feature detection over version comparison: ```js if (typeof TokenApps.requestPurchase === "function") { /* v1.3+ */ } ``` Every change within v1.x is backward compatible, so code you integrate today will keep working unchanged through future v1 updates. See the [release notes](/dev/releases) for the change history. --- # Release Notes (releases) # Release Notes ## Game-only embed delivery — 2026-09-18 - Runtime remains v1.4.0; no new SDK methods or protocol messages. - Detailed game-only route guide, sizing/input examples, framing headers, diagnostics and AI coding request in English and Korean. - Runnable HTML reference at /sdk/examples/embed.html; no score or reward events. - Developer brief and submission guidance distinguish SDK integration from embed layout readiness. - Full-width host player with compact controls and reduced corners. External game layouts still need their own responsive implementation. ## Player and developer portal — 2026-09-17 - SDK runtime stays v1.4.0; no new wire protocol. - Automatic farming after 60 seconds, shared daily award with the existing 45-second SDK completion gate; 15-second host heartbeat. - English/Korean developer handoff, Mobile & PC guide, grouped submission and downloadable brief. - Owners maintain private device/SDK/test requirements; operators review them. Device URLs are review references, not automatic player routing. The change history for the SDK and the platform. **Every addition within v1.x is backward compatible** — existing fields are never removed or changed, and games can simply ignore unknown messages and fields. Code you integrate today will keep working unchanged through future v1 updates. When you need to branch, we recommend feature detection over version comparison: ```js if (typeof TokenApps.requestPurchase === "function") { /* v1.3+ */ } ``` ## SDK v1.4 — Duels (Live and Async Matchmaking) (2026-09-10) Games can add duels through `TokenApps.match`. - **`TokenApps.match.find(opts)`** — Finds an opponent and resolves a `Match`. The default, `kind: "auto"`, looks for a **live match** with someone playing the same game right now, and if nobody comes within 45 seconds switches to an **async duel** against the best record of the closest-rated player in the same game. The match starts at the `ready` event, and both sides build the same board from `seed`. - **`match.send(data)` and the `message` event** — Relay game messages to the opponent in a live match. Up to 8 per second and 4 KB each; anything over is dropped with a `ratelimited` event. - **`match.report({ score, outcome? })`** — Reports the result. The server judges and moves the rating. A live match settles only when the two reports agree; otherwise it closes `disputed`. - **`match.leave()`** — Cancel the wait or leave. Leaving a live match in play is recorded as a loss; the other side gets an `opponent_left` event. - Duel results never touch TAP. Earning stays the session-completion rule; a result only moves a per-game rating and a record. - **`ready()` re-sends** — Until the first `session` arrives the SDK sends `ready` again. A game that finished loading before the player page was listening used to lose its only `ready`, and a signed-in player looked like a guest. Nothing changes on the game side. Usage: [SDK guide §4.8](/dev/guide) · Spec: [§6.10–6.14](/dev/spec) ## SDK v1.3 — In-Game Items (2026-08-25) You can now sell items, themes, and boosts for TAP (TokenApps Points). - **`TokenApps.getProducts()`** — Returns the list of this game's products registered on the platform. Guests can query it too, so you can render your store display regardless of sign-in state. - **`TokenApps.requestPurchase(sku)`** — Requests a purchase. Price display, player confirmation, payment, and record-keeping are all handled by the platform; the game only receives the result. No message in the protocol has an amount field — the only price ever charged is the value registered on the server. - **`TokenApps.getInventory()`** — The list of items this player owns in this game. When restoring on restart, use this response as the source of truth, not local storage. - The iron rule for granting: grant items only on a `purchased` or `already_owned` response. - If you have a game server, you can query the inventory with the session token for server-side verification, the equivalent of receipt validation — [Specification §6.9](/dev/spec). Usage: [SDK Guide §4.7](/dev/guide) · Spec: [Specification §6.6–6.9](/dev/spec) ## SDK v1.2 — Continue Sheet (2026-08-24) - **`TokenApps.requestContinue()`** — Call it once at game over and the platform presents a three-option sheet: "Continue with TAP / Watch an ad to continue / Give up." Revive the player on `paid`/`rewarded`; otherwise proceed with your usual game-over flow. - For continues, we recommend this sheet over handling ads directly — the player gets more choices and your game code is shorter. ## SDK v1.1 — Rewarded Ads (2026-08-24) - **`TokenApps.requestRewardedAd(placement)`** — Requests a rewarded ad. The player opts in explicitly on a platform sheet, and the reward is granted by the server. No ad SDK or ad account is needed on the game side. - **`unavailable` is a normal response.** Guests, the viewing interval and daily view limits, and the player reaching the daily TAP cap all come back as this response. Always keep a path where the game proceeds without the ad — it's also a review requirement. ## SDK v1.0 — Initial Release (2026-08-18) - `ready()` / `session` — the handshake. Provides the sign-in state, a game-scoped pseudonymous player id, and a token for saves. - `track(event)` / `points` — gameplay event reporting and the server's ruling. Whether to award, the rate, and the caps are all decided by the server. - `save()` / `load()` — the account save slot. It follows the player across devices, and since localStorage doesn't work in the iframe, this is the standard storage. - `submitScore(n)` — display-only score submission. Never connected to TAP. - `openPointsPurchase()` — requests opening the platform's top-up sheet. ## Platform Changes That Affect Games | Date | Change | |---|---| | 2026-08-25 | A game info block (description, spec card, more-games shelf) was added to the play page. The description you wrote at submission is shown as-is, so the more complete it is, the better for your game | | 2026-08-25 | The developer center (/dev) opened — every specification, including this document, is available there | | 2026-08-24 | A 45-second measured-play gate now applies to the `session_complete` daily award. Below the gate the response is `ignored`; if the event is resent after real play, the award is granted normally. No game-side changes are needed | | 2026-08-24 | The Basic/Full soft launch was introduced — approved games start in Basic and are promoted to Full as metrics accumulate. See the [Review Policy](/dev/policy) | | 2026-08-18 | Host-side playtime measurement began (no game involvement) |