What to build, how to hand it over, and how to use the accounts and leaderboards without writing a backend.
Contributor kit · last revised 2026-08-16
Build a game that runs in a browser with no install. Zip it. Send it. It gets played, and if it clears the bar it gets a cabinet, a URL and — if you want them — accounts, saves and leaderboards for free.
You do not need to write a backend, run a server, handle passwords, or know anything about how any of this is hosted.
Four rules, checked in this order. The first one you fail is the answer.
No ads, no battle pass, no lootboxes, no “wait four hours or pay $2.99”. If your game has a monetisation designer it has one designer too many.
No analytics, no ad pixels, no scraping location or contacts, no quietly building a list to sell on. If your game wants to tell somebody about the extended warranty on a car I do not own, don’t bother.
And yes — submissions get opened with the network tab up. Every request a game makes gets looked at, and anything talking to a host that is not this one had better have an obvious reason. If it is having a side conversation with somebody, we will see it.
This is not paranoia about telemetry in general: if your game genuinely needs to call something external, say so up front and it is probably fine. The rule is against doing it quietly.
You, some friends, an AI you argued with at 2am — all fine. A studio with a legal department is not, because the moment one of those turns up some indie gets a letter about a word they used.
Actively developed. Not finished — nothing here is finished — but not abandoned in 2019 either.
Attitude encouraged, snark strongly encouraged. Corporate voice is the one thing that will actually get you turned down.
base: './'; in most bundlers it is a one-line setting. This is the single most common thing that breaks.A zip, laid out like this:
your-game.zip
├── game.json <- the manifest. Required.
├── version.json <- build stamp. Required, regenerated per release.
├── index.html <- entry point. Required.
├── roadmap.md <- optional, shows up under /docs
├── assets/ <- whatever you like in here
└── ...
Everything except game.json is yours to arrange however you
want. Nothing rewrites your files; the zip is unpacked and served as-is.
{
"id": "star-catcher",
"title": "STAR CATCHER",
"kind": "Arcade · Single player",
"blurb": "Catch the falling stars. Miss three and it is over.",
"accent": "#4de0c0",
"author": "Your Name",
"url": "https://wherever-you-develop-it",
"accounts": true,
"leaderboard": {
"enabled": true,
"perSeed": false,
"scoreLabel": "POINTS"
},
"save": {
"enabled": true
}
}
| Field | What it does |
|---|---|
| id | Your slug. Lowercase, hyphens. Becomes your URL (/star-catcher/) and your key in the database. Pick it once — changing it later orphans every save and score. |
| title | What appears on the cabinet marquee. |
| kind | The small line above the title. Genre, player count, whatever. |
| blurb | Two or three sentences on the card. Write it like a person. |
| accent | Your cabinet's colour. The portal has no colour of its own — every hue on that page belongs to a game, so this is genuinely yours. |
| accounts | Whether you want sign-in at all. A game can be entirely anonymous. |
| leaderboard.perSeed | True if your board is per-world rather than global. Most games want false. |
| save.enabled | Whether you want cloud saves. |
On persistence fields: you do not declare them. The server stores your save as one opaque JSON document and never looks inside it, so its shape is entirely yours and you can change it whenever you like without telling anybody. That is deliberate — a schema that modelled every game's save would need migrating every time any game changed a field.
Version your own document ({ "v": 2, ... }) and migrate it on load. That is what Rustlands does.
Published at your game’s root and regenerated on every release. The portal reads it to put a build stamp on your cabinet, so a visitor can see at a glance whether the thing has been touched this year.
{
"version": "1.04.000",
"released": "2026-08-16T23:39:45Z",
"commit": "62818f6",
"release": "optional; whatever your pipeline calls this build"
}
| Field | What it does |
|---|---|
| version | X.YY.ZZZ, zero-padded. Required. |
| released | ISO-8601 UTC. When this build was cut. |
| commit | Short git hash, if you use git. Optional but useful when somebody reports a bug. |
| release | Free text. Ignored by the portal. |
Why the zero padding.1.02.000sorts lexically the same way it sorts semantically. Unpadded,1.13.0lands before1.2.0insort, in a filename listing, in a databaseORDER BYand in every ad-hoc script anybody writes later — and each of those then needs a version-aware comparator to work around a problem that three zeroes would have prevented. Pad the minor to two and the patch to three.
Keep a VERSION file with just the number in it and have your
release script read it, rather than typing the version into two places and
eventually disagreeing with yourself.
Building it with an AI session? Point it at adding-your-game.md — the same contract written for a machine to follow, with the schemas, the deploy handshake and the things that fail silently. Fetching that beats pasting this page into a prompt.
One file, no build step, no dependencies. Served from the arcade so there is exactly one copy — do not vendor it, or you will silently keep running against an endpoint that has moved on.
<script type="module">
import { Arcade } from '/arcade.js';
const arcade = new Arcade('star-catcher'); // your id from game.json
// Who is playing? null means nobody, or offline, or the server is down —
// all of which mean the same thing to you.
const me = await arcade.me();
if (me) console.log(`Playing as ${me.displayName}`);
// Cloud save. The document is yours; the server never opens it.
await arcade.save({ v: 1, level: 4, coins: 120 }, revision);
const { document, revision } = await arcade.load();
// Leaderboard.
await arcade.submitScore(9100, { level: 4, time: 83 });
const board = await arcade.leaderboard({ limit: 10 });
// -> [{ name: 'Rusty Joe', score: 9100, detail: {...}, at: '...' }]
</script>
revision. Keep a counter, increment on every write, pass it. A stale write is refused and the newer document comes back, so a second device can catch up rather than clobber.me().displayName is already resolved for your game.| Call | Returns |
|---|---|
| me() | player, or null |
| providers() | which sign-in methods exist |
| setName(name) | rename in your game only |
| setName(name, {global:true}) | rename account-wide |
| clearName() | fall back to the account default |
| signOut() | — |
| load() | { document, revision } |
| save(doc, revision) | { ok, revision } or { ok:false, conflict } |
| submitScore(score, detail, seed) | boolean |
| leaderboard({seed, limit}) | entries, highest first |
If your game wants it, there is a signalling server that introduces two browsers to each other and then gets out of the way. Game traffic goes peer-to-peer over WebRTC — the server never sees a packet of it, which is why one small box can host a lot of players.
Open a room and you get a five-character code. Somebody types that code and the two of you are connected. There is a relay fallback for players whose network defeats direct connection.
This part is less finished than the rest. The transport and wire format are built and tested and Rustlands is the first game wiring them to actual gameplay. If you want multiplayer, talk to me — the hook exists but it has not yet been used by anybody who did not write it, which is the only real test of an API.
Zip it and send it, along with anything you want me to know. It gets played before it gets a marquee — that is the whole review process, and it is done by a person, not a form.
If you would rather not zip things, a git repo is fine too. If your game updates often, a repo is better, because then updating your cabinet is a pull rather than an email.
Your roadmap.md, if you include one, appears under
/docs alongside everyone else's.