Troubleshooting

Symptom → likely cause → how to verify → fix. The page you reach for when something silently doesn't work.

When the SDK doesn’t behave the way you expected, this page is the fastest path from a symptom to a root cause. The errors page catalogues what each GameeError code means; this page is the other half — the symptoms that don’t raise a typed error and the ones that look like one problem but are really another.

Each section has the same shape: Symptom → Likely cause → How to verify → Fix. Scan the symptom headings; jump in when one matches.

init() never resolves#

Symptom. Your boot code awaits gamee.init(...) and the promise hangs. Eventually (after 30 s by default) it rejects with BRIDGE_TIMEOUT — or it never resolves at all because you forgot the await.

Likely causes, in order of frequency:

  1. You’re running the game outside a GAMEE host. Opening the HTML file directly, previewing in a search engine, or running astro dev standalone means there’s no platform on the other side of the bridge.
  2. On web: the platform’s origin isn’t in allowedOrigins. Inbound postMessage messages from origins outside the list are dropped without a warning by default. To the SDK, the platform looks silent.
  3. On iOS/Android: the native handler isn’t wired up. The host app needs webkit.messageHandlers.callbackHandler (iOS) or window._toDevice (Android) installed before the game loads. A misconfigured host app looks identical to “no host at all” from the SDK’s point of view.

How to verify.

  • Wrap your boot in hasPlatformContext() before calling init(). If it returns false, you’re not inside a real host — render a fallback instead of hanging.
  • Open the emulator, point it at your game URL, and watch the Bridge traffic panel. If you see your init request leave the game but never get a response, the host side isn’t replying.
  • On web, check window.parent.postMessage is being received: temporarily set allowedOrigins: '*' in createSdk() and see if init resolves. If it does, your real origin list is too strict — add the platform’s origin.

Fix. Always guard boot with hasPlatformContext(). In production, configure allowedOrigins explicitly so messages from the real platform origin are accepted (config). On native, talk to the host-app team about handler wiring.

Saved state isn’t coming back#

Symptom. You call gamee.gameSaveState(...), the next session starts, and initResponse.saveState is undefined (or stale).

Likely causes.

  1. You didn’t declare the saveState capability in init(). The SDK throws CAPABILITY_MISSING synchronously the first time you call gameSaveState — but if you swallowed the error, you’d never notice.
  2. You called gameSaveState before init() resolved. Signals validate immediately but rely on the SDK being in the post-init state to actually ship. A pre-init call rejects synchronously with INVALID_STATE.
  3. You passed un-stringifiable data (a Function, a circular ref). The SDK does JSON.stringify on object values and that throws.
  4. The platform didn’t return the previous save in this session. This is a platform decision, not an SDK bug — for example, the user reset their save from the platform UI.

How to verify.

  • console.log(gamee.initResponse?.saveState) right after init() resolves. If it’s undefined, the platform either has no save or chose not to send it.
  • Open the emulator and inspect the wire: each gameSaveState call should appear as { method: 'saveState', data: { state: '<string>' } }.
  • Wrap gameSaveState in try/catch (or .catch) and log err.codeCAPABILITY_MISSING is the most common surprise.

Fix. Declare 'saveState' in the capabilities array on init(). Pass plain JSON-serializable values (objects of primitives + arrays + nested objects).

Events I subscribed to never fire#

Symptom. gamee.on('start', ...) is registered but the handler is never invoked. Same for pause, resume, etc.

Likely causes.

  1. You subscribed after calling gameReady(). The platform may dispatch immediately on gameReady(). See Events → subscribe before gameReady() — this is the canonical rule and the most common cause.
  2. The event is capability-gated and you didn’t declare the capability. useExtraLife requires platformExtraLife in init(); without it the platform won’t dispatch the event at all.
  3. init() never resolved (see the first section). No events fire before the handshake completes.

How to verify.

  • Move every gamee.on(...) call above gamee.gameReady(). If the events start firing, ordering was the problem.
  • For useExtraLife, confirm 'platformExtraLife' is in your init() capabilities array.
  • Check the emulator’s bridge traffic for incoming events — if the platform is sending them but your handler isn’t running, you’ve got a subscription order or capability bug.

Fix. Subscribe before gameReady(). Declare every capability you rely on at init.

