Developers

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:

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 sdkVendoredfalse 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:

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