Read this page as markdown — for coding agents, or for anyone who prefers the source.
Common failures
Every entry here has happened, in this catalogue, to a real submission. None of them is hypothetical and none is a matter of taste. They are ordered by how much they cost to find, which is not the same as how hard they are to fix — the expensive ones are the failures that leave the game looking fine.
A working game with all of this already correct: the starter. Copying it is faster than reading this page.
The silent ones
These do not crash, do not log, and do not look wrong. They are the reason this page is ordered the way it is.
An SDK older than 0.2.0
"sdkMinVersion": "0.1.1"
What happens. Nothing. The game loads, plays, saves its own high scores and reports absolutely nothing to the portal: no playtime, no scores, no ads, no earnings. 0.2.0 renamed the global, the events and the manifest and changed the message envelope, so an older SDK is talking to a shell that cannot hear it.
How to see it. await Bananai.init({ gameId: 'x', debug: true }) logs
initialised { standalone: true }. Inside the portal, standalone: true is
always a bug.
Fix. Link /sdk/v0/bananai-sdk.js rather than carrying a copy. If you must
vendor one, vendor the official file at 0.2.0 or newer — not a reimplementation.
gameOver() sent from the draw loop
function draw() {
if (gameOver && !fired) { fired = true; Bananai.gameOver({ score }); }
}
What happens. Usually nothing. Occasionally the run vanishes. Between the
frame that raises the flag and the frame that sends the event there is a gap,
and an input landing in it can restart the game first — resetting gameOver and
fired before the event is ever sent. The player's score is never recorded, and
no error appears anywhere.
Fix. Send it where the run actually ends, not where it is next drawn. Guard the function that ends the run so the send is idempotent, and you will not need a separate flag:
function endRun() {
if (over) return;
over = true;
Bananai.gameOver({ score, level, durationMs });
}
No gameplayStop()
What happens. One session that starts when the player does and ends when the tab closes. Pauses, menus and backgrounded tabs all count as play. The pair is what measures the playtime you are paid on, so this is not a rounding error in your favour — it is a number nobody can trust.
Fix. Stop on pause, on settings, and on visibilitychange when the document
is hidden. gameOver() implies gameplayStop(); calling both is harmless.
Restarting by reloading the page
window.addEventListener('bananai:restart', () => location.reload());
What happens. The reload throws away the SDK handshake and the session with it. The game comes back looking correct and reporting nothing.
Fix. Reset your own state. If that is hard, it is worth making easy — the shell's restart button is not the only caller, and an in-game replay wants the same function.
The loud ones
These fail the automated checks on submission, which load a game standalone and inside the real shell at several viewport sizes.
A fixed height
.game { min-height: 480px; }
What happens. In landscape on a phone the frame is shorter than 480px, so the canvas overflows it, the game document scrolls, and on iOS that scroll chains outwards and moves the portal instead of playing.
This one broke five of the six games in this catalogue at once. It is the single most common reason a submission is sent back.
Fix. Read the container and never assume: canvas.width = el.clientWidth, recomputed on every resize. No min-height, no
aspect-ratio, no fixed pixel size. See section 2 of the
submission rules.
An absolute URL for the SDK
<script src="https://bananai.games/sdk/v0/bananai-sdk.js"></script>
What happens. It is a cross-origin request everywhere except live production, so it fails in local development, in review, and in the checks.
It is also how this portal's SDK came to be reimplemented from scratch: a developer got a 404 on a documented URL, concluded the file did not exist, and wrote their own from the prose. That reimplementation then could not be migrated, because it was never our code.
Fix. /sdk/v0/bananai-sdk.js, root-relative, no origin in front of it.
A request to anywhere else
<script src="https://cdn.example.com/library.min.js"></script>
<link href="https://fonts.googleapis.com/css2?family=..." rel="stylesheet">
What happens. Rejected. There is no allowed CDN and no allowed font host — the rule is zero requests to any origin other than the one the game is served from, and the checks assert it directly.
Fix. Bundle everything inside your folder. Yes, that means your own copy of
the library, even if another game already ships the same one. Five identical
copies of p5.min.js were deduplicated into a shared folder once and it had to
be reverted: a game folder is self-contained, and cross-folder sharing creates a
dependency you never agreed to and cannot control.
The folder name and the manifest id disagree
public/games/pallanoids/ ← folder
{ "id": "pallanoid" } ← manifest
What happens. This one reached the catalogue and was corrected by hand.
Nothing in the automated checks opens bananai.json yet, so it is caught by a
person reading it, or not at all.
Fix. Folder name, manifest id, the constant in your source and the
catalogue entry are one string, four times. It is permanent once published.
The ones that only appear on a phone
The automated checks do not simulate touch. Nothing below is caught by them, or by us until a person picks up a phone — so these are the ones that reach players.
Touch handlers that do not return false
What happens. The page scrolls under the player's finger while they are trying to play.
Fix. Return false from touchStarted / touchMoved / touchEnded, and
set touch-action: none on the game's root container. The CSS is not optional
— returning false does not stop the browser claiming a pinch or a double-tap
zoom before your handler sees it.
Both an on-screen pad and a declared virtualControls
What happens. The shell draws its pad on top of yours and swallows the presses meant for it. The game looks like it has stopped responding.
Fix. Pick one. Native touch is what the rules require and what should win; the shell's pad exists for a keyboard-only game that has no other way to be playable on a phone. No entry in this catalogue declares it today.
A synthetic click after a tap
What happens. A touch device fires touchstart and then a synthetic
click, so a single tap runs your handler twice. In a game with an ad break
between runs, that is two breaks requested for one tap.
Fix. Use one pointerdown handler for mouse, touch and pen. If you must
keep both paths separate, guard the mouse one — if (touches.length > 0) return;.
The rest
localStorage without a guard
localStorage.setItem('best', score); // throws in private browsing
Fix. Wrap every access in try/catch and degrade to not saving, never to a
broken game. Namespace under game:<id>:.
Audio before the first interaction
What happens. Browsers block it. A AudioContext created on load starts
suspended, and everything played into it is silent — including everything
played after the player finally does interact, unless you resume it.
Fix. Create or resume the context inside the first real input handler:
audioContext.resume(). And provide a mute control, which the rules require of
any game with audio at all.
A thumbnail outside the game folder
Fix. assets/thumb.png — or .svg, or .webp — inside your own folder,
square, 400×400, legible at the 180px the grid actually renders. A submission
should be one directory that can be added or removed as a unit, with no
orphaned image left behind when a game is withdrawn.
A screenshot of the starting screen used as a thumbnail
Five of the seven catalogued games did this, HUD and all, so it is the most common failure on this page rather than a hypothetical one.
Why it fails. The starting screen is the moment before anything has happened: score zero, level one, board untouched. And the card is 180px wide, where a HUD becomes an illegible smear of information the card already prints beside the image in text.
Fix. Compose the image instead of capturing it. No HUD, one subject filling the frame, few large shapes, checked at 180px. See section 5 of SUBMISSION-RULES.md.
Before you send it
Walk the QA checklist rather than trusting this page. It is the manual pass review actually performs, published so you can run it first, and it catches more than a list of known mistakes can.
On submission your game also goes through our automated checks, which load it standalone and inside the real shell at five viewport sizes and fail it if the canvas does not match its frame, if the game document scrolls, or if anything outside the origin is requested. Those run here rather than on your machine, so there is no tooling of ours for you to install — and the starter is the practical way to know you will pass them, because it already does.
The submission rules are what a game is judged against; this page is only the shortlist of ways they get broken.
Document history
- 2026-08-19 — Example manifest field renamed from
sdkVersiontosdkMinVersion. - 2026-08-16 — Thumbnail rule spells out what the image must show, not just its size.
- 2026-08-12 — Manifest enum values (
category,ageRating,controls,orientation) published. - 2026-08-11 — Internal runbook content taken out; the starter added as the canonical working reference.