Game Developer Documentation
Build, package, and ship browser games on MrXo β from the first script tag to admin review.
On this page
Introduction
A MrXo game is a plain HTML5 page. You write HTML, CSS and JavaScript exactly as you always do, add one script tag, and the platform handles player identity, scores, leaderboards, multiplayer rooms and cloud saves for you.
- Player info β who is playing, for free
- Scoring β submit scores and get an instant leaderboard rank
- Multiplayer β real-time rooms with codes, host authority and matchmaking
- Storage β persistent per-player, per-game key/value saves
- Notifications β show toasts inside the game with one call
No backend, no accounts system, no WebSocket setup β everything goes through the SDK.
Quick start
The smallest possible game. Save as index.html, open your browser console and you should see the greeting:
<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8" /><title>My Game</title></head>
<body>
<div id="game">Loading...</div>
<script src="/sdk/game-sdk.js"></script>
<script>
Platform.onReady((ctx) => {
document.getElementById("game").textContent =
"Hello " + ctx.player.name + "! You are playing " + ctx.game.slug;
});
// Tell the platform your game finished loading
Platform.ready();
</script>
</body>
</html>Two rules: include the SDK before your own code, and call Platform.ready() when your game has finished loading (the platform shows a spinner until you do).
- Sign in to MrXo and open Profile β Become a developer
- Go to Studio β New game and fill in the metadata
- Open Manage on your game and upload your
.zip - Click Submit for review β an admin approves it and it goes live
The platform context
When the platform boots your game it sends a context object to Platform.onReady:
| Field | Type | Description |
|---|---|---|
ctx.game.slug | string | Your gameβs URL identifier |
ctx.game.version | number | Currently running bundle version |
ctx.game.isMultiplayer | boolean | Whether multiplayer was enabled for this game |
ctx.game.maxPlayers | number | Maximum players per room |
ctx.player.id | string | Signed-in player id |
ctx.player.name | string | Player display name |
ctx.player.avatarUrl | string? | Avatar URL, if the player has one |
ctx.room | object? | Present only when launched into a room: { code, gameId, isHost } |
ctx.token | string | Internal handshake token β never use it directly |
name: "Guest" β your game should still work for them.SDK API reference
Lifecycle
Platform.onReady((ctx) => {
// Fires once, after the platform has initialized the SDK
});
Platform.ready(); // call when your game finished loadingPlayer
const player = Platform.getPlayer();
// { id: string, name: string, avatarUrl?: string }
Platform.onPlayerChange((player) => {
// Called when the player info changes
});Scoring
Platform.submitScore(value, metadata?) submits a score to the gameβs leaderboard and resolves with the rank:
const result = await Platform.submitScore(1500, { difficulty: "hard" });
// { ok: true, rank: 42, value: 1500 }
if (result.rank === 1) Platform.notify("New record!", "success");valuemust be a finite numbermetadatais optional JSON for your own tracking- The player must be signed in (guests cannot submit)
- Rejects after 10 seconds if the platform does not answer
Storage
Key/value storage scoped to this game and this player β use it for progress, settings and best scores:
await Platform.storage.set("progress", { level: 5, coins: 100 });
const progress = await Platform.storage.get("progress");
// { level: 5, coins: 100 } β or null if unset
await Platform.storage.remove("progress");- Values must be JSON-serializable
getresolvesnullwhen the key does not exist (or after 8 seconds)set/removereject after 8 seconds if the platform does not answer
Multiplayer
Everything revolves around rooms. joinRoom resolves with the room you ended up in:
const room = await Platform.multiplayer.joinRoom({ mode: "auto" });
// { code: "AB12CD", gameId: "...", isHost: true }| Option | Type | Description |
|---|---|---|
mode | "'auto' | 'create' | 'join'" | auto joins an open lobby or creates one, create always makes a new room, join needs a code |
code | string? | Room code to join when mode: "join" |
Once in a room you exchange messages and watch the roster:
// Send to everyone
Platform.multiplayer.send({ type: "jump", x: 10, y: 20 });
// Send to one player
Platform.multiplayer.send({ type: "hit", dmg: 3 }, targetPlayerId);
Platform.multiplayer.onPlayersChange((players) => {
// Full player list after every join/leave
});
Platform.multiplayer.onMessage((msg, from) => {
// msg = whatever the other player sent, from = { id, name, avatarUrl? }
});
Platform.multiplayer.onPlayerLeft((playerId) => {});
Platform.multiplayer.onRoomClosed((reason) => {}); // e.g. host ended the game
await Platform.multiplayer.leaveRoom();
const current = Platform.multiplayer.getRoom(); // Room | nulljoinRoomrejects after 15 seconds (bad code, full room, no open lobby)- Payloads must be JSON-serializable β keep them small, this is realtime traffic
- Room events also drive the platform lobby UI, so players see joins/leaves there too
Notifications
Platform.notify("Hello!", "info"); // "info" | "success" | "error"Multiplayer guide
Enable multiplayer in your gameβs metadata before uploading. Then decide how your game uses rooms:
- Host authority β the player with
room.isHost === trueruns the real simulation and broadcasts state; everyone else sends inputs. This avoids desyncs. - Room codes β the code from
joinRoomis shown in the platform lobby; players can share it to join privately. - Quick Play & matchmaking β players can be dropped into your game from the platformβs Quick Play; handle the case where
ctx.roomalready exists ononReady. - Spectators / late joiners β send a full state snapshot to a new player on their first message.
Platform.onReady(async (ctx) => {
// Might already be in a room when launched via Quick Play
const room = ctx.room ?? await Platform.multiplayer.joinRoom({ mode: "auto" });
if (room.isHost) {
setInterval(() => broadcastState(), 100); // authoritative loop
}
Platform.ready();
});ctx.game.maxPlayers.Sandbox & security
Your game runs in an iframe with sandbox="allow-scripts" and an opaque origin. That is great for players (their session and data are isolated from your code) and has a few consequences:
- β No
localStorage/sessionStorage/ cookies β usePlatform.storage - β No access to the parent pageβs DOM, cookies or JS
- β No reliable
fetch()to external servers β bundle your assets into the zip - β Your own scripts, assets and APIs inside the bundle work normally
- β
window.Platform(postMessage bridge) always works
window.parent.* or touching storage APIs throws a SecurityError β guard with try/catch or simply avoid them.Packaging & upload
Zip the contents of your folder β index.html must sit at the root of the zip, not inside a nested folder:
my-game.zip
βββ index.html β REQUIRED at the root
βββ game.js
βββ style.css
βββ assets/
βββ sprite.png
βββ sound.mp3Limits: max 25 MB per zip Β· max 200 files Β· index.html at the root (index.htm also accepted).
Allowed file types:
| Kind | Extensions |
|---|---|
| HTML | .html .htm |
| JavaScript | .js .mjs |
| CSS | .css |
| Data | .json .map |
| Images | .png .jpg .jpeg .gif .webp .svg .ico |
| Fonts | .woff .woff2 .ttf .otf .eot |
| Audio | .wav .mp3 .ogg |
Error codes you may get back:
| Code | HTTP | Meaning |
|---|---|---|
NO_INDEX_HTML | 400 | No index.html at the zip root |
ZIP_TOO_LARGE | 413 | Zip exceeds 25 MB |
TOO_MANY_FILES | 400 | More than 200 files |
DISALLOWED_EXT | 400 | A file has a disallowed extension |
INVALID_ZIP | 400 | File is not a readable zip |
EMPTY_ZIP | 400 | Zip has no files |
INVALID_PATH | 400 | A path contains .. |
FORBIDDEN | 403 | Not signed in as the gameβs developer |
Publishing workflow
- Draft β create the game in Studio, upload the zip
- Submitted β click *Submit for review*
- Published β an admin approves it; it appears in Browse and Quick Play
- Rejected β the admin leaves a note; fix, re-upload, resubmit
Good metadata (clear title, description, tags, correct category and multiplayer flag) gets your game played β it is what Browse, search and Quick Play use.
Complete example
A finished single-player game: click counter with a saved best score, submitted to the leaderboard every 10 clicks.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Click Rush</title>
<style>
body { font-family: system-ui, sans-serif; display: grid; place-items: center;
min-height: 100vh; margin: 0; background: #0f172a; color: #e2e8f0; }
button { font-size: 2rem; padding: 1rem 2.5rem; border: 0; border-radius: 1rem;
cursor: pointer; background: #f43f5e; color: white; }
</style>
</head>
<body>
<div style="text-align:center">
<h1>Clicks: <span id="count">0</span></h1>
<p>Best: <span id="best">0</span></p>
<button id="btn">CLICK!</button>
</div>
<script src="/sdk/game-sdk.js"></script>
<script>
let clicks = 0, best = 0;
const countEl = document.getElementById("count");
const bestEl = document.getElementById("best");
Platform.onReady(async (ctx) => {
best = (await Platform.storage.get("best")) || 0;
bestEl.textContent = best;
Platform.ready(); // finished loading
});
document.getElementById("btn").addEventListener("click", async () => {
clicks++;
countEl.textContent = clicks;
if (clicks > best) {
best = clicks;
bestEl.textContent = best;
await Platform.storage.set("best", best); // persistent personal best
}
if (clicks % 10 === 0) {
const res = await Platform.submitScore(clicks);
Platform.notify("Score " + clicks + " submitted β rank #" + res.rank, "success");
}
});
</script>
</body>
</html>Multiplayer example
The skeleton of a real-time multiplayer game β join a room, keep the roster in sync, host-authoritative messages:
<script src="/sdk/game-sdk.js"></script>
<script>
let room = null;
Platform.onReady(async (ctx) => {
try {
room = ctx.room ?? await Platform.multiplayer.joinRoom({ mode: "auto" });
document.getElementById("status").textContent =
"Room " + room.code + (room.isHost ? " (host)" : "");
Platform.ready();
} catch (err) {
Platform.notify(err.message, "error");
}
});
Platform.multiplayer.onPlayersChange((players) => {
document.getElementById("players").textContent = players.length + " players";
});
Platform.multiplayer.onMessage((msg, from) => {
if (room.isHost) {
// Host validates inputs and broadcasts authoritative state
if (msg.type === "jump") applyJump(from.id, msg.x, msg.y);
} else {
// Non-host: render the state the host sent
if (msg.type === "state") render(msg.snapshot);
}
});
function jump(x, y) {
Platform.multiplayer.send({ type: "jump", x: x, y: y });
}
</script>Sample games & testing
- Published sample games on the platform (Click Rush, Reaction Duel, Memory Match) are complete, working references
- Open your game in two browser windows to test multiplayer
- Check the browser console for SDK warnings (e.g. messages dropped before
onReady) - Verify scores on the gameβs Leaderboard page and progress via
Platform.storage - Test both a signed-in player and a guest
Troubleshooting
- `Platform` is undefined β the SDK script tag is missing or placed after your code. Load
/sdk/game-sdk.jsfirst. - `onReady` never fires β expected outside the platform (e.g.
file://). Test through your Studio upload or Browse. - Messages are dropped with a console warning β you called an SDK method before
onReady; move that code inside the callback. - `joinRoom` rejects after 15 s β wrong room code, room is full, or no open lobby to match into.
- `submitScore` rejects β player not signed in, non-numeric value, or the platform did not answer within 10 s.
- `storage.get` returns `null` β the key was never set (or the value failed to answer in 8 s).
- Upload rejected β see the [error codes](#packaging): most often a nested folder hiding
index.htmlor a disallowed file type. - No multiplayer β the game was created with the multiplayer flag off; metadata controls it.
Get the SDK
The SDK is a single self-contained file served by the platform β always load it from the URL below so you get protocol fixes automatically:
<script src="/sdk/game-sdk.js"></script>