Integration guide
This page covers runtime modes, initialization config, accounts and email binding, cloud saves, leaderboards, analytics, ads, game-level storage and offline reconciliation. Method signatures live in the API reference.
How to include the SDK
| Scenario | Approach |
|---|---|
| Hosted by the platform (instfun.games/play/{gameId}/) | Load the CDN bundle <script src="/sdk/v1/instfun.js"></script> and use the global Instfun |
| Source integration built on the platform | Use the same global object; import the SDK's TypeScript types when you need them |
Accounts and wallets are shared across games only when they are served from the same origin; cross-origin iframe handshakes are not supported yet.
Runtime modes
| mode | Use case | Where data lives |
|---|---|---|
local | Standalone play, backend-free development | Browser localStorage (falls back to memory when unavailable) |
remote | Production (requires the platform backend) | Platform Worker + D1, server-authoritative |
auto (default) | Switch automatically by deployment | Remote when apiBaseUrl is present, local otherwise |
apiBaseUrl resolution order (shared by auto and remote):
| Order | Condition | Result |
|---|---|---|
| 1 | mode: "local" | Local only; apiBaseUrl is ignored |
| 2 | config.apiBaseUrl is non-empty | Use it |
| 3 | window.INSTFUN_CONFIG.apiBaseUrl is non-empty (injected by the portal) | Use it |
| 4 | mode: "remote" | Defaults to "/api/v1" (same-origin deployment) |
| 5 | Anything else (auto with no URL) | Local mode |
Fallback rules for auto: the SDK falls back to local only when remote startup fails with the NETWORK error code. Configuration errors, UNAUTHORIZED and explicit remote are thrown as-is. After initialization sdk.mode is always "local" or "remote".
const sdk = await Instfun.init({
gameId: "block-blast",
mode: "remote",
apiBaseUrl: "/api/v1" // default for same-origin deployment; may be omitted
});
sdk.mode; // resolved mode: "local" | "remote"Capability differences between the two modes:
| Capability | Local mode | Remote mode |
|---|---|---|
| Identity | localStorage guest account (shared same-origin, locally generated 8-char account code) | Server guest account + token (issued from deviceSeed, server account code) |
| Email binding | Unsupported; dialog shows an offline notice (unavailable) | Email + verification code binding/rebinding |
| Wallet | Local ledger (for standalone play) | Server-authoritative ledger (idempotency, policies, daily caps) |
| Saves / records | Local storage | Local-first + server sync; offline goes to the outbox |
| Leaderboard | On-device best (localOnly: true) | Global board (falls back to local when offline) |
| Analytics | Local queue; flush() only reports pending | Batched upload; clears on success |
| Ads | Browser simulation (simulated: true) | Browser host still simulated (granted locally); other hosts use server-side receipt redemption |
| Game KV | Local | Local (server-side KV planned) |
Initialization config
| Field | Type | Description |
|---|---|---|
gameId | string, required | Letters, digits, underscores and hyphens only; must match game-info.json |
gameVersion | string | Game version; defaults to "0.0.0" and is reported with saves and events |
debug | boolean | Enable debug logging |
mode | "local" / "remote" / "auto" | Defaults to auto |
apiBaseUrl | string | Remote API prefix; may be omitted for same-origin deployment |
initialBalance | Partial<Record<"coins" / "gems", number>> | Initial balance in local mode |
limits.saveThrottleMs | number | Minimum interval between direct save pushes; defaults to 2000ms |
limits.sessionEvents | number | Per-process analytics event cap; defaults to 1000 |
Instfun is a singleton: calling init again replaces the current instance. The old object stays usable but is no longer returned by getInstance().
Accounts and identity
- One guest account is shared by all games on the same origin. Local mode stores identity in
instfun:v1:identity; remote mode exchanges a local deviceSeed for a server account and token, and re-logs into the same account when the token expires. getCurrentUser()returns aUserProfilewithuserId,nickname,avatarUrl,bio,accountCode,email,accountBound,createdAtandrecentGameIds.accountCodeis an 8-character code (digits plus upper/lowercase letters) for display and support;emailis masked (for examplea***@example.com) and isnullwhen unbound.updateProfile({ nickname?, avatarUrl?, bio? })updates only those three fields.
Email binding
Guest accounts can bind an email for identification and cross-device recovery (remote mode only). Binding uses an SDK-rendered dialog (Shadow DOM isolated, bilingual, closable with ESC or a backdrop click).
const user = await sdk.account.getCurrentUser();
user.accountCode; // 8-character account code
user.email; // masked email, or null when unbound
user.accountBound;
if (!(await sdk.account.hasEmail())) {
const result = await sdk.account.openEmailBindDialog({
// locale: "en", // defaults to the URL lang (?lang= on the site) then navigator.language
onSuccess: (profile) => console.log("bound", profile.email),
onCancel: () => console.log("cancelled")
});
// result: { status: "bound", email, profile } | { status: "cancelled" }
// | { status: "unavailable", reason: "offline" }
}Flow and behavior:
- Email input → send code (resend available after 60s by default; the server's
resendAfterSecondswins) → 6-digit code → verify. Errors are shown inline and a failed verification can be retried inside the dialog. - When the send-code response has
alreadyBound: true(the email already belongs to the current account), the dialog skips code entry, refreshes the profile and resolves as bound. - Calling it again for a bound account shows the current masked email and a "Change email" entry that reuses the same flow.
- In local mode (or without a DOM) the dialog shows "Email binding is unavailable offline" and returns
unavailable(offline)without throwing;onCancelfires only when the user closes the dialog, presses ESC, or clicks the backdrop — never forunavailable. - A 401 during binding clears the token, re-logs as a guest with the local deviceSeed once and retries; if it still fails, the dialog shows an inline "session expired, refresh the page" message.
- After a successful bind the SDK refreshes its cached
UserProfileandhasEmail()returnstrueimmediately.
Cloud saves and records
Both modes are local-first:
const progress = await sdk.game.loadProgress<{ level?: number }>();
await sdk.game.start({ level: progress?.data.level ?? 1 });
await sdk.game.saveProgress({ level: 4, bestScore: 8320 }, { merge: true });
const finish = await sdk.game.finish({
result: "complete",
score: 8320,
submitScore: true
});saveProgress(data, { merge }):mergeis a shallow merge (top-level keys overwrite, nested values are replaced) and defaults tofalse. A serialized save over 256KB throwsPAYLOAD_TOO_LARGE.- Saves carry a monotonically increasing
revision. Remote pushes usebaseRevision = revision - 1for optimistic concurrency; on conflict the SDK fetches the server save, shallow-merges with the server as the base and retries once. A second conflict goes to the outbox untilsync(). - Remote mode merges pushes within a 2s window (
limits.saveThrottleMs) into the outbox and returnspendingSync: true. game.start(meta?)opens a session; a left-over unfinished session is recorded asquit.game.finish(input)settles the record and stats, writesinput.progresswhen present, and submits to the leaderboard only whensubmitScoreis true andscoreis a non-negative integer.game.record(input)writes a record without a session;getStats()andgetRecords({ limit? })return stats and recent records (newest first, at most 50 kept).- On the first remote start, if the server has no save for the game, the existing local save is uploaded once (history migration).
Leaderboard
await sdk.leaderboard.submitScore({ score: 8320 });
const snapshot = await sdk.leaderboard.get({ boardId: "global", limit: 20 });submitScorekeeps only the better score;higherIsBetterdefaults totrue. When the direction differs from the stored record, comparability is considered void and the score overwrites it.getreturns aLeaderboardSnapshot: local mode givesscope: "local",localOnly: trueand rank 1; remote mode givesscope: "global"and falls back to the local board when offline.- Your UI should branch on the snapshot: show "on-device best" when
localOnly: true, render a global board whenscope: "global". - The snapshot type includes
window: "all" | "weekly". The weekly board exists on the server, but the SDK currently always requestswindow: "all"and does not expose the parameter.
Analytics
sdk.analytics.track("level_complete", { level: 4 });
const { sent, pending } = await sdk.analytics.flush();tracknever throws: event names must be 1–64 characters; properties over 8KB serialized are dropped; the local queue keeps the newest 500 events and the per-process cap is 1000 (overridable withlimits.sessionEvents).- Remote
flush()uploads in batches of 100 and clears the queue only when every batch succeeds; failures keep the unsent tail. Localflush()performs no request and returns{ sent: 0, pending }. - Inside
play/{gameId}/on the site, the SDK reads the page watermark<meta name="instfun-wm">and reports it asproperties.wmfor attribution; an explicitwmpassed by the game wins.
Ads
const ad = await sdk.ads.showRewarded({ placement: "DOUBLE_REWARD" });
// ad: { completed, simulated, rewardGranted, reward?, transactionId? }
const interstitial = await sdk.ads.showInterstitial({ placement: "GAME_OVER" });
// browser host returns { shown: false, simulated: true }- After a fully watched rewarded ad the SDK grants the reward idempotently (100 coins by default; override with
currency,amountorreason). Do not add currency yourself. - The browser host simulates ads (
simulated: true). When thewindow.InstfunWebHostbridge exists the ad runs in the host; in remote mode the server issues and redeems a reward receipt. The Android bridge is legacy compatibility. supports("ads") === trueonly means the capability exists (possibly simulated). Do not use it to skipshowRewarded; distinguish real from simulated with thesimulatedfield.
Game-level storage
await sdk.storage.set("settings", { music: true });
const settings = await sdk.storage.get("settings");
const allKeys = await sdk.storage.keys();
sdk.storage.persistent; // false when localStorage is unavailableKeys are namespaced per gameId, so identical keys in different games do not collide. When localStorage is unavailable the SDK degrades to memory, persistent is false, and you should warn players that progress will not be saved.
Lifecycle and offline reconciliation
await sdk.sync();
// { flushed, pending }: how many operations were sent and how many remain- In remote mode, failed save/record/leaderboard writes enter the outbox (up to 200 operations).
sync()replays them in enqueue order;NETWORK,SERVER_ERRORandRATE_LIMITEDare retryable, and the first retryable failure stops the replay while keeping the remaining operations. Non-retryable errors drop the operation and report anoutbox_op_droppedevent. - Call it after SDK initialization, when the page returns to the foreground (
visibilitychange), when the network recovers (online), and at low-frequency points such as entering the lobby or the results screen. Do not call it every frame or after every round —sync()is subject to server rate limits. - In local mode
sync()performs no network request and returns{ flushed: 0, pending }from the local analytics queue; the shape matches remote mode, so game code needs no branching.
Security and quotas
Default thresholds (configurable on the server):
| Item | Default |
|---|---|
| Reads | 300/minute |
| Writes | 150/minute |
| Saves | 500/day (per user per game) |
| Records | 500/day (per user per game) |
| Leaderboard submissions | 300/day (per user per board) |
| Registrations | 50/IP/day |
| Request body | 64KB regular, 256KB for saves |
SDK-side protection: remote save pushes are merged within a 2s window; saves over 256KB and event properties over 8KB are blocked or dropped locally. On RATE_LIMITED, saves, records and leaderboard submissions go to the outbox until the next sync(); wallet and ad rewards surface the error as-is and must not be retried automatically. Rate-limit and daily-quota 429s return Retry-After and InstfunError.details contains retryAfterSeconds, limit and window.
Integration notes
- Branch leaderboard UI on
localOnly/scope. - Ad rewards are written to the wallet by the SDK; never grant them twice.
- In remote mode call
sdk.sync()at startup, on foreground return and on network recovery. - When localStorage is unavailable, warn players that progress will not be saved.
- Cross-game identity and wallet sharing require same-origin deployment; cross-origin iframes are not supported.
- Wallet and ad rewards require connectivity in remote mode; everything else works offline and reconciles automatically.
本页说明 SDK 的运行模式、初始化配置、账号与邮箱绑定、云存档、排行榜、埋点、广告、游戏级存储以及离线补偿的行为细节。方法签名与返回值见「API 速查」。
引入方式
| 场景 | 方式 |
|---|---|
| 平台托管(instfun.games/play/{gameId}/) | 引入 CDN 单文件 <script src="/sdk/v1/instfun.js"></script>,使用全局 Instfun |
| 平台内构建的源码接入 | 可直接使用同一全局对象;需要类型时可引用 SDK 包中的 TypeScript 类型定义 |
SDK 自带账号与钱包的跨游戏共享能力,前提是同源部署;跨域 iframe 的门户握手待后续版本。
运行模式
| mode | 适用场景 | 数据位置 |
|---|---|---|
local | 单机玩法、无后端联调 | 浏览器 localStorage(不可用时降级内存) |
remote | 正式环境(需平台后端) | 平台 Worker + D1,服务端权威 |
auto(默认) | 按部署环境自动切换 | 有 apiBaseUrl 走远端,否则本地 |
apiBaseUrl 解析顺序(auto 与 remote 共用):
| 顺序 | 条件 | 结果 |
|---|---|---|
| 1 | mode: "local" | 纯本地,忽略 apiBaseUrl |
| 2 | config.apiBaseUrl 非空 | 使用它 |
| 3 | window.INSTFUN_CONFIG.apiBaseUrl 非空(门户注入) | 使用它 |
| 4 | mode: "remote" | 默认 "/api/v1"(同源部署) |
| 5 | 其余(auto 且未配置) | 本地模式 |
auto 模式的回落规则:只有远端启动失败且错误码为 NETWORK 时才自动回落本地;配置错误、UNAUTHORIZED 等会原样抛出,显式 remote 不做回落。初始化成功后 sdk.mode 只会是 "local" 或 "remote"。
const sdk = await Instfun.init({
gameId: "block-blast",
mode: "remote",
apiBaseUrl: "/api/v1" // 同源部署默认值,可省略
});
sdk.mode; // 解析后的实际模式:"local" | "remote"两种模式的能力差异:
| 能力 | 本地模式 | 远端模式 |
|---|---|---|
| 身份 | localStorage 游客账号(同源共享,本地 8 位账号码) | 服务端游客账号 + token(deviceSeed 换发,服务端账号码) |
| 邮箱绑定 | 不支持,弹窗只提示离线(unavailable) | 邮箱 + 验证码绑定/换绑 |
| 钱包 | 本地账本(单机联调用) | 服务端权威账本(幂等、策略、每日额度) |
| 存档 / 记录 | 本地存储 | 本地优先 + 服务端同步;离线进 outbox |
| 排行榜 | 本机最佳(localOnly: true) | 全球总榜(断网回退本机榜) |
| 埋点 | 本地队列,flush() 只报告 pending | 队列批量上报,成功清空 |
| 广告 | 浏览器模拟(simulated: true) | 浏览器宿主仍为模拟(本地入账);其它宿主走服务端票据核销 |
| 游戏 KV | 本地 | 本地(服务端 KV 待后续) |
初始化配置
| 字段 | 类型 | 说明 |
|---|---|---|
gameId | string,必填 | 只允许字母、数字、下划线、连字符;必须与 game-info.json 一致 |
gameVersion | string | 游戏版本,缺省 "0.0.0",随存档与事件上报 |
debug | boolean | 输出调试日志 |
mode | "local" / "remote" / "auto" | 缺省 auto |
apiBaseUrl | string | 远端 API 前缀;同源部署可省略 |
initialBalance | Partial<Record<"coins" / "gems", number>> | 本地模式初始余额 |
limits.saveThrottleMs | number | 远端存档直推最小间隔,缺省 2000ms |
limits.sessionEvents | number | 单次进程埋点入队上限,缺省 1000 条 |
Instfun 是单例:重复 init 会覆盖当前实例,旧对象仍可用但不再由 getInstance() 返回。
账号与身份
- 同一浏览器同源共享一个游客账号;本地模式身份存在
instfun:v1:identity,远端模式由本机 deviceSeed 换取服务端账号与 token,token 失效时 SDK 自动重新登录同一账号。 getCurrentUser()返回UserProfile,核心字段包括userId、nickname、avatarUrl、bio、accountCode、email、accountBound、createdAt、recentGameIds。accountCode是 8 位账号码(数字 + 大小写字母),用于展示与客服识别;email是脱敏邮箱(如a***@example.com),未绑定为null。updateProfile({ nickname?, avatarUrl?, bio? })只更新这三个字段。
邮箱绑定
游客账号可绑定邮箱,用于账号识别与换设备找回(仅远端模式)。绑定入口是 SDK 自绘弹窗(Shadow DOM 隔离、中英双语、ESC/遮罩可关闭)。
const user = await sdk.account.getCurrentUser();
user.accountCode; // 8 位账号码
user.email; // 脱敏邮箱,未绑定为 null
user.accountBound;
if (!(await sdk.account.hasEmail())) {
const result = await sdk.account.openEmailBindDialog({
// locale: "en", // 缺省按 URL lang(官网 ?lang=)→ navigator.language 协商
onSuccess: (profile) => console.log("bound", profile.email),
onCancel: () => console.log("cancelled")
});
// result: { status: "bound", email, profile } | { status: "cancelled" }
// | { status: "unavailable", reason: "offline" }
}弹窗流程与行为:
- 邮箱输入 → 发送验证码(默认 60s 后可重发,以服务端
resendAfterSeconds为准)→ 6 位验证码 → 验证;错误内联提示,验证失败可留在弹窗内重试。 - 若发码响应
alreadyBound: true(该邮箱已绑定当前账号),弹窗不进入输码环节,刷新资料后直接按绑定成功返回。 - 已绑定用户再次调用会展示当前脱敏邮箱与「更换邮箱」入口,换绑走同一流程。
- 本地模式(或无 DOM 环境)弹窗提示「离线模式下暂时无法绑定邮箱」,返回
unavailable(offline),不抛错;onCancel仅在用户关闭、按 ESC 或点击遮罩取消时触发,unavailable不触发。 - 绑定接口遇 401 会清 token 并用本机 deviceSeed 重登一次后重试;仍失败时弹窗内联提示「登录状态失效,请刷新页面后重试」。
- 绑定成功后 SDK 刷新缓存的
UserProfile,hasEmail()立即返回true。
云存档与记录
两种模式都是本地优先:
const progress = await sdk.game.loadProgress<{ level?: number }>();
await sdk.game.start({ level: progress?.data.level ?? 1 });
await sdk.game.saveProgress({ level: 4, bestScore: 8320 }, { merge: true });
const finish = await sdk.game.finish({
result: "complete",
score: 8320,
submitScore: true
});saveProgress(data, { merge }):merge为浅合并(顶层键覆盖,嵌套整体替换),缺省false;序列化后超过 256KB 抛PAYLOAD_TOO_LARGE。- 存档带单调递增的
revision;远端推送以baseRevision = revision - 1做乐观并发控制,冲突时自动拉取服务端存档、以服务端为底做浅合并后重试一次,二次冲突进入 outbox 等待sync()。 - 远端模式默认 2s 合并窗口(
limits.saveThrottleMs):窗口内的推送合并进 outbox,返回结果带pendingSync: true。 game.start(meta?)开局;若发现上次遗留的未结束会话,会按quit补记一条记录。game.finish(input)结算记录与统计,input.progress存在时同时写存档,submitScore && score 为非负整数时才提交榜单。game.record(input)不依赖会话直接落一条记录;getStats()/getRecords({ limit? })返回统计与最近记录(倒序,最多保留 50 条)。- 首次以远端模式启动时,若服务端尚无该游戏存档,会把本机已有存档自动上传一次(历史存档迁移)。
排行榜
await sdk.leaderboard.submitScore({ score: 8320 });
const snapshot = await sdk.leaderboard.get({ boardId: "global", limit: 20 });submitScore只保留更优成绩;higherIsBetter缺省true,当方向与历史记录不同时视为可比性失效并直接覆盖。get返回LeaderboardSnapshot:本地模式scope: "local"、localOnly: true、rank恒为 1;远端模式scope: "global",断网时回退本机榜。- UI 需按快照区分展示:
localOnly: true显示「本机最佳」,scope: "global"渲染全球榜。 - 快照类型包含
window: "all" | "weekly",服务端周榜已支持,SDK 目前固定请求window: "all",尚未暴露周榜参数。
埋点
sdk.analytics.track("level_complete", { level: 4 });
const { sent, pending } = await sdk.analytics.flush();track永不抛错:事件名长度需为 1~64;properties 序列化后超过 8KB 会被丢弃;本地队列上限 500 条(保留最新),单次进程入队上限 1000 条(可用limits.sessionEvents覆盖)。- 远端
flush()按 100 条分批上报,全部成功才清空队列,失败保留未发送部分;本地flush()不发请求,只返回{ sent: 0, pending }。 - 在官网
play/{gameId}/内运行时,SDK 自动读取页面水印<meta name="instfun-wm">并作为properties.wm上报,用于来源追踪;游戏显式传入的wm优先。
广告
const ad = await sdk.ads.showRewarded({ placement: "DOUBLE_REWARD" });
// ad: { completed, simulated, rewardGranted, reward?, transactionId? }
const interstitial = await sdk.ads.showInterstitial({ placement: "GAME_OVER" });
// 浏览器宿主返回 { shown: false, simulated: true }- 激励广告完整观看后由 SDK 幂等发奖(默认 100 coins,可用
currency/amount/reason覆盖);游戏不要自行加币。 - 浏览器宿主提供模拟广告(结果
simulated: true);存在window.InstfunWebHost桥时走宿主,远端模式下由服务端签发并核销奖励票据;Android 桥为遗留兼容。 supports("ads")为true只表示能力可用(可能是模拟),不要用它跳过showRewarded;真实与模拟由结果的simulated字段区分。
游戏级存储
await sdk.storage.set("settings", { music: true });
const settings = await sdk.storage.get("settings");
const allKeys = await sdk.storage.keys();
sdk.storage.persistent; // localStorage 不可用时为 falseKV 按 gameId 隔离,不同游戏同名 key 互不影响。localStorage 不可用时 SDK 降级内存,persistent 为 false,应提示玩家进度不会保存。
生命周期与离线补偿
await sdk.sync();
// { flushed, pending }:本次成功补发条数与仍积压条数- 远端模式下,存档、记录与榜单的失败写操作按类型进入 outbox(上限 200 条),
sync()按入队顺序重放,NETWORK/SERVER_ERROR/RATE_LIMITED视为可重试,遇到第一个可重试失败即停止并保留后续操作;不可重试错误会丢弃该操作并上报outbox_op_dropped埋点。 - 建议调用时机:SDK 初始化完成后、页面回到前台(
visibilitychange)或网络恢复(online)时、进入大厅或结算页等低频时机。不要每帧或每局结束都调用,sync()本身受服务端限流约束。 - 本地模式下
sync()不发网络请求,只返回本地埋点队列的{ flushed: 0, pending },返回结构与远端一致,游戏侧无需分支。
安全与配额
默认阈值(服务端可调):
| 项 | 默认值 |
|---|---|
| 读请求 | 300 次/分 |
| 写请求 | 150 次/分 |
| 存档 | 500 次/天(每用户每游戏) |
| 记录 | 500 条/天(每用户每游戏) |
| 榜单提交 | 300 次/天(每用户每 board) |
| 注册 | 50 次/IP/天 |
| 请求体 | 普通 64KB,存档 256KB |
SDK 侧保护:远端存档推送默认 2s 合并窗口;超过 256KB 的存档与超过 8KB 的埋点属性在本地被拦截或丢弃。收到 RATE_LIMITED 时,存档、记录与榜单进 outbox 等下次 sync(),钱包与广告奖励原样报错,不要自动重试。安全限流与日配额触发的 429 返回 Retry-After,InstfunError.details 含 retryAfterSeconds / limit / window。
集成注意
- 排行榜 UI 按
localOnly/scope区分本机榜与全球榜。 - 激励广告奖励由 SDK 自动写入钱包;游戏不要重复发奖。
- 远端模式下建议在启动、回到前台、网络恢复时调用
sdk.sync()。 - localStorage 不可用时提示玩家进度不会保存。
- 身份与钱包跨游戏共享依赖同源部署;跨域 iframe 暂不支持。
wallet与广告奖励需要在线(远端模式),其余能力离线可用并自动补偿。