Contents
Builda SDK
For developers publishing games to the Builda platform (Godot Web / HTML5 / Unity WebGL): what the Builda SDK provides,
how to integrate and use it, and the rules you must follow. For packaging and upload rules see h5_bundle_spec.md; this guide covers only the SDK itself.
Copy for AI
Install Builda game tools from /agent/setup.md
What is Builda SDK
Builda SDK is the only interface layer between your game and the Builda platform (the Builda App host). It is exposed to the game as the global object
window.Builda. All three engines share the same JS API; Godot and Unity each have an official wrapper layer
(a GDScript addon / a C# static class) with semantics identical to the JS API.
| Module | Capabilities |
|---|---|
runtime | Ready signal, quit, safe area / capsule button avoidance, current language |
whoami / requestToken | Current player identity, game-scoped short-lived service token |
getRoleInfo / getServerTime | Other players' public nicknames and avatars, Builda server-authoritative time |
privateKV | Per-player private cloud save (current game × current player) |
assets | URL resolution for the platform asset pack (assets.zip) |
audio | BGM / sound effects / mute / vibration / microphone permission |
mic | Microphone PCM stream primitives (voice-controlled gameplay) |
pay | In-app purchase payment panel |
ad | Rewarded video ads |
input | Platform-native text input panel |
rank | Leaderboard submission and queries |
events | Subscription callbacks: app going to background / returning to foreground, pause panel shown / closed |
Two builds: mock (development) and runtime (production)
The SDK has two implementations with an identical API surface, so game code never needs to tell the environments apart:
- mock (development) build (installed into your project by
builda sdk install, served locally bybuilda dev): runs only in the browser. Identity and saves are faked locally (localStorage), audio and vibration use native browser features, and payments/ads open a mock modal where you manually click "success/failure". Used for local development and debugging. - runtime (production) build (published on the platform CDN): runs only inside the Builda App host; every capability uses the host's native implementation. Without a host it always returns a failure state (
HOST_UNAVAILABLE) oravailable: falseand never fakes success.
Key contract: your game zip does not bundle the SDK file itself. The
<head>ofindex.htmlreferences it with a root-relative path:<script src="builda-sdk.js"></script>. Locally,builda devautomatically serves the mock build as a fallback; in production, Builda App downloads the production build from the CDN according to thesdkcontract in the release manifest and injects it at the same path. Upload validation strictly checks for this reference; build tools (vite/webpack) must not bundle it as a module — it must remain an external<script>tag.
Unified return format
Every asynchronous API returns a Promise that resolves to a unified Result structure; it never throws:
{ "ok": true, "data": { } }
{ "ok": false, "error": { "code": "BAD_KEY", "message": "..." } }
A few purely local data APIs return synchronously and unwrapped (no Result wrapper, no await): runtime.safeArea(),
runtime.capsuleMenuRect(), runtime.language(), assets.url() / assets.baseUrl,
and mic.read() (real-time data must be available within the frame). Most async APIs accept opts.timeoutMs (default 120 seconds);
on timeout they return code: "PENDING".
Besides request-style APIs there is a set of subscription callbacks, Builda.events.on/off (events the host pushes to the game: app
going to background / returning to foreground, pause panel shown / closed). See "events: subscription callbacks" below.
Integration (by engine)
Prerequisite for all three engines: the project-level toolchain (./.builda-agent/builda) has been installed via the setup task on the home page / upload page.
If it isn't installed yet, click "Copy for AI" in the top-right corner and send the setup task to the AI in your project.
HTML5 (pixi / phaser / Cocos / plain canvas)
- Run
./.builda-agent/builda sdk installin the project root — the mock SDK,builda-sdk.d.tsand the test shell are installed to.builda-agent/sdk/web/(regenerable, do not commit). - Add
<script src="builda-sdk.js"></script>to the game HTML<head>(root-relative path; it must survive into the build output). - Call
window.Builda.*directly. At the very start of the game, you must callBuilda.runtime.ready()(the first thing in your entry script). - TypeScript projects can add
builda-sdk.d.tsto tsconfig include to get type hints. - Local verification:
./.builda-agent/builda dev --web dist, then check the game in the test shell at the printed dev-url.
Builda.runtime.ready(); // must be called at game start (force-closed if not received within 30 s)
const who = await Builda.whoami();
if (who.ok) console.log(who.data.name);
await Builda.privateKV.set("save_slot1", bytes); // bytes: Uint8Array
const r = await Builda.privateKV.get("save_slot1"); // r.data === null means the key does not exist
Godot (Web export)
- In the Godot project root (containing
project.godot) run./.builda-agent/builda sdk installand./.builda-agent/builda sdk check. This installsaddons/builda/(the SDK wrapperbuilda.gd, mock JS, examples), copies the recommended wrapperBuildaClient.gdintoscripts/and registers it as an Autoload, and writes the SDK reference into the Web export preset'shtml/head_include. - The result model is async request/signal: each call returns a
request_id, and the result comes back via thesdk_result(request_id, result)signal (resultis the Dictionary form of the unified Result):
Byte values (privateKV, mic samples) cross the bridge as base64; restore them withvar builda = BuildaClient.builda var rid := builda.whoami() # match the result by request_id in the sdk_result callback builda.private_kv_set("save", var_to_bytes(save_dict)) # reading side: bytes_to_var(builda.private_kv_bytes(result.data))private_kv_bytes()/mic_samples(). Usevar_to_bytes()/bytes_to_var()as the save format. - At the very start of the game, you must call
builda.runtime_ready()(the auto-installedBuildaClientAutoload already calls it as the first thing in_ready()). - In the editor / non-Web exports every call returns
UNSUPPORTED(no crash); behavior must be verified in a browser after a Web export, usingbuilda dev --web build/web. - Do not manually mix addon / JS / d.ts files from different versions; if versions are out of sync, rerun
sdk install.scripts/BuildaClient.gdbelongs to your project — modify it as needed; upgrades won't overwrite it.
Unity (WebGL export)
- Run
./.builda-agent/builda sdk installin the Unity project root — the C# wrapper and jslib bridge are installed toAssets/Builda/, and the Builda WebGL template toAssets/WebGLTemplates/Builda/. - In
Player Settings → Resolution and Presentation → WebGL Templateselect Builda (the template already contains the SDK reference; custom templates must add it to<head>themselves). - Call the static class
BuildaSDKin theBuildanamespace; async APIs take callbacks:using Builda; BuildaSDK.RuntimeReady(_ => {}); // must be called at game start (force-closed if not received within 30 s) BuildaSDK.Whoami(result => { if (result.Ok) Debug.Log(result.DataMap["name"]); }); BuildaSDK.KvSet("save_slot1", bytes); // byte[]; the SDK bridges it as base64 internally BuildaSDK.KvGet("save_slot1", r => { var data = BuildaSDK.KvBytes(r.Data); // null = key does not exist }); var lang = BuildaSDK.RuntimeLanguage(); // synchronous API, returns directly - In the editor / non-WebGL platforms every call returns an
UNSUPPORTEDfailure (no exception). Verify the same way: WebGL export +builda dev.
Capabilities and usage rules
runtime: ready, quit, avoidance, language
runtime.ready()must be called at the very start of the game: make it the first thing in the entry script / first scene, don't wait until assets finish loading, and make sure every startup path reaches it. The host uses it to confirm the game is alive: if ready isn't received within 30 seconds of page load, the game is force-closed. The mock build logs a console warning if ready isn't received within 15 seconds of page load.- You usually don't need
runtime.quit(): the platform capsule menu already has an exit entry. Calling it doesn't close the game directly; it opens the platform's standard pause/exit page and lets the player decide. runtime.safeArea()(synchronous, unwrapped): safe-area insets{top, right, bottom, left}in CSS pixels, all 0 on devices without a notch. Backgrounds may go full-screen, but key HUD elements / buttons must stay clear. If the game viewport resolution differs from the window, scale first (inset × viewport width ÷ window CSS width) before applying. The host updates it on orientation change, so read it fresh in each resize callback.runtime.capsuleMenuRect()(synchronous, unwrapped): the rectangle of the platform's floating capsule button in the top-right corner,{top, right, width, height}(top/rightare insets from the viewport's top/right edges). Don't place clickable UI inside this rectangle; your own top buttons can align to itstop/heightand sit to its left.width/heightof 0 means there is no floating entry and nothing to avoid.runtime.language()(synchronous, unwrapped String; never an empty string, falls back to"en"): the platform only provides bare language codes (zh,en, with no region variants —zh-CNnever appears); the only exception is Spanish, which ises-419. Don't branch on Simplified/Traditional or split copy by region. Match language packs with a three-level fallback: exact match → language prefix (es-419→es) → the game's default language. Reading it once at startup is enough; the host supports switching language at runtime, so re-read it at points like scene changes to keep up — no polling needed.
whoami and requestToken
whoami()returns{id, name, avatar?}. On device the host provides the real player; the game is unaware of login and handles no tokens. The local mock returns a fake player specified by URL parameters.requestToken()returns{token, expiresAt}(Unix seconds): a game-scoped short-lived token bound to the current game × current player × a limited lifetime, used by the game's logic layer to connect directly to Builda services that require authentication (such as Builda Online). It is not a full login session. Request a new one yourself asexpiresAtapproaches. Local mock tokens are fake and cannot reach real services; they exist only to exercise your refresh-on-expiry logic.
Hard rule: keep the token in memory only. It is strictly forbidden to write it to privateKV, local files, URLs, logs or saves.
getRoleInfo and getServerTime: other players' public profiles, authoritative time
getRoleInfo(roleIds)batch-queries the public nicknames and avatars of other players and returns a{roleId: {userName, userImg}}map: a present key means it was found this time, a missing key means it wasn't; the nickname or avatar may be an empty string.roleIdis a positive decimal integer string, at most 32 per call. Use it to display opponents on leaderboards and in multiplayer rooms (whoami()only covers the current player). Godot:builda.get_role_info(role_ids); Unity:BuildaSDK.GetRoleInfo(roleIds, callback).- The SDK merges and deduplicates queries made within 50 ms, and caches successful profiles within the current game page for 30 minutes, up to 512 entries — so querying each player once is enough. Don't poll at high frequency, and don't treat a missing key as a network error to retry repeatedly. The local mock returns deterministic fake profiles; only real devices call Builda.
getServerTime()returns{serverTimeMs}: Builda's server-authoritative time (Unix milliseconds). Daily task resets, time-limited events, season countdowns and preventing reward farming via device clock changes should all be based on it — don't use the device's local time. Godot:builda.get_server_time(); Unity:BuildaSDK.GetServerTime(callback).- On the first call the SDK syncs the clock with Builda once (waiting at most 1 second), then extrapolates with a local monotonic clock and periodically re-syncs, so repeated calls don't make network requests. If the sync fails and no usable clock is available it returns a
NETWORKfailure; the game should retry later or treat time as "unknown" in the meantime. It is meant for second-level business decisions, not millisecond-accurate sync, and it does not replace server-side validation of rewards, rankings and similar outcomes. The local mock extrapolates from the local machine's time.
const profiles = await Builda.getRoleInfo(["11299825", "11299826"]);
if (profiles.ok) {
const p = profiles.data["11299825"]; // may be undefined: not found this time
showOpponent(p ? p.userName : "Player", p ? p.userImg : "");
}
const now = await Builda.getServerTime();
if (now.ok && now.data.serverTimeMs >= dailyResetAtMs) refreshDailyTasks();
Rule: public profiles are for display within the current session only — don't write them into saves or build your own player database from them. Base time decisions on
serverTimeMs, but final outcomes involving rewards, rankings and payments are still validated by the platform server.
playerData: observe your stored Player Data
Builda.playerData.getMany(["coins"]) requires non-empty keys; listKeys() returns the directory in data.meta.keys with values={}. Both return {meta,values,source}. Published games can read their own data online without joining a room; draft trials are unsupported. Godot: player_data_get_many / player_data_list_keys. Unity: PlayerDataGetMany / PlayerDataListKeys.
This is a possibly stale, non-authoritative stored observation. Neither revision nor source guarantees freshness. Online game server actions decide rewards, deductions, stealing and payments. Never overwrite a save from this read or replay a successful action because a later read is stale. Failure is not an empty save or zero balance; label retained displays as possibly stale / refresh failed.
Missing keys are null; tombstones are entries with a null value. Values retain a {type,data} tree: int64 and revisions are decimal strings, bytes are Base64, objects recursively contain typed fields. schemaVersion is a uint32 number or null. Do not convert int64 to Number. This is independent of privateKV; never put server secrets in readable fields. The local mock has a separate coins=0 fixture and sends no real HTTP requests.
privateKV: player cloud saves
Private save storage for the current game × current player. Values are opaque byte streams (Uint8Array in JS,
PackedByteArray + var_to_bytes() in Godot, byte[] in Unity). API:
get / set / remove / getMany / setMany / removeMany.
| Item | Rule |
|---|---|
| Key format | ^[A-Za-z0-9_:-]{1,64}$ (new code should use only letters, digits, _ and -) |
| Single value | ≤ 32KB (measured in decoded bytes) |
| Batch | ≤ 32 keys per call (the SDK does not split batches automatically; exceeding this returns BATCH_TOO_LARGE) |
| Total quota | ≤ 100 live keys per player per game (no aggregate byte quota) |
| Write rate | Server-enforced at about 60 writes/minute; a batch counts as 1 (not simulated by the mock) |
| Concurrency | Last write wins; no versioning/CAS |
get returns null | The key does not exist (empty bytes are a valid value; deletion requires an explicit remove) |
setMany | The whole batch is validated; any violation rejects the entire batch. Writes are best-effort, not transactional |
removeMany | Idempotent for keys that don't exist |
Usage rules
- Game-managed private byte saves go through privateKV — don't roll your own localStorage, IndexedDB, cookie or engine local-file saves. Only privateKV data can be managed by the platform (e.g. when a player resets their save in the App); data stored elsewhere is outside the platform's control and will linger after a reset.
- Write at event milestones (level cleared, round settled, purchase completed); when several keys change together, merge them into one write with
setMany. Don't write every frame or on a timer, and don't use it for real-time state sync — high-frequency writes are rejected on real devices. - At startup, read multiple keys in one round trip with
getManyinstead of serial per-keygetcalls; beyond 32 keys you must split batches explicitly and check each batch's result. - Layered validation: key format, the 32KB per-value limit and the 32-key batch limit are pre-checked by the SDK; the 100 live-key total and the write rate are enforced authoritatively by the server. Exceeding a limit returns an explicit error code (
VALUE_TOO_LARGE/QUOTA_EXCEEDED, etc.) — nothing is silently truncated. The mock enforces the same quotas as real devices, so you can hit and fix them locally.
assets: platform asset pack
Large assets (audio, 3D models, etc.) are not bundled into the main game package; they are packed separately into assets.zip and uploaded via
builda assets upload. It contains two top-level subtrees:
audio/**: played through the host audio channel (.mp3/.ogg/.wav), used withBuilda.audio.*.res/**: large resources the game code fetches itself (3D models, textures, level data, AssetBundles, data-only pck files, etc.).
Limits: ≤ 32MB per file, ≤ 320MB total uncompressed, ≤ 10000 files. Scripts/executable content
(.js / .wasm / .html) and fonts are not allowed.
- Never hard-code CDN URLs —
assetsBaseUrlchanges on every re-upload. Always resolve from a relative path at runtime:Builda.assets.url("res/models/car.glb")(Godotasset_url(...), UnityBuildaSDK.AssetsUrl(...)); the return value is always an absolute URL. - Don't also bundle
res/**resources into the main zip (you'd pay for the size twice). The main package must still be self-contained up to "can enter and start the core gameplay";res/**is only for lazily loaded content. - Load failures must be visible and retryable (progress bar / placeholder / retry button); blank screens or hangs are not allowed.
- For local development, copy
audio/andres/into the build directory to close the loop: without an assetsBaseUrl the mock build resolves paths to absolute URLs relative to the page's directory, so GodotHTTPRequest/ UnityUnityWebRequestwork directly. Remove these local copies from the release package.
audio: BGM / sound effects / vibration
await Builda.audio.playBGM("audio/bgm/main.mp3", { loop: true, volume: 0.8 });
await Builda.audio.stopBGM();
await Builda.audio.playSFX("audio/sfx/hit.ogg", { sessionId: "hit", volume: 1 });
await Builda.audio.setMuted(true, "bgm"); // group: "bgm" | "effect"
await Builda.audio.vibrate(2); // 1 light / 2 medium / 3 heavy
- Pass the relative path under
audio/**inside assets.zip; on device the host plays it natively (FMOD). - Audio does not need a "tap to unlock": on device playback is native to the host and not subject to browser autoplay policies, so BGM can play as soon as the game starts. Don't copy the web-game convention of a "tap anywhere to enable sound" overlay. The local mock uses the browser's
Audioand may be blocked by autoplay until the first interaction — that's a development-environment artifact; don't change game logic for it. - Don't build your own music/SFX toggles or exit button: the platform's standard settings / pause-exit page provides them (including a mute toggle). Don't put them in your game's settings page either.
mic: voice control
For voice-controlled gameplay (volume / blowing / pitch), prefer the official built-in component BuildaMic (shipped with the SDK, same design for Godot and Unity)
instead of using the low-level primitives directly:
- Godot: register the autoload
BuildaMic="*res://addons/builda/mic/builda_mic.gd"; callBuildaMic.start()in a tap callback; readBuildaMic.volume(RMS) /BuildaMic.pitch(Hz) every frame to drive gameplay, and listen tostate_changedto update the guidance UI.
Packaging note: after exporting you must copyaddons/builda/mic/web/as-is into the export directory before zipping — the analysis worker must ship with the package. Local dev has a fallback that will mislead you; if the files are missing from the release package, real devices reportWORKER_LOAD_FAILED. - Unity: attach the
BuildaMiccomponent to a persistent object and useStartMic()/Volume/Pitch/StateChanged; the analysis worker lives inAssets/StreamingAssets/builda-mic/and is included in WebGL builds automatically. - To change algorithms or add features, edit only the respective
builda-mic-worker.js(heavy DSP belongs in this Worker); that directory belongs to your project and SDK upgrades won't overwrite it.
When using the low-level primitives directly (audio.requestMicrophone + mic.start/read/stop, for HTML5 without the built-in component), the core constraint is two gestures:
- In the callback of the 1st tap (an "Enable voice control" button), call
audio.requestMicrophone()to request permission.available: false= the user denied it or there is no mic: show a clear blocking message and allow retrying (no black screen / hang / silent failure); an alternative no-mic gameplay mode is optional, not required. - After the permission result comes back, don't call
mic.start()directly — the permission callback is not in a gesture context (Godot signals are dispatched via per-frame polling and inevitably leave the gesture context), so the AudioContext would be blocked by the autoplay policy with no way to recover. Stop at a "tap again to start" prompt and callmic.start()in the callback of the 2nd tap. - Then call
mic.read()each frame to synchronously pull mono float32 PCM ([-1,1]), and callmic.stop()when you no longer need it.
start()is idempotent and re-entrant; concurrent/repeated in-flight calls are deduplicated into the same result and never return a transientBUSY.dropped > 0fromread()means you pulled too slowly and the ring buffer overwrote data (usually harmless for real-time control; don't use it as a continuous stream for recording).state: "interrupted"means capture was interrupted (incoming call / backgrounded); the SDK rebuilds it automatically on returning to foreground. If the interruption persists, prompt the player to tap and callstart()again.- The actual sample rate is the
sampleRatereturned bystart(); a requested rate is not guaranteed to take effect. - The SDK provides raw PCM only and does no feature analysis; do pitch/volume extraction in your own Web Worker. Don't run heavy DSP in GDScript or on the main thread (it competes with the engine for frame budget).
- Test voice-control sensitivity with BGM playing — the SDK explicitly disables echo cancellation / noise suppression (otherwise voices get cancelled to silence while BGM plays), so BGM bleed is always present in the PCM; tune thresholds against the real mix.
Hard rule: PCM may only be analyzed locally in real time. Recording, retaining or uploading it is forbidden and will be treated as a review incident.
iap: in-app purchases
// grantBy declares the expected fulfillment path; it doesn't override the order's frozen config — actual fulfillment follows grantBy in the response
const r = await Builda.iap.showPayPanel("coin_pack_1", { grantBy: "client" });
// r.data = { success, orderId, grantBy } success only means payment succeeded; the order stays in Paid
// r.data.grantBy === "client": fulfill idempotently by orderId, then
await Builda.iap.claim(r.data.orderId);
// r.data.grantBy === "room": hand orderId to Room doOrder; the client neither fulfills nor claims
// { orderId, status: "delivered" } (idempotent)
const pending = await Builda.iap.getPendingOrderIds();
// pending.data = { orderIds: string[] }
saleIdis thepayIddeclared inpayPointsof the manifest (builda.publish.json) (format^[A-Za-z0-9_-]{1,64}$). Product configuration and order creation/query are all handled by the host talking directly to the platform; the game must not construct order API calls itself.success == trueonly means payment succeeded — the order stays in Paid; it is not fulfilled yet.falsemeans cancelled or failed; don't grant anything. ThegrantByin the response is the fulfiller frozen with this order — it is authoritative: forclient, the client grants idempotently byorderIdand must callclaim(orderId)after granting (Paid→Delivered, idempotent; unclaimed orders stay in the undelivered list forever); forroom, hand theorderIdto RoomdoOrder— the client must not grant or claim.Builda.pay.showPayPanelis legacy: the host auto-claims before responding, the order goes straight to Delivered, it can't be re-fulfilled, and it can't be used withgrantBy=room. It is kept only for existing live games; don't use it in new code.payhas noclaim/getPendingOrderIds.getPendingOrderIds()is read-only and returns the list of undeliveredorderIds, withoutpayId/grantBy. Fulfillment responsibility is whatever was frozen at order time; you can't infer it from the current price list or guess it from a bareorderId. When unknown, don't assume client fulfillment. Only hand theorderIdto Room once you've confirmed it's a room order (onlyallowedfromdoOrdercarries a trustedpayId); only fulfill idempotently on the client once you've confirmed it's a client order.payPointsdeclaration rules:priceis an integer in the platform's virtual currency, G coins (1 CNY ≈ 10000 G coins), minimum 100, preferably a multiple of 100. The fulfillergrantBymay only beclient/room; omitted or""meansclient. Room fulfillment must be explicit:"grantBy":"room". EachpayIdcan have only one fulfiller. With no in-app purchases, omitpayPointsentirely (omitted = leave the platform's existing price list unchanged); don't write an empty array. There is currently no self-service way to "clear all pay points" — to remove them, contact the platform.- Changing an existing
payIdchanges the product mapping; always confirm before changing it.
ad: rewarded video
const r = await Builda.ad.showRewardAd("revive");
// r.data = { success, transId }
posIdis a game-defined ad placement label for analytics (e.g.revive,double_reward) — any string, used only to distinguish in-game trigger points in stats. It is not a platform-assigned ad slot ID; no application or manifest declaration is needed.success == true= watched to completion; grant the in-game reward according to your game's design.false= failed / closed early / host has no ads integrated; grant nothing. The platform only plays the ad and returns the result; it grants no rewards.transIdis the platform's ad transaction ID, usable for your own display or server-side verification. The game must not call any ad verification/redemption API.- In a browser or without a host, payments/ads return
HOST_UNAVAILABLE— never fake success.
input: text input
In a mobile WebView, text boxes drawn by the game can't get the system IME (no candidate list, cursor or selection). For all text input — naming, entering room codes, etc. — call:
const r = await Builda.input.showInputPanel({ placeholder: "Enter a nickname", defaultValue: "", maxLength: 16 });
// confirmed: { confirmed: true, text } cancelled: { confirmed: false, text: "" }
- The returned
texthas already gone through the platform's content moderation and length truncation; use it directly without submitting it for review yourself. - For prefilled cases such as renaming, pass the current value as
defaultValue.maxLengthdefaults to 64, max 256. - Single instance at a time: calling again while the panel is still open returns
BUSY. - Don't draw your own text boxes and listen for keyboard events.
rank: leaderboards
Leaderboards must first be declared in rankBoards of builda.publish.json and take effect after the release passes review
(each board has rankId/displayName/sortType/cycleType/minScore/maxScore, up to 100 boards;
sortType: asc|desc, cycleType: day|week|month|forever).
await Builda.rank.submitScore("weekly_score", 1234);
const r = await Builda.rank.getRankList("weekly_score", { limit: 50 });
// r.data = { rankId, list: [{rank, score, player}], self: {rank, score} | null }
- Scores are integers; the platform-wide range is ±9999999999. Each board's own min/max is validated authoritatively by the host against the published config (unpublished:
RANK_NOT_FOUND; out of range:SCORE_OUT_OF_RANGE). - The host keeps each player's best score per cycle according to
sortType— just submit at settlement; no need to compare scores yourself. getRankListdefaults to 50, max 100;selfbeingnullmeans the player isn't ranked in this cycle.- Changing
rankIdis equivalent to creating a new board (historical scores are lost); changing sort order / cycle / score range, or deleting a board, clears scores once the release is reviewed and published. - The mock build does not validate board configuration (it has no access to the manifest) and only returns fixed fake opponents for UI debugging; verify real semantics in a Builda App draft playtest.
events: subscription callbacks (app foreground/background, pause panel)
Request-style APIs are "the game asks, the platform answers"; events are "the platform pushes, the game receives". There are currently two events.
const off = Builda.events.on("appVisibility", ({ visible, source }) => {
if (!visible) pauseGame(); // app minimized / backgrounded / screen locked
else resumeGame(); // back in the foreground
});
// off(); // unsubscribe when needed; Builda.events.off("appVisibility") clears all subscriptions
Builda.events.on("pausePanel", ({ visible }) => {
if (visible) pauseGameplay(); // host "Paused" panel shown (same panel as capsule ✕ or runtime.quit())
else resumeGameplay(); // panel closed without quitting
});
appVisibility:visible=false= app minimized / backgrounded / screen locked (the WebView is still alive),true= back in the foreground. Onfalse, pause gameplay, timers and BGM, and stop unnecessary network writes; ontrue, resume.sourceis"host"(pushed by the host, precise) or"browser"(WebViewvisibilitychangefallback); the SDK normalizes and deduplicates by state, so the game doesn't need to distinguish them.pausePanel: when the panel appears the host pauses BGM and stops sound effects itself, but game logic keeps running in the WebView; this event lets the game pause gameplay and timers in sync. An actual quit does not firevisible:false. It only comes from host pushes; there is no browser fallback.- The two events are orthogonal: backgrounding while the panel is open fires both. Handle them as "pause if either is paused, resume only when both are resumed"; the SDK does not merge them.
- Both fire only on state changes; subscribing does not replay the current state (initially assume foreground and panel hidden).
onreturns an unsubscribe function; registering the same handler withonmore than once registers it only once; a handler that throws does not affect other subscribers. - Engine side: Godot uses the
app_visibility_changed(visible, source)/pause_panel_changed(visible)signals ofbuilda.gd(generic formsdk_event(name, payload)); Unity usesBuildaSDK.AppVisibilityChanged/PausePanelChanged(generic formBuildaSDK.EventsOn(name, handler)). All callbacks run on the engine's main thread. - Local
builda devtest shell: the "Background" button simulates host foreground/background pushes; the pause panel opened by the capsule button and its "Resume" button simulatepausePanel; switching browser tabs goes throughsource:"browser".
online: multiplayer (Builda Online)
If you need multiplayer matches, rooms or an authoritative server, networking is provided by Builda Online, released alongside this SDK at the same version:
Builda Online user documentation. After completing the Builda integration on this page, have your project AI run
./.builda-agent/builda sdk install --with-online (CLI 0.4.56+): the Online Skill, client SDK, Room SDK and local mock
are all installed at this SDK's version and upgraded together by sdk upgrade. AI-readable setup instructions: https://ai.builda.game/online/releases/0.12.3/setup.md.
Local development and debugging
The builda dev test shell
./.builda-agent/builda dev --web <build-dir> # e.g. dist, build/web
./.builda-agent/builda dev --web build/web --safearea 44,0,34,0 # simulate a notch + bottom home bar
Open the printed dev-url. The test shell provides: portrait/landscape switching, notch/safe-area simulation, capsule button simulation, payment/ad mock modals
(simulating host panels overlaid on the WebView), "Deny mic" / "Mic interrupt" toggles, and a "Background" button (simulating host foreground/background pushes);
the pause panel opened by the capsule button also simulates events.pausePanel.
Mock environment URL parameters
| Parameter | Effect |
|---|---|
builda_mock_player / builda_mock_name | Switch the mock player identity (KV is isolated per player) |
builda_mock_lang=es-419 | Override the value returned by runtime.language() to test language fallback |
builda_mock_safearea=44,0,34,0 | Same as --safearea; injects the safe area (top,right,bottom,left) |
builda_mock_mic=deny | Simulate the user denying the microphone |
Mock vs. real device differences
Passing local tests ≠ working on a real device. The mock verifies API shapes and UI flows; real identity, KV, payment, ad and leaderboard semantics can only be verified by playtesting a draft in the Builda App pending-release area.
| Capability | mock (builda dev) | Real device (Builda App) |
|---|---|---|
| Identity | Fake player from URL parameters | Real logged-in player |
| privateKV | localStorage (same quota checks; write rate not enforced) | Host persists to server/local storage; write rate 60/min |
| Payments/ads | Modal where you click success/failure manually | Real host panels/ads |
| Audio | Browser Audio (subject to autoplay limits) | Host-native, no autoplay limits |
| Leaderboards | No board config validation, fixed fake opponents | Authoritative validation against published rankBoards |
| requestToken | Fake token, can't reach real services | Host exchanges a real token with the platform |
| events.appVisibility | Shell "Background" button simulates host pushes; switching tabs uses the browser fallback | Host onAppEvent pushes + WebView fallback |
| events.pausePanel | Pause panel opened by the shell's capsule button / "Resume" | Host "Paused" panel shown/closed pushes |
| SDK file | dev serves the mock build as a fallback | Host injects the production build from the CDN per the manifest |
Pre-release checklist
runtime.ready()is called at the very start of the game and is reached on every startup path (in dev, check the console for the 15-second warning; on device, the game is force-closed if it isn't received within 30 seconds).- The
index.html<head>keeps the external<script src="builda-sdk.js">reference, and the zip does not bundle the SDK file. - Private byte saves go through privateKV; Online server-managed Player Data is independent, with no localStorage/local-file saves of your own; writes happen at event milestones, with no timed writes.
- No homemade music/SFX toggles, exit button, or "tap to enable sound" overlay.
- Key UI avoids safeArea and capsuleMenuRect (tested in portrait and landscape with
--safearea). - Payments use
Builda.iap(not legacypay);successonly means payment succeeded; only withgrantBy=clientdoes the client grant idempotently by orderId andclaim; withgrantBy=roomthe orderId is handed to Room. - Text input uses
showInputPanel, with no self-drawn text boxes. - If using leaderboards:
rankBoardsis declared in the manifest and therankIds in code match. - If using assets: no hard-coded CDN URLs;
res/**resources are not duplicated in the main package; the load-failure path has been tested. - If using BuildaMic (Godot):
addons/builda/mic/web/has been copied into the export directory and ships in the zip. - Voice-controlled games: denied permission, interruption/recovery, sensitivity with BGM on, etc., have each been run through in the shell.
Common AI commands
Once the toolchain is installed, just say these to your agent (Claude Code / Cursor, etc.) for the following tasks:
| Command | Effect |
|---|---|
| Open local preview | Starts the local mock page so you can preview the in-App experience in a desktop browser |
| Upload draft | Uploads the game to Builda App, where you can then publish it |
| Deploy server (multiplayer games) | Uploads and activates the Room server source, carried out by the Builda Online Skill; see Builda Online user documentation |
Cautions and hard rules
Hard rules (violations lead to review rejection or are treated as incidents):
- Don't bypass the SDK to call the host bridge directly — game code must never touch
window.__cysjHost,flutter_inappwebview.callHandleror any low-level App bridge method.- Tokens stay in memory only — the token from
requestTokenmust never be written to privateKV, local files, URLs, logs or saves.- Microphone PCM may only be analyzed locally in real time — recording, retaining or uploading it is forbidden.
- Never fake success without a host — payments/ads returning
HOST_UNAVAILABLEin a browser is correct behavior; don't paper over it in the game as "purchase succeeded".- Don't modify SDK distribution files —
builda-sdk.js,addons/builda/(exceptmic/web/) andAssets/Builda/are overwritten wholesale on upgrade bysdk install/builda check; the only things you own arescripts/BuildaClient.gd, the mic analysis worker directory and your own game code.
Persistent identifiers (think carefully before changing)
- privateKV key:
^[A-Za-z0-9_:-]{1,64}$;payId/rankId:^[A-Za-z0-9_-]{1,64}$. New code should use only letters, digits,_and-. - Changing a
payId= changing the product mapping; changing arankId= a new board with history wiped. Don't casually rename IDs that are already live. - Legacy keys in existing data (e.g. ones containing
:) can still be read, written and deleted; don't force a migration just for format consistency.
What the platform handles (so the game shouldn't)
- Login / token management (
whoamigives you the player directly) - Music/SFX toggles, pause/exit page (capsule menu + platform standard page)
- Text moderation and truncation (the result of
showInputPanelis final) - Order creation/query (don't build order HTTP calls yourself; use
iap.getPendingOrderIdsfor the undelivered list. Recovering missed fulfillment is the game's job — see the iap section above) - Ad verification (the result of
showRewardAdis the verdict) - Leaderboard best-score comparison and cycle rotation (the host keeps scores per sortType)
- Audio unlocking (real devices have no autoplay limits)
Common error codes
| code | Meaning |
|---|---|
HOST_UNAVAILABLE | No Builda App host (the normal failure state when opening the runtime build directly in a browser) |
PENDING | Timed out waiting for the host to respond (default 120s; pass timeoutMs to change) |
BUSY | A single-instance panel is already open (e.g. calling input again while its panel is still open) |
UNSUPPORTED | Engine wrapper called outside a Web/WebGL environment |
BAD_KEY / BAD_VALUE / BAD_PAY_ID / BAD_RANK_ID / BAD_SCORE | Invalid argument format (local pre-validation by the SDK) |
VALUE_TOO_LARGE / BATCH_TOO_LARGE / QUOTA_EXCEEDED | privateKV limit exceeded |
RANK_NOT_FOUND / SCORE_OUT_OF_RANGE | Leaderboard not published / score out of range (authoritative host validation) |
PERMISSION_DENIED / MIC_UNAVAILABLE / MIC_NOT_STARTED | Microphone permission denied / unavailable / read before start |
WORKER_LOAD_FAILED | The BuildaMic analysis worker wasn't shipped with the package |
JS_ERROR / BAD_RESULT / BAD_RESPONSE | Bridge-layer error (usually an integration mistake; check the SDK version and how you're calling it) |
Further reading
| Document | Contents |
|---|---|
h5_bundle_spec.md | Game zip packaging spec (entry point, exclusion list, per-engine validation rules) |
md_persistent_ids.md | Full contract for persistent identifiers (KV key / payId / rankId) |
md_rankboards.md | Full contract for leaderboard manifest configuration |
md_bundle_size.md | Package size reduction and res/** asset splitting in practice |
md_godot_i18n.md / md_unity_webgl.md | Localization and fonts; Unity WebGL export |
md_mobile_perf.md | Mobile performance / dynamic DPR |
| Builda Online user documentation | Multiplayer: player experience choices, matchmaking and rooms, Builda App's built-in room chat and invites, usage limits; AI installation: builda sdk install --with-online, instructions at https://ai.builda.game/online/releases/0.12.3/setup.md |
The md_* topic documents are published with SDK 0.12.3 at https://ai.builda.game/game-sdk/releases/0.12.3/ and are also installed into your project via the guidance/ folder of each of the three engine packages; h5_bundle_spec.md ships with the Agent toolchain.