Read this page as markdown — for coding agents, or for anyone who prefers the source.
Arcade SDK
Reference for public/sdk/v0/bananai-sdk.js, served at
/sdk/v0/bananai-sdk.js. Current version: 0.3.0.
Every game must load the SDK and use it for all communication with the portal.
Games run in a sandboxed iframe and must never touch window.parent directly.
A note on prior art
The API surface here deliberately follows the shape that portal SDKs have converged on — a loading phase, explicit gameplay start/stop, a game-over signal, and ad hooks. That convention is worth adopting for a concrete reason: a developer who has shipped on another portal can port a game here in an afternoon.
The implementation and this documentation are our own. We do not copy another portal's SDK source or docs — that is someone else's copyrighted work, and shipping it would be both a legal problem and an obstacle to the independence we actually want. Following an established interface convention is normal engineering practice; copying an implementation is not.
Advertising is part of that convention, and both methods really cross to the shell, which owns the entire ad stack. They were local shims in 0.1.0 and have not been since. A game written against an ad-funded portal runs here unmodified either way, which was the point of matching the shape in the first place.
Crossing the frame is not the same as an ad appearing. Advertising ships
switched off, so today both calls resolve without showing anything, and
rewardedBreak() resolves false. Write for that answer — it is the one you
will get.
Your game never loads an ad script. It asks; the shell answers. That is what keeps your folder self-contained, keeps third-party ad code off your origin, and lets the portal change ad providers without you redeploying anything.
Wire protocol
One envelope, as of 0.2.0:
// game -> shell
{ source: 'bananai-sdk', sdkVersion: '0.3.0', gameId: 'gemburst', type, payload }
// shell -> game
{ source: 'bananai-shell', type, payload }
Replies are typed <type>:reply.
Three reasons this is the right shape, all of which the previous one lacked:
gameIdtravels with every message. The host would otherwise have to infer which game is talking from which frame sent the message, which stops working the moment more than one frame exists.sdkVersionis declared. The host can adapt to older games instead of guessing, which is what makes supporting third-party submissions realistic.- Both ends are named.
sourcesays who sent it; a sharedchannelconstant only says the message belongs to us, not which direction it travels.
Until 0.2.0 the SDK sent { channel: 'turbigo', type, payload } and the host
accepted both shapes, replying in whichever it was addressed in. That
compatibility layer is gone: it was carrying the old product name, and an alias
kept for a name nothing uses is an alias that never gets removed.
If you have a copy older than 0.2.0, it can no longer talk to the portal.
Nothing partially works — the handshake times out, and your game runs as though
it were standalone: no loading bar, no score record, no playtime attribution,
and bananai:restart never arrives. Take a fresh copy of the file.
Lifecycle
const { language } = await Bananai.init({ gameId: 'gemburst' });
// while loading assets
Bananai.loadingProgress(0.4);
Bananai.loadingFinished();
// when the player actually starts playing
Bananai.gameplayStart();
// on pause, menu, or any interruption
Bananai.gameplayStop();
// when a run ends
Bananai.gameOver({ score: 12500, level: 7, durationMs: 84000 });
API
init(options): Promise<{ language, locale, region, device }>
Must be awaited before any other call. Resolves once the shell acknowledges, or immediately when the game runs standalone.
| Option | Type | Description |
|---|---|---|
gameId |
string | Required. Must match the catalogue ID. |
debug |
boolean | Logs SDK activity to the console. |
It never rejects, and it never throws. There is nothing to catch and no error to handle — which is deliberate, because a game that refuses to start because the portal was slow is worse than a game that starts.
What actually happens when the shell does not answer: init() waits 5
seconds, then resolves anyway with no session token and every field below
null, and the game proceeds exactly as it would standalone. Calling it twice
is a no-op — the second call returns immediately with the same values, without
a second handshake.
The resolved value, added in 0.3.0, is the same object also readable at
any time as Bananai.language / Bananai.locale / Bananai.region (see
below), plus device:
const { language, locale, region, device } = await Bananai.init({ gameId: 'gemburst' });
// language: 'it' — resolved, see below
// locale: 'it-IT' — BCP-47, for Intl
// region: { country: 'IT', source: 'timezone', timeZone: 'Europe/Rome' }
// device: { standalone: false }
By the iframe rule none of these four are things a game can read for itself
with any confidence — see "The player's language crosses the iframe" in
ARCHITECTURE.md for why the shell resolves and hands them over instead of a
game reading navigator.language on its own.
That has a consequence worth stating plainly, because it has already cost one game a release. A game whose SDK cannot reach the shell is indistinguishable from a game running standalone, and neither one looks broken. It loads, it plays, it saves its own high scores — and it reports no playtime, no scores and no ads, so it earns nothing. If you are debugging a game that seems fine but records nothing, this is the first thing to check, not the last:
await Bananai.init({ gameId: 'my-game', debug: true });
With debug on, the SDK logs initialised { standalone: true } when it got no
session token. Inside the portal, standalone: true means something is wrong —
almost always a vendored SDK older than 0.2.0, which speaks a message envelope
the shell no longer understands.
Bananai.version is readable at any time and reports what the loaded SDK
actually is, which is more reliable than what a manifest claims.
Two unrelated meanings of "standalone" appear in this document — the debug
log two paragraphs up ("no session token, game running with no shell at all")
and device.standalone below ("the shell is installed as a fullscreen PWA")
are not the same fact, and a game can be in either, neither, or both at once.
Bananai.language / Bananai.locale / Bananai.region
Readable at any time once init() has resolved; also returned directly by
init() as shown above. Stay current across a language switch — see
bananai:language under "Events from the shell".
language is already resolved against this game's own declared
languages in bananai.json: the shell knows what the game supports and
never hands over a code the game did not declare. A game with
languages: ["it"] gets 'it' regardless of what the player picked; one
declaring ["en", "it"] gets whichever of the two the player chose in the
shell. languages: [] — a game with no real text to translate — gets the
shell's raw choice unresolved, since there is no wrong answer for a game that
reads none of this.
locale is the browser's own BCP-47 tag (navigator.language, e.g.
'it-IT'), independent of language and of what the game declared. Use it
for Intl.NumberFormat/Intl.DateTimeFormat — score and date formatting want
the fullest tag available, not the two-letter UI language.
region is { country, source, timeZone }, and it is advisory only —
never use it for anything that must be correct, such as pricing or a
leaderboard region. country is a best-effort guess from the player's system
timezone against a small, deliberately partial lookup table; it is null
when the timezone is not in it. source is 'timezone' today and will become
'server' once the CDN can supply a real geo-IP header, with nothing else
about the shape changing — code that reads region.country today keeps
working unchanged when that happens, just with fewer nulls.
getDeviceInfo()
Bananai.getDeviceInfo(); // { standalone: false }
Facts about how the portal is being viewed that a game cannot determine for
itself with confidence from inside the frame. Today, one field: standalone,
true when the shell is running as an installed, fullscreen PWA — useful for
not offering your own "install this" nudge inside a game that is already
installed.
captureError(error)
window.addEventListener('error', event => Bananai.captureError(event.error));
Reports an error to the portal instead of your game loading its own
crash-reporting service — which npm run check would refuse as an external
request from a game folder regardless. Fire-and-forget: nothing to await,
nothing that can fail on your side. Pass an Error (its message and stack
are read) or any value that has a sensible String() conversion.
There is no reply and no backend yet — reports are logged and buffered on the shell side against the day one exists — so nothing in your game should depend on this call doing anything observable.
loadingProgress(fraction)
fraction is clamped to 0..1. Drives the shell's loading indicator. Call it
during asset loading, not after.
loadingFinished()
Assets are ready. The shell hides its loading state.
gameplayStart() / gameplayStop()
Bracket every period of active play. Idempotent — repeated calls in the same state are ignored. The shell uses these to avoid interrupting live gameplay and to measure engagement.
These two calls are how you get paid. Active playtime is the basis of the revenue share, and a game that never brackets its play looks to the portal like a game nobody plays. They are also what stops an ad appearing over a live run. See the revenue share terms, sent on request.
Call gameplayStop() on pause, on opening a menu, and whenever the tab loses
visibility.
gameOver(result)
Ends the run and produces a score record. Implicitly calls gameplayStop().
| Field | Type | Required |
|---|---|---|
score |
number | yes |
level |
number | no, defaults to 1 |
durationMs |
number | recommended — used for plausibility checks |
happyMoment(intensity)
Signals player delight: a cleared level, a beaten personal best. Telemetry only today; the shell may later use it to time non-intrusive prompts. Do not call it on every point scored.
commercialBreak(): Promise<void>
Asks for a commercial break and resolves when it is over.
Call it between activities only — a menu, a level transition, a game over —
and call gameplayStop() first. Your game is suspended for as long as the
promise is pending, and the shell refuses the break outright if its own gameplay
signals say a run is still live.
It always resolves, and resolves quickly when there is nothing to show: no fill, frequency caps, no consent, advertising disabled, a blocked ad script. Nothing in your game may depend on a break having actually happened.
Since 0.3.0, bananai:adStart fires the moment content actually begins
showing — earlier than this promise resolves, which only happens on
completion. It is the one signal to duck your own audio in time:
window.addEventListener('bananai:adStart', () => muteMyAudio());
await Bananai.commercialBreak();
unmuteMyAudio();
It does not fire at all when there was nothing to show — the same cases listed above. A game that only unmutes after the promise resolves does not need this event, but one that wants silence during the break rather than guessing when it started does.
rewardedBreak(): Promise<boolean>
Plays a rewarded video and resolves true only if it was watched to the end.
Ask the player first — the offer is yours, since only your game knows what it is offering. The shell just plays the video.
Games must handle false gracefully, and false is the common case: no
fill, a dismissal, no consent, an ad blocker, or a portal with advertising
switched off. A game that gates progress behind a rewarded video is not
publishable here.
Bananai.gameplayStop();
const earned = await Bananai.rewardedBreak();
if (earned) grantExtraLife();
else showNormalGameOver(); // must be a complete experience on its own
Bananai.gameplayStart();
Rewarded pays more per impression than any other format, so a natural offer — a continue, an extra life, a hint — is worth implementing. How that turns into money is in the revenue share terms, sent on request.
setDebug(enabled)
Events from the shell
The SDK re-dispatches shell messages as CustomEvent on window:
window.addEventListener('bananai:restart', () => resetGame());
window.addEventListener('bananai:pause', () => pauseGame());
window.addEventListener('bananai:resume', () => resumeGame());
window.addEventListener('bananai:language', event => redraw(event.detail.language));
window.addEventListener('bananai:adStart', () => muteMyAudio());
bananai:restart fires when the player chooses to play again after a game over.
Handling it is mandatory — a game that requires a page reload to restart fails
QA.
bananai:language (0.3.0) fires when the player switches language while your
game is open — the shell does not reload the frame, so Bananai.language read
once at boot would go stale for the rest of the visit. event.detail is
{ language, locale, region }, the same shape init() resolves with. Handling
it is only necessary if your game renders its own UI text; one that draws
nothing but shapes and a score has nothing to redraw.
bananai:adStart (0.3.0) — see commercialBreak() above.
Standalone mode
When a game is opened directly rather than embedded, window.parent === window
and every SDK call becomes a safe no-op. Games must remain fully playable in
this mode; it is how developers work locally and how QA reviews submissions.
Migration from the v0 event contract
Gem Burst predates the SDK and uses raw window events (arcade:gameover,
arcade:restart). Those still work but are deprecated.
| v0 | v0.1 |
|---|---|
dispatchEvent('arcade:gameover', {score, level}) |
Bananai.gameOver({score, level}) |
addEventListener('arcade:restart') |
addEventListener('bananai:restart') |
Every game in the catalogue has been migrated. Nothing still speaks the old contract, and the shell no longer listens for it.
Loading the SDK
Two supported options. Both are accepted in review, but they are not equally
good, so this section says which to prefer and why. Whichever you choose,
declare it in sdkVendored — false for a link, true for a vendored copy —
so review does not have to open index.html to tell which.
Preferred: link to the portal's copy
<script src="/sdk/v0/bananai-sdk.js"></script>
Your game is served from the portal's own origin, under /games/<id>/, so this
path resolves without any configuration. There is one copy of the SDK on the
whole site, and fixes reach your game without you redeploying — which is the
entire point of the versioning policy below.
Accepted: vendor a copy into your folder
<script src="js/vendor/bananai-sdk.js"></script>
The folder stays self-contained and portable to another portal. The cost is that
the wire protocol freezes at the version you copied, including the
advertising methods. A vendored copy can never receive a fix, so its minimum
and its exact contents are the same number: declare it in bananai.json as
sdkMinVersion, and set sdkVendored to true.
Vendor when you have a reason to — you ship the same build to several portals, or you need the game to run from a folder that is not under a portal root. Link otherwise.
Never write the origin
<script src="/sdk/v0/bananai-sdk.js"></script> <!-- correct -->
<script src="https://bananai.games/sdk/v0/bananai-sdk.js"></script> <!-- rejected -->
The absolute form looks more explicit and is strictly worse. It is a cross-origin request everywhere the portal is not live production: on your machine, on the preview domain, and in our automated checks, which fail any request leaving the origin under test. It also stops working the day the portal changes domain — and a game folder is not ours to edit, so we could not fix it for you. The root-relative form is correct in all of those places at once, and the checks reject the absolute one on sight: it is a static scan of your files, not a runtime probe, so an absolute URL down a branch nothing reaches is caught too.
If you develop with your game folder as the document root, /sdk/v0/... will
404 locally. Put the file at <your-root>/sdk/v0/bananai-sdk.js while you work:
the path is then identical to production, and what you test is what ships.
Vendor the current version, 0.3.0, and download the file rather than writing your own. Both halves of that matter:
- Anything older than 0.2.0 speaks the previous envelope and cannot reach the portal at all. Before that, 0.1.0 had a second defect worth knowing about: its two advertising methods were local stubs that resolved without sending anything, so a game vendoring it could not show an ad or earn from one, and nothing about it looked broken — it simply never requested. One game in our own catalogue is in exactly that state, and it is the reason this paragraph exists.
- A reimplementation written from this document will be subtly wrong in ways
neither of us will enjoy diagnosing: the envelope, the reply types, the
timeouts and the duplicate-suppression rules are all part of the contract, and
prose is not a specification. Take the file from
/sdk/v0/bananai-sdk.jsand ship that.
Whichever you choose, sdkMinVersion must state what the game genuinely
needs — for a link, the oldest SDK it still works against; for a vendor, the
exact copy it carries, since there is no other version it could run.
Versioning
The SDK follows semver. Games pin a major version:
<script src="/sdk/v0/bananai-sdk.js"></script>
v0 is initial development, and it is not stable. That is what a major
version of zero means in semver, and it is meant literally here: the protocol is
still being designed, and a breaking change inside v0 stays at /sdk/v0/ rather
than minting a new major. We announce it, we say what to change, and the minimum
accepted version moves — but we do not promise to keep an older v0 answering
beside it. Building against a 0.x protocol means accepting that.
From v1 onwards, that reverses. v1 is published when the protocol is settled rather than when it is convenient, and it is the point at which stability becomes a commitment: after it, a breaking change ships as a new major and the previous one stays alive for at least six months, because a developer with a deployed game cannot be expected to redeploy on our schedule.
So the guarantee is not absent, it is deferred to the version that is supposed
to carry it. Ask what the minimum accepted sdkMinVersion is before you start,
not after — it is stated in the submission rules and it moves.
sdkMinVersion replaced sdkVersion because the old name stopped saying
what it meant the moment a game could link the shared copy: "what your package
contains" is only ever true for a vendored copy. The field now means the
lowest version the game genuinely requires, which is why a game that links and
uses nothing new can stay declared at 0.2.0 for months without that being
stale — it is a true statement, not an old one. The wire envelope keeps its own
sdkVersion, reporting what is actually running; the two no longer share a
name, so envelope.sdkVersion >= manifest.sdkMinVersion replaces the strict
equality the checker used to require, which the rename would otherwise have
broken on every game that links.
The floor and the ceiling move on different schedules. The floor — the
oldest sdkMinVersion still accepted — is raised by hand and deliberately
slowly: a game can stay declared at an old minimum for weeks or months after a
newer version ships, and when the floor does move, a game caught under it gets
a warning window, not an immediate stop. The ceiling is the opposite: a
manifest cannot declare a sdkMinVersion higher than what /sdk/v0/ actually
is today, checked automatically, because that is a mistake worth catching
before deploy rather than at runtime.
0.2.0 is the precedent, and it was not an accident of process. It renamed
the global, the events, the manifest and the message envelope, and it did not
get a new major. The reason recorded at the time was that there was nothing
deployed to protect: the file was documented at /sdk/v0/ while it actually sat
at /sdk/, so the documented URL had returned 404 for its entire existence —
confirmed by an external developer, who reimplemented the SDK from prose because
he could not download it. Under the rule above that reasoning is no longer
needed. A breaking 0.x release does not require an excuse.
0.3.0 added language/locale/region/device to init()'s resolved
value and as live-readable properties, the bananai:language and
bananai:adStart events, and captureError()/getDeviceInfo() — additive
only, so it did not touch the envelope. A game declaring sdkMinVersion: "0.2.0" that links the shared copy keeps working unchanged; it simply does
not call the new methods.
0.2.0 unified the envelope and carried the rename from the product's previous name. Both were breaking and both were unavoidable, so they went out together rather than asking every developer to migrate twice for one product decision. 0.1.1 turned the two advertising methods from stubs into real calls; 0.1.0 was the first release.
Two older URLs still redirect here rather than 404, because a game folder is not
ours to edit and neither is anyone else's <script src>:
/sdk/turbigo-sdk.js → /sdk/v0/bananai-sdk.js
/sdk/v0/turbigo-sdk.js → /sdk/v0/bananai-sdk.js
A redirect fixes the URL, not the file behind it: a game that follows one still receives the current release and must speak the envelope introduced in 0.2.0, unchanged since.
Document history
- 2026-08-21 — 0.3.0:
language/locale/region/deviceoninit(),bananai:language/bananai:adStartevents,captureError(),getDeviceInfo().languagesis now mandatory inbananai.json(an empty array is still valid). - 2026-08-19 —
sdkVersionrenamed tosdkMinVersion; the checker's rule changed from equality toenvelope.sdkVersion >= manifest.sdkMinVersion. - 2026-08-18 — Versioning policy clarified: v0 is a development protocol, v1 is what promises stability.
- 2026-08-08 — Product renamed to Bananai; message envelope unified at 0.2.0.
- 2026-08-07 — Advertising hooks (
commercialBreak,rewardedBreak) added, shipped switched off.