Developers

Read this page as markdown — for coding agents, or for anyone who prefers the source.

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.

MUST be playable in English. English is the one language a submission has to have; every other language is yours to choose, and the order below is a recommendation you are free to ignore.

This reverses the earlier rule, which asked only for the language the game was written in. The reason is the audience rather than the code: the portal's own default has been English since 2026-08-18, the catalogue copy has needed an en since before that, and a game whose menus answer in a language the player did not pick reads as broken rather than as foreign.

It is not retroactive. It applies to submissions and to new versions from 2026-08-30. Games already in the catalogue stay in it, and npm run check reports a missing en without failing — the same treatment category divergence gets, and for the same reason: a rule that breaks the existing catalogue on the day it is written is a rule that gets switched off. It becomes a hard failure once the catalogue satisfies it.

A game with no real text to translate satisfies this by having nothing to translate; see languages below, where an empty array stays valid.

If you do translate, the order worth following is the one the industry has already paid to learn: English, French, Italian, German and Spanish first, then Turkish, then Chinese, Japanese and Korean, then Brazilian Portuguese and Russian. The string table stays inside your folder either way.

The two manifest fields are a different matter: description and instructions are catalogue copy, they are ours to display, and both need an it and an en. They are what a player reads before deciding to play, on a page they reach without opening your game. The two need to actually say the same thing in two languages, not the same string twice — the check flags a verbatim match as a warning, since a copy-pasted translation passes every other rule here.


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:

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

"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 (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 run under the portal's Content Security Policy. Every game document is served with one, report-only from 2026-09-13 and enforced since 2026-09-23, which means the player's browser — not only our checks — refuses what it forbids:

Allowed Not allowed
scripts from files on the game's own origin, and WebAssembly modules compiled from them inline <script> blocks, on*="" handlers, eval, new Function, string timers
styles from files, <style> blocks and style="" attributes — a style runs no code, so nothing here is restricted
images, audio and fonts from the origin, data: and blob: URLs any other origin
fetch, XHR and WebSocket to the game's own origin any other origin — the rule above, now enforced
being embedded by the portal being embedded by any other site

A library that only mentions new Function behind a feature test is fine, as long as the line never runs. npm run check loads your game with the policy applied and fails on anything it blocks, so you find out before review does. The measured policy and its reasoning are in the portal's configuration, not repeated here.

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 our automated checks, which fail 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:<id>:.

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. Attempting it is grounds for rejection.

Two of those three are enforced by review, not by the browser, and you should know which. The frame is sandbox="allow-scripts allow-same-origin allow-pointer-lock", and your game is served from the portal's own origin — so allow-same-origin returns the access the sandbox would otherwise have removed. window.parent and document.cookie are reachable from your game today. Top-level navigation is genuinely blocked, because allow-top-navigation is not granted.

That permission is load-bearing rather than an oversight: the shell reads iframe.contentDocument to suppress pinch-zoom inside your document, dispatches a synthetic resize into your window so canvases relying on windowResized are told the frame changed, and mounts virtual controls by generating key events on your window. All of it is same-origin work, and none of it survives a stricter sandbox.

The direction of travel is a per-game origin and a sandbox without allow-same-origin, with input mediated by the SDK over postMessage instead of dispatched directly — the swap VIRTUAL-CONTROLS.md already describes under its known limitations. Write your game as though that had already happened. A game that reaches through the frame today will break when it does, and will be rejected before then.

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(). false is the ordinary answer, not the exceptional one — no fill, no consent, a closed video, or advertising switched off entirely, which is the shipped default today. Treat a reward as a bonus that may never arrive, and make sure the game is complete without it.

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.


4b. Demo mode — optional, and worth more than it costs

A demo mode is a way to start your game so that it plays itself. No player input, no menu to get through: the game runs, plays reasonably, and keeps running. Arcade cabinets called it attract mode, and it existed for the same reason — something moving is what makes somebody stop and watch.

How it is declared. "demoMode": true in bananai.json, and your game starts in demo mode when it is loaded with ?demo=true. Declared, never guessed: the shell will not probe for a mode you did not say you had, for the same reason it never guesses virtualControls.

And say which values you understand, with demoModes:

"demoMode": true,
    "demoModes": ["true", "loop"]
    

This is how you state that you have done what the next section requires, and it is checked rather than taken on trust: npm run check opens your game once per declared value and fails the submission if the game reports its demo off — and fails it too if a value nobody declared switches the demo on, because a game that accepts anything has declared nothing.

