# Bananai developer documentation Every published document, concatenated. Source: https://bananai.games/developers/ Generated from docs/developers/ — do not edit. --- # Publishing guide How to get a game onto Bananai. Read [`SUBMISSION-RULES.md`](submission-rules.md) first — this document covers the mechanics, that one covers the requirements. Current process is manual (phase 0–2). The developer portal in phase 4 replaces steps 5 and 6 with an upload form. --- ## 1. Create the folder ``` public/games// ├── bananai.json ├── index.html ├── css/style.css └── js/ ├── main.js bootstrap: mounts the game, wires the SDK └── .js game logic ``` The `` is lowercase, hyphen-separated, and **permanent** — it appears in URLs, cache keys and score records. ## 2. Load the SDK ```html ``` Root-relative, with no origin in front of it. Your game is served from the portal's own origin, so this always resolves; `https://bananai.games/sdk/v0/bananai-sdk.js` is a cross-origin request everywhere except live production and is rejected by the checks. You may also vendor a copy into your folder — [`ARCADE-SDK.md`](arcade-sdk.md) covers the trade-off. ```js await Bananai.init({ gameId: '' }); Bananai.loadingFinished(); Bananai.gameplayStart(); // ... Bananai.gameOver({ score, level, durationMs }); window.addEventListener('bananai:restart', () => resetGame()); ``` Full reference: [`ARCADE-SDK.md`](arcade-sdk.md). ## 3. Technical requirements - **Responsive.** Fill the container, do not assume fixed dimensions. In p5: `createCanvas(el.clientWidth, el.clientHeight)` plus `windowResized()`. - **Touch.** `touchStarted` / `touchMoved` / `touchEnded` must return `false`, otherwise the page scrolls under the player's finger. - **Self-contained.** Libraries and assets live inside the game folder. No external requests. - **Audio silent until interaction.** Browsers enforce this anyway. ## 4. Thumbnail `public/games//assets/thumb.png` (or `.svg`, or `.webp`), square, 400×400, declared in `bananai.json`. It lives inside the game folder, not in the portal's shared `/assets/`. The submission is then one self-contained directory that can be added or removed as a unit — no orphaned image left behind when a game is withdrawn. It has to read clearly at roughly 180px, which is the actual card size in the grid. Check it at that size, not at full resolution. ## 5. Register in the catalogue In `public/index.html`, add to the `games` array: ```js { id: "", title: "Readable Title", category: "arcade", // arcade | puzzle | azione thumb: "games//assets/thumb.png", path: "games//index.html", featured: false, comingSoon: false } ``` From phase 1 this moves to `public/games.json`. If the category is new, add the matching `.chip` button to the category bar. ## 6. Update supporting files - Add the game URL to `public/sitemap.xml` - Add an entry to `docs/CHANGELOG.md` The service worker cache version updates itself at build time — nothing to bump. --- ## Pre-deploy checklist - [ ] Works standalone at `/games//index.html` - [ ] Works inside the shell modal - [ ] Works on mobile: touch, both orientations, 320px width - [ ] SDK lifecycle calls present and correctly sequenced - [ ] `bananai:restart` restarts without a page reload - [ ] Thumbnail visible and legible in the grid - [ ] Search and category filter find the game - [ ] `comingSoon` is `false` **only if** the folder actually exists - [ ] No console errors - [ ] No network requests to external origins - [ ] `CHANGELOG.md` updated Then: ```bash fly deploy ``` --- # Submission rules Requirements a game must satisfy to be accepted. A submission that violates any **MUST** is rejected without review; the QA queue is not a linting service. Applies to internal and third-party submissions alike. --- ## 1. Content **MUST NOT** contain: sexual content, graphic violence, gambling or simulated gambling with real-money framing, hate speech, or anything targeting minors inappropriately. The portal is open to all ages by default. **MUST** declare an age rating: `all`, `7+`, `12+`, or `16+`. **MUST** be original work, or work the submitter holds the rights to. This includes art, audio and fonts — not just code. Asset licences are declared in the manifest and are checked. **MUST NOT** use third-party trademarks, characters or branding without written permission. --- ## 2. Sizing and layout This section exists because the portal decides how much room a game gets, and that amount is not known in advance. It changes with the device, the orientation, and whether the player is in fullscreen. **MUST fill its container and nothing more.** The root element is `width: 100%; height: 100%`. It **MUST NOT** carry `min-width`, `min-height`, `aspect-ratio`, or any fixed pixel dimension. This is the single most common failure we see. A rule such as: ```css .my-game-wrap { height: 100%; min-height: 480px; } /* WRONG */ ``` looks harmless and is not: on a phone the play area is often shorter than 480px, `min-height` wins over `height`, and the game silently renders taller than its frame. Everything drawn near the bottom — a player ship, a HUD, a button — is clipped away, and input coordinates stop matching what the player sees. **MUST declare a minimum playable size in the manifest instead:** ```json "minSize": { "width": 320, "height": 480 } ``` The shell guarantees the game gets at least that. Where the device cannot provide it, the shell scales the frame down rather than letting the game overflow. Declaring the minimum is how a game asks for space; CSS is not. **MUST NOT use `position: fixed`.** Inside an iframe it anchors to the frame, not the screen, and behaves differently again in fullscreen. Use `position: absolute` within the root, which is what is almost always meant. **MUST implement `windowResized` completely.** Resizing the canvas is not enough: every entity positioned relative to the old dimensions has to be recomputed. A ship parked at `height - 60` stays at the old offset after a shrink and ends up off-screen permanently. Rotate the device mid-game and verify the result — this is checked in review. **MUST NOT depend on pinch zoom.** Zoom is suppressed during play, because a shifted visual viewport desynchronises touch input from what is on screen. ### Is shipping CSS reasonable at all? Yes, but for a narrower purpose than most submissions assume. Legitimate uses are DOM overlays a game owns — a game-over panel, a pause menu, a HUD built from elements rather than drawn. What is not legitimate is CSS that negotiates with the container. Sizing is a contract between the game and the shell, and that contract is expressed in the manifest, where the shell can read it. CSS cannot be read or reasoned about by the shell; it can only be discovered when something looks wrong on someone's phone. Concretely: all styles **MUST** be scoped under a game-specific class, and **MUST NOT** target `html`, `body`, or `:root` with dimensions, overflow, or positioning. --- ## 3. Technical **MUST** load and use the arcade SDK (`docs/developers/ARCADE-SDK.md`), calling at minimum `init`, `loadingFinished`, `gameplayStart`, `gameplayStop` and `gameOver`. **MUST** be fully playable standalone, by opening its `index.html` directly. **MUST** handle `bananai:restart` without a page reload. **MUST NOT** make network requests to any origin other than the one it is served from. No external analytics, no external fonts, no CDN-loaded libraries. Bundle everything. **MUST** reference portal files by root-relative path — `/sdk/v0/bananai-sdk.js`, never `https://bananai.games/sdk/v0/bananai-sdk.js`. Your game is served from the portal's own origin, so the root-relative form always resolves; the absolute form is a *cross-origin* request everywhere the portal is not live production — on your machine, on the preview domain, and in `npm run check`, which fails it as an external request. It also breaks the day the portal changes domain, and that day is coming. This rule is checked automatically. **MUST NOT** use `localStorage` for anything other than its own settings and local high scores, namespaced under `game::`. **MUST work in private browsing.** Incognito restricts `localStorage`, and an unguarded write throws. Wrap every storage access in try/catch and degrade to not saving, rather than to a broken game. **MUST NOT let its own scrolling reach the parent page.** A game document that scrolls inside its frame will chain that scroll outwards on iOS, and the player ends up moving the portal instead of playing. In practice this follows from sizing correctly, but it is checked directly. **MUST NOT** attempt to access `window.parent`, `document.cookie`, or navigate the top-level window. The sandbox blocks this; attempting it is grounds for rejection. **MUST** be responsive: fill the container, handle resize, work from 320px wide upward, in both orientations. **MUST** support touch input, with `touchStarted` / `touchMoved` / `touchEnded` returning `false` so the page does not scroll underneath. **MUST** run at a stable 30fps minimum on a mid-range 2021 Android phone. We test on real hardware, not a throttled desktop. **SHOULD** stay under 15 MB total. Above 25 MB requires justification. **SHOULD** reach interactive within 3 seconds on a 4G connection. --- ## 4. Behaviour **MUST NOT** autoplay audio before the first user interaction. Browsers block it and it is hostile regardless. **MUST** provide a mute control if it has audio at all. **MUST NOT** gate progress behind `rewardedBreak()`, which always resolves `false` here. Treat rewards as an optional bonus that may never arrive. **MUST NOT** display advertising of any kind, including house ads for the developer's other games or cross-promotion to external sites. **MUST NOT** collect personal data. No forms asking for email, name or age. **MAY** assume it has the keyboard. The shell focuses the game's frame once it has loaded, and re-focuses it after fullscreen and after any click that lands on the shell chrome, so key events are delivered to the game's own document. Listen on the game's `window` or `document` — a listener on a specific element only fires if that element is what actually holds focus. --- ## 5. Submission package ``` / ├── bananai.json manifest (see below) ├── index.html entry point ├── assets/ │ └── thumb.png catalogue thumbnail, square, 400×400 └── js/ └── vendor/ bundled libraries ``` **The thumbnail ships inside the game folder**, at `assets/thumb.png` (or `.svg`, or `.webp`), and is declared in the manifest. It is not handed over separately and does not live in the portal's shared assets. This follows from the folder being self-contained: a submission is one directory that can be dropped in, moved, or removed as a unit. A thumbnail kept elsewhere is a second thing to remember, and the thing people forget when a game is withdrawn. It must stay legible at roughly 180px, which is its real size in the grid. Check it at that size, not at full resolution. SVG is preferred where the artwork suits it — a few KB, sharp at any density — but a PNG is the right call for detailed or photographic art. `bananai.json`: ```json { "id": "gemburst", "title": "Gem Burst", "version": "1.0.0", "category": "arcade", "ageRating": "all", "description": { "it": "...", "en": "..." }, "instructions": { "it": "...", "en": "..." }, "controls": ["mouse", "touch"], "orientation": "any", "minSize": { "width": 320, "height": 480 }, "sdkVersion": "0.1.1", "maxScoreRate": 500, "author": { "name": "...", "email": "...", "url": "..." }, "licences": [ { "asset": "audio/pop.ogg", "source": "...", "licence": "CC0" } ] } ``` `sdkVersion` is the **full** version — `0.1.1`, not `0.1`. The patch digit is not cosmetic: in 0.1.0 the two advertising methods were local stubs that resolved without contacting the portal, so a game vendoring 0.1.0 can never show an ad or earn from one, and nothing about it looks broken. `0.1` does not say which of the two you shipped, which makes the one question review needs to answer unanswerable. State what the package actually contains; declaring a version you have not shipped is worse than shipping an old one. `maxScoreRate` is the theoretical maximum points per second. The backend uses it to reject implausible submissions, so an inflated value is a red flag in review, not a way to be safe. --- ## 6. Review process 1. **Automated checks** — manifest schema, bundle size, no external requests, SDK calls present, sandbox compliance, and `npm run check`, which loads the game at five viewport sizes and verifies the canvas matches its frame with no internal scrolling. Runs on submission; failures come back within minutes. 2. **Internal QA** — see `docs/developers/QA-CHECKLIST.md`. Target turnaround: 5 working days. 3. **Outcome** — approved, or rejected with specific reasons. Resubmission is unlimited. Approved builds are published by the developer, not automatically. Publishing is a separate, explicit action. --- ## 7. Rights and takedown The developer retains full ownership. Submission grants Bananai a non-exclusive licence to host and display the game. Either party may withdraw a game with 30 days' notice. We may remove a game immediately if a rights claim, security issue or rule violation is found — with a written explanation and a right of reply. **We do not claim exclusivity.** A game published here can be published anywhere else, which is precisely why the SDK ships ad-API shims: portability is a feature we offer developers, not a leak we tolerate. --- # Arcade SDK Reference for `public/sdk/v0/bananai-sdk.js`, served at [`/sdk/v0/bananai-sdk.js`](/sdk/v0/bananai-sdk.js). Current version: **0.2.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 works here for real as of 0.1.1. `commercialBreak()` and `rewardedBreak()` were shims that resolved immediately in 0.1.0; they now cross to the shell, which owns the entire ad stack. 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. **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**: ```js // game -> shell { source: 'bananai-sdk', sdkVersion: '0.2.0', gameId: 'gemburst', type, payload } // shell -> game { source: 'bananai-shell', type, payload } ``` Replies are typed `:reply`. Three reasons this is the right shape, all of which the previous one lacked: - **`gameId` travels 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. - **`sdkVersion` is declared.** The host can adapt to older games instead of guessing, which is what makes supporting third-party submissions realistic. - **Both ends are named.** `source` says who sent it; a shared `channel` constant 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 ```js 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` 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. | ### `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](mailto:hello@bananai.games). 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` 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.** ### `rewardedBreak(): Promise` 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. ```js 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](mailto:hello@bananai.games). ### `setDebug(enabled)` --- ## Events from the shell The SDK re-dispatches shell messages as `CustomEvent` on `window`: ```js window.addEventListener('bananai:restart', () => resetGame()); window.addEventListener('bananai:pause', () => pauseGame()); window.addEventListener('bananai:resume', () => resumeGame()); ``` `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. --- ## 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')` | Gem Burst gets migrated in phase 2, alongside score persistence. --- ## 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. ### Preferred: link to the portal's copy ```html ``` Your game is served from the portal's own origin, under `/games//`, 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 ```html ``` 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. Declare the exact version in `bananai.json` under `sdkVersion` so review can tell what your game is actually running. 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 ```html ``` 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 `npm run check`, which fails 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 `npm run check` rejects the absolute one. If you develop with your game folder as the document root, `/sdk/v0/...` will 404 locally. Put the file at `/sdk/v0/bananai-sdk.js` while you work: the path is then identical to production, and what you test is what ships. **Vendor 0.2.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.js`](/sdk/v0/bananai-sdk.js) and ship that. Whichever you choose, `sdkVersion` must state what the package actually contains. Declaring a version you have not shipped is worse than shipping an old one. --- ## Versioning The SDK follows semver. Games pin a major version: ```html ``` Breaking changes ship as a new major with the old one kept alive for at least six months. Third-party developers cannot be expected to redeploy on our schedule. **0.2.0 is a breaking change that did not get a new major, and here is why.** The rule above protects deployed games. There were none to protect: the file was documented at `/sdk/v0/` while it actually sat at `/sdk/`, so the URL this document has always given returned 404 for its entire existence — confirmed by an external developer, who reimplemented the SDK from prose because they could not download it. Nothing was ever served from `/sdk/v0/`, so nothing could break by changing what lives there. Minting `/sdk/v1/` to preserve a `/sdk/v0/` that never existed would document a history that did not happen. The next breaking change will not have that excuse, and gets `/sdk/v1/`. **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 `