Validation errors on logEvent / updateScore / updateMissionProgress#

Symptom. A signal call rejects with GameeError({ code: 'VALIDATION' }). Because signals are fire-and-forget, you may only see the rejection as an “unhandled promise rejection” in the console.

Likely causes. Each has a hard limit enforced locally:

MethodConstraint
logEventname ≤ 24 chars, non-empty; value ≤ 160 chars (string).
updateScorescore finite; playTime ≥ 0 and finite; checksum non-empty if set.
updateMissionProgressprogress in [0, 100], finite.
purchaseItemWithGemsgemsCost ≥ 0; itemName non-empty.
initEvery capability string must be one of the known six.

How to verify. Wrap the call in try/catch (or .catch) and log err.method plus err.message. The message names the failing field.

Fix. Clamp / sanitize at the call site. Don’t ship a feature that emits logEvent names longer than 24 chars and rely on the platform to truncate — it won’t.

Rewarded ad never plays#

Symptom. gamee.showRewardedVideo() rejects, resolves with { videoPlayed: false } instantly, or hangs.

Likely causes.

  1. You didn’t call loadRewardedVideo() first (or its result was { videoLoaded: false }). showRewardedVideo shows what’s already loaded; it doesn’t load on demand.
  2. The rewardedAds capability isn’t declared. Both load and show throw CAPABILITY_MISSING synchronously.
  3. You’re not in a real GAMEE host. The emulator stubs the ad UI, but in a standalone browser there’s no ad provider to talk to.
  4. noAds: true in initResponse. The user has paid for an ad-free experience. Rewarded ads remain opt-in but loadRewardedVideo may legitimately return { videoLoaded: false }.

How to verify. Log the loadRewardedVideo result before offering the “watch ad” UI. Check gamee.initResponse?.noAds.

Fix. Pre-load before offering. Hide the “watch ad” button when videoLoaded is false. Treat a false result as the platform’s final answer, not an error.

”Works on web, broken on iOS/Android” (or vice versa)#

Symptom. The same build behaves differently across platforms — events that fire on web don’t on mobile, or init resolves on iOS but hangs on Android.

Likely causes.

  1. Platform-specific code that branches on user agent. Don’t sniff the UA; use sdk.platform after init() or initResponse.platform.
  2. iOS/Android don’t run in an iframe — they load as the top-level document inside WKWebView / WebView. Code that assumes window.parent !== window is wrong on mobile.
  3. The native host app uses an older bridge implementation that doesn’t know about a newer SDK method. The SDK will see BRIDGE_TIMEOUT because the host never replies.

How to verify. Log initResponse.platform and initResponse.environment on every device you test. Reproduce the failing wire traffic in the emulator (it can simulate each platform).

Fix. Treat the bridge as authoritative and never branch on UA. When you genuinely need per-platform behavior (audio unlock semantics differ, for instance), branch on initResponse.platform. For host-side mismatches, coordinate with the host-app team — there is no SDK-side workaround.

INVALID_STATE from a method call#

Symptom. A method rejects with GameeError({ code: 'INVALID_STATE' }).

Likely causes.

  1. init() was called twice on the same SDK instance. See Methods → init is one-shot. The fix is createSdk() in tests, not a second init().
  2. A method ran after dispose(). All pending Promises reject with INVALID_STATE once dispose() is called.
  3. A signal ran before init() resolved. Pre-init signals reject immediately because the SDK has no platform context yet.

How to verify. Log the call order in the failing path. The order is always initon(...)gameReady → everything else. A signal that fires from a top-level module body (before await init) trips this.

Fix. Keep boot logic inside the async function that awaits init. For repeated init in tests use createSdk() per test, dispose in afterEach.

My signal call rejected and I didn’t notice#

Symptom. Behavior silently wrong; “unhandled promise rejection” in the console.

Likely cause. Signal methods return Promise<void> that resolves immediately on success but rejects on validation / capability failures before the call ever hits the wire. If you neither await nor .catch() them, those rejections surface as unhandled.

Fix. For signals you don’t want to await, attach a .catch:

gamee.updateScore(score).catch((err) => {
  if (err instanceof GameeError) console.warn(err.code, err.method);
});

Or set the top-level safety net once at boot so every unhandled GameeError lands in analytics.