How the game reports it. Expose one global function that answers, once the page has loaded, which mode was requested and whether the demo is on:

window.__bananaiDemo = () => ({ mode: 'loop', active: true });
    

mode is the value you recognised, or null; active is true when the demo is running. The check waits up to eight seconds for the function to exist, so define it at load, not after a menu. Without it a demoModes declaration fails outright, because nothing can confirm it.

Omitting demoModes is allowed and means ["true"], which is what every game supported before loop existed. Nothing already in the catalogue has to be resubmitted. But a game that does not declare loop will be recorded with ?demo=true, and the next section is why that matters.

?demo=true hands the game over. ?demo=loop never does.

Required, both of them, and the difference is the whole point.

With ?demo=true the demo is a shop window that steps aside: the first real command — a key, a pointer, a touch — switches the pilot off for good, and from then on the game belongs to whoever gave it. That is what somebody expects who opened the address on purpose. Yielding only while the finger is down is the wrong answer: it leaves a human and a pilot contending for the same game, and neither is playing it.

With ?demo=loop the pilot never yields. This is the one for an unattended capture and for the preview that runs on a landing page, where a pointer event in passing must not leave a half-finished game on screen with nobody playing it. Our trailer recorder sends whichever of these your demoModes declares, preferring loop. It used to send loop to everything on the strength of a comment reading "all seven catalogued games understand both" — true when it was written, and not a fact about a hundred games. A game that does not know loop reads it as off, plays nothing, and the recording is forty-five seconds of a title screen that nothing anywhere reports as a failure.

Match the value exactly. loop and true are the only two strings that mean anything; ?demo=1, ?demo=yes and a bare ?demo all mean off, which is better than guessing what was meant.

Either mode restarting itself when a run ends is expected — a looping preview has nobody to press a key — and is not what loop refers to.

?level=N — optional, and it pins the scene

If your game has distinct scenes worth showing separately, accept ?level=N (1-based, clamped to what exists) and declare how many there are:

"demoMode": true,
    "demoModes": ["true", "loop"],
    "demoLevels": 3
    

The recorder refuses a --level above what you declared rather than ignoring it: a level nobody honours records the default scene again under a different name, which is worse than not recording it.

Two rules. It is honoured only while a demo is running — it is not a shortcut for a real player, who starts from the beginning like anybody else. And with it set, the demo stops rotating and stays in that scene; without it, cycling through the scenes is the right default.

What it earns is one trailer per scene instead of one per game. A thirty-second Short cannot show three environments and do any of them justice, and three Shorts that each show one is not the same video published three times.

Leave demoLevels out if the idea does not apply. A wave in a shooter is not a scene, and a number that means nothing is worse than an absent one — we read this field rather than probing for it, so an honest absence costs you nothing.

What it earns. These are commitments, not predictions:

We are honest about the order of events. This was written when no catalogued game had a demo mode; since 2026-08-30 all seven do, and the recorder they made possible is running. What we still cannot tell you is that a demo mode earns more, because there is no game without one left to compare against. What we can tell you is what we do with yours, and the list above is that.

What it does not have to be. It does not have to play well, or finish, or look like a record attempt. It has to look alive for thirty seconds. A loop of plausible moves beats a perfect solver nobody can see the difference from.

Why we are asking rather than building it ourselves. We can already drive some games from outside — a match-3 board can be read from the pixels it has already drawn, and a solver written against it. But that only works for games whose state is visible on screen and regular enough to parse, it has to be written again for every genre, and it is guesswork about a game we did not write. You know where your game keeps its state. Thirty lines from you replace an afternoon of ours, and produce something better.


5. Submission package

<game-id>/
    ├── bananai.json        manifest (see below)
    ├── index.html          entry point
    ├── assets/
    │   ├── thumb.png       catalogue thumbnail, square, 400×400
    │   └── thumb-hover.webp  optional: animated hover thumbnail, see below
    └── 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.

A screenshot of the starting screen is not a thumbnail. Five of the seven games in the catalogue shipped one, and every one of them included the game's own HUD: PUNTI 0, REC 0, LIV 1. This rule used to say only how big the image had to be and how small it would be shown, so a screenshot satisfied every word of it. It is being spelled out because the omission, not the developers, produced the same result five times.

Two things go wrong, and the second is the one that decides it:

So the thumbnail is a composed image:

  1. Composed, not captured. Show the game at its most characteristic moment, which is rarely a moment the game ever actually renders.
  2. No HUD. No score, no level, no press SPACE line. The card carries the title and the category already.
  3. One subject filling the frame, built from few large shapes. If it survives being scaled to 180px it is right; if it needs the other 220px to make sense it is not.
  4. The game's name inside the image is allowed and often worth it. Of the seven, the one thumbnail identifiable without reading the card next to it is the one that carries its own title.

