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:
- A trailer on our YouTube channel. Short-form video is where a browser games catalogue is actually discovered now, and a game that plays itself can be recorded on demand, as often as we like, without anyone sitting down to play it well.
- A self-playing preview on your game's landing page, at
/<lang>/game/<id>/— where a visitor arriving from a search currently sees a still thumbnail and has to decide from it. - Priority in the catalogue grid.
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:
- The starting screen is by definition the moment when nothing has happened yet — score zero, level one, board untouched. The thumbnail advertises an empty game.
- The card is 180px wide, not 400. At that size a HUD is an illegible smear, and it is a smear of information the card already prints next to it in text. A ship drawn as a six-pixel outline on black disappears; an eleven-by-eleven board gives each piece 14px and reads as noise.
So the thumbnail is a composed image:
- Composed, not captured. Show the game at its most characteristic moment, which is rarely a moment the game ever actually renders.
- No HUD. No score, no level, no
press SPACEline. The card carries the title and the category already. - 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.
- 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.
- A single animated WebP, square, same 400×400 as
thumb. Not a video: the whole point of a small looping image over a<video>element is that it costs nothing extra to fetch until a player's cursor is actually over the card, and stays that cheap once it is. GIF and APNG are not accepted — WebP's compression is meaningfully better at this frame count and every browser the portal supports plays it natively. - 2–3 seconds, looping seamlessly. The last frame has to flow into the first; a visible jump on every repeat reads as broken rather than as a choice not to loop.
- 300 KB, hard ceiling. The grid is a wall of these; one oversized loop degrades every card next to it, not just its own. A well-composed loop at this frame count and size comes in well under that on typical compression — treat getting close to the ceiling as a sign to cut a frame, not to ship it anyway.
- Same composition rules as the static thumbnail, points 1–3 above: no HUD, one subject filling the frame, and it has to read at 180px. What it adds is motion the still frame cannot — a character's idle bounce, a tile swap, a ship banking — not new information that belongs in the description instead.
- Desktop only, by nature rather than by rule. There is no hover on
touch, so a phone never requests it and always sees
thumb. Nothing to build for that case: it falls out of what "hover" means.
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
reference — true 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
- 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.
- Internal QA — see
QA-CHECKLIST.md. Target turnaround: 5 working days. - 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
- 2026-09-23 —
sportis an acceptedcategory, for games built on the one gesture a sport is made of — aiming, striking, and what the ball does next. It arrives with one game, our own Pool, which is a change to the rule that a category waits for its second game: filing a pool table underarcadewould have been a label nobody searches and nobody expects, and a wrong category costs more than a thin one. The rule now reads: a category opens when nothing that exists describes the game. - 2026-09-23 — §3: the Content Security Policy on game documents is enforced, not report-only. Nothing about what it allows has changed since it was written on 2026-09-13, and no catalogued game needed an edit to run under it; what changed is who refuses a violation — the player's browser now, instead of our checks alone.
- 2026-09-21 —
typingis an acceptedcategory, for games played by typing words or letters. It arrives with two of ours, Typing Test and Falling Words. A typing game has to open the phone's keyboard itself — a canvas cannot. Ours do it with an invisible text field, and the whole of it is readable at/games/typing-test/js/keys.js. - 2026-09-19 —
snakeis an acceptedcategory, for games where you steer something that grows and must not run into itself. It arrives with our own Snake. - 2026-09-17 — §4b: how a game reports its demo mode is now written
down.
npm run checkhas always asked the game throughwindow.__bananaiDemo(), and failed ademoModesdeclaration without it — but no published page said so, and the first game built from these pages alone could not have known. It returns{ mode, active }. - 2026-09-17 — §3: WebAssembly is allowed. The policy now compiles a
module shipped in your folder, which it refused before for every game.
evalandnew Functionstay refused, and that includes the ones a dependency makes for you: a Rust audio library testing forAudioContextwitheval()would leave your game silent once the policy is enforced, andnpm run checkfails it today. - 2026-09-13 — §3: every game document is served with a Content Security
Policy, report-only for now and enforced after that. It allows scripts and
connections from the game's own origin only,
data:andblob:for images, audio and fonts, inline styles, and noeval.npm run checkloads each game under the policy and fails on anything it would block. Thecategoryvalueactionis no longer accepted; it merged intoarcade. - 2026-09-17 — The animated hover thumbnail (
thumbHover) is still optional and now strongly recommended. The paragraph promising a future check was replaced by whatnpm run checkactually validates. - 2026-09-06 — §4b now specifies both demo entry points:
?demo=true, which hands the game over permanently on the first real command, and?demo=loop, which never yields — the one our recorder uses, and the only one safe for an unattended capture. Added the optional?level=Nwith itsdemoLevelsdeclaration, which pins the demo to one scene so a game with distinct environments can have a trailer for each. - 2026-08-30 — English is required in a game, for submissions and new
versions from this date; not retroactive, and an empty
languagesarray stays valid. Added §4b, demo mode: optional, declared with"demoMode": trueand entered with?demo=true, and what a game that has one gets from us. - 2026-08-21 —
languagesis now mandatory (an empty array is still valid); added the recommended language order for SDK 0.3.0. - 2026-08-20 — Renaming a published game is now stated as a new submission, not an edit; §7 now says what withdrawal actually does to the URL and to saved scores.
- 2026-08-20 — Examples referencing Tetrix (withdrawn for trademark risk
— see
CLAUDE.md) replaced with games still in the catalogue. - 2026-08-20 — Optional
thumbHover: an animated WebP shown on hover, alongsidethumbrather than instead of it. The example manifest also gainedthumbitself, missing from it since the field became mandatory. - 2026-08-20 —
sdkVendoredis now cross-checked againstindex.html; anit/encopy-pasted translation now gets a warning. - 2026-08-19 —
sdkVersionrenamed tosdkMinVersion;sdkVendoredandlanguagesadded. - 2026-08-18 — Versioning policy clarified: v0 is a development protocol, v1 is what promises stability.
- 2026-08-16 — Thumbnail rule spells out what the image must show, not just its size.
- 2026-08-15 —
maxScoreRatemade mandatory;nullis a valid, explicit answer. - 2026-08-13 — Game
idis derived from the title, and checked automatically. - 2026-08-12 — Manifest enum values (
category,ageRating,controls,orientation) published.