Optional, and strongly recommended: an animated hover thumbnail. Declared as thumbHover in the manifest, alongside thumb rather than instead of it — a player without a mouse, or arriving on the static image before the animation is fetched, still sees the same thumbnail as everyone else.

Nothing rejects a game without one. But fourteen of the fifteen games in the catalogue have one, so on desktop the grid moves under the cursor card after card, and a card that stays still reads as unfinished rather than as a choice. It is also the cheapest way to show what playing the game looks like before anyone clicks.

npm run check validates the field when it is present: it must be a string, and the file it names must exist inside your folder. It does not measure the format, the dimensions, the duration or the weight — those are checked at review, so check them yourself before sending.

bananai.json:

{
      "id": "gemburst",
      "title": "GemBurst",
      "version": "1.0.0",
      "category": "arcade",
      "ageRating": "all",
      "thumb": "assets/thumb.png",
      "thumbHover": "assets/thumb-hover.webp",
      "description": { "it": "...", "en": "..." },
      "instructions": { "it": "...", "en": "..." },
      "controls": ["mouse", "touch"],
      "orientation": "any",
      "minSize": { "width": 320, "height": 480 },
      "sdkMinVersion": "0.2.0",
      "sdkVendored": false,
      "languages": ["en"],
      "maxScoreRate": 500,
      "author": { "name": "...", "email": "...", "url": "..." },
      "licences": [
        { "asset": "audio/pop.ogg", "source": "...", "licence": "CC0" }
      ]
    }
    

The title decides the id

id is derived from title, and you do not get to choose it separately. Lowercase the title and replace each space with a hyphen. That result is the id, the folder name, and the URL.

title id and folder
Pallanoids pallanoids
Pace Invaders pace-invaders
GemBurst gemburst
Malaga malaga

A title MUST be letters, digits and single spaces — nothing else. Accented letters are allowed and are folded to their base letter for the id, so Málaga gives malaga. Punctuation is not: no colons, apostrophes, ampersands or underscores.

That last one is a refusal rather than a silent strip, and the reason is worth a sentence. Pace Invaders 2: Rebirth and Pace Invaders 2 Rebirth are two different titles that would strip to one id, and an id is not a display detail: it is the URL, the entry in the sitemap, and the game:<id>: namespace holding every player's saved scores. Two games sharing one would share those. Being told at submission that a colon is not accepted costs you a minute; discovering the collision after publication costs a rename, and a rename throws away the high scores.

The id is permanent. Choose the title you want to keep. This is checked automatically, so a mismatch comes back before review rather than after.

Renaming a published game is a new submission, not an edit. Because the id is permanent, changing a game's title after publication means a new id, a new folder, and a version that restarts at 1.0.0. There is no mechanism to keep the old id with a new title, and no way to carry the old id's history forward — saved scores included. The old entry is retired when its replacement arrives; nothing links the two beyond a shared developer and a line in docs/CHANGELOG.md.

Four fields accept a fixed set of values, and this is that set:

Field Accepted Notes
category arcade, puzzle, card, board, snake, typing, sport English slugs. The chip the player sees is translated from the slug, so this is not the label. action was accepted until 2026-09-13 and merged into arcade
ageRating all, 7+, 12+, 16+ Every game published so far is all
controls any of keyboard, mouse, touch An array, listing what the game genuinely supports. Touch is required regardless
orientation any, portrait, landscape Declaring one does not make the shell enforce it. The rules still require both orientations to work

Two of those were undocumented until now, and it cost something worth recording: with no list to choose from, three action games were submitted as "arcade" — the only value that appeared in this example — and were filed in the catalogue as action anyway. Neither side was wrong, because there was nothing to be wrong against.

Nothing here is read at runtime yet. The shell's catalogue is its own list, and no part of the portal opens bananai.json while a player is on the page. The manifest is a declaration: it is what review reads, and what the catalogue will be generated from once games.json exists. Filling it accurately costs you nothing now and is what stops your game being filed under someone else's guess later.

The minimum accepted sdkMinVersion is 0.2.0, and this number moves — but slowly and with warning. Anything older cannot reach the shell at all: 0.2.0 renamed the global, the events and the manifest, and changed the message envelope. A game on 0.1.x loads, plays, and is silent — no playtime, no scores, no ads, and nothing that looks broken from the outside.

It moves because v0 is a development protocol and is not stable — see the versioning section of the SDK reference. A breaking change inside v0 stays at /sdk/v0/ and eventually raises this floor; it is announced with the exact edit, and a game caught under the new floor gets a warning window rather than an immediate stop. Stability becomes a promise at v1, which is also when a previous major starts being served for six months after its successor.

There is also a ceiling, checked automatically: sdkMinVersion cannot be higher than the SDK version actually published today. A game cannot require what does not exist yet, and this is caught before deploy rather than at runtime.

sdkMinVersion means the minimum the game genuinely requires, not what a package happens to contain — a game that links /sdk/v0/bananai-sdk.js and uses nothing new can stay declared at an old number for months, correctly. For a vendored copy the two collapse to one: it can never receive a fix, so its minimum and its exact contents are the same value. See sdkVendored below.

sdkMinVersion is the full version — 0.2.0, not 0.2. The patch digit is not cosmetic. 0.1.0 shipped the two advertising methods as local stubs that resolved without contacting the portal, so a game vendoring it could never show an ad or earn from one; 0.1 does not say which of the two you shipped, which makes the one question review needs to answer unanswerable.

sdkVendored is a required boolean. false if the game links the portal's own copy at /sdk/v0/bananai-sdk.js — preferred, see the SDK referencetrue if it carries its own copy in js/vendor/. Review reads it instead of opening index.html to find out — and the automated check cross-references it against index.html anyway, so the two cannot quietly disagree.

languages is mandatory, and an ordered array of BCP-47 codes ("it", "en", "pt-BR", "zh-Hans"), declaring what the game itself is written in — not the catalogue copy, which is the separate description/instructions requirement above. The first element is the game's reference language, used as the fallback when the player's chosen language matches none of the others. An empty array declares a game with no real text to translate — a scoreboard and one or two words do not need an entry here — and is a valid, deliberate declaration, not a gap.

Since SDK 0.3.0, this field is what the shell resolves the player's chosen language against before handing it across the iframe as Bananai.language — see ARCADE-SDK.md. A game is never handed a code it did not declare here.

en is required in this array for submissions and new versions from 2026-08-30 — see the English rule at the top of this document, including what "not retroactive" means for a game already in the catalogue. An empty array is still valid and still means what it meant: a game with no real text, which therefore has no English to be missing.

Recommended order beyond English, matching the audience this catalogue's traffic actually converges on rather than an arbitrary list: French, Italian, German, Spanish first; then Turkish; then Chinese, Japanese, Korean; then Brazilian Portuguese and Russian. A recommendation, not a requirement — beyond English, a game folder belongs to its author.

maxScoreRate is mandatory, and null is a valid answer. It is the theoretical maximum points per second. The backend will use it to reject implausible score submissions, so an inflated value is a red flag in review rather than a way to be safe — declare a worst case you can justify, the way Trekking's does: boss, level clear and two pickups landing in the same second, itemised.

Some games have no honest maximum — an endless mode whose speed grows without bound is the common shape of it. Any number for a game like that is invented, and an invented number in a fraud signal is a liability, not a safeguard. Those declare null:

"maxScoreRate": null
    

null, and not simply leaving the field out. A missing field cannot be told apart from a forgotten one, so a rule saying "omit it if your game has no ceiling" is a rule nothing can check. An explicit null is a statement that the question was considered, which is why the key is required and its absence is a failure.

If you consume this field, compare against null before any arithmetic. rate > null evaluates as rate > 0 in JavaScript, so a plausibility check written without that guard would reject nearly every score — the field meant to prevent false positives producing all of them.


6. Review process

  1. Automated checks — manifest schema, bundle size, no external requests, SDK calls present, sandbox compliance, and a browser pass that loads the game standalone and inside the real shell at five viewport sizes, verifying the canvas matches its frame with no internal scrolling and that nothing outside the origin is requested. We run these; you do not need any tooling of ours. Failures come back with the file and line.
  2. Internal QA — see QA-CHECKLIST.md. Target turnaround: 5 working days.
  3. Outcome — approved, or rejected with specific reasons. Resubmission is unlimited.

The way to know in advance that you will pass the first step is the starter, which already does.

Approved builds are published by us, and publishing is a separate, explicit action rather than a consequence of approval.


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.

What withdrawal actually does: the folder is removed, not just delisted — a takedown that leaves the game reachable at its own URL is not a takedown — and the URL returns a 404 afterwards rather than a redirect. Locally saved high scores are not migrated; they simply stop being reachable through the catalogue.

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.


Document history