PixelJS gives your game levels, leaderboards, achievements, cloud saves, online play and a player community, without servers of your own. Your game declares what it has in a manifest file and talks to the portal through a small bridge library. This guide is the contract between your game and the portal (bridge 2.0, manifest version 2).
1. Who does what
The portal handles player accounts and sign-in, public handles and avatars, storing results, ranking (100 players per page), the champion on your game page, anti-cheat checks, achievements, cloud saves, notifications, sharing and moderation.
Your game handles telling the portal when a level starts and ends, and what the player achieved. A game never learns who the player is beyond a "signed in" flag and their public handle, and never sends anything over the network itself: games run in an isolated sandbox with no network access and no browser storage.
A game without any of this still works on PixelJS. It simply has no leaderboard.
2. Quick start
- Download the starter game above. It is a complete example with three levels, leaderboards, stars, achievements and saves, built with the PixelJS engine.
- Edit
public/pixeljs.json(the manifest, section 3) to describe your game. - Call
levelStart()when a level begins andlevelEnd()when it ends (sections 5 and 6). - Build, zip the build folder (with
index.htmlandpixeljs.jsonat the root of the ZIP) and upload it in the studio. - Open the signed preview: it runs in test mode with the bridge inspector, so you see every message and every validation error.
- Submit the version for review.
The smallest integration, for a game with one endless level:
{
"manifest_version": 2,
"min_age": 0,
"capabilities": ["pause", "mute", "scores"],
"leaderboards": [
{ "id": "high-score", "name": "High score", "scope": "game", "sort": "desc", "type": "points",
"min": 0, "max": 1000000, "periods": ["all", "week", "day"], "default": true }
]
}
import { connectPortal } from "./portal-bridge.js";
const portal = await connectPortal();
portal.ready();
async function playOneRun() {
const run = await portal.levelStart(); // the implicit level "main"
const score = await runGameUntilTheShipIsHit(); // your game
const result = await portal.levelEnd(run, { outcome: "fail", scores: { "high-score": score } });
if (result.newBest) showNewBestBanner(); // optional: the portal shows its own message too
}
3. The manifest: pixeljs.json
A JSON file (UTF-8, at most 256 KB) at the root of the uploaded ZIP. It is validated when the build is uploaded, and reviewed with the version. Listing information (title, description, category, cover, preview clip) is entered in the studio.
3.1 Top level
| Field | Required | Notes |
|---|---|---|
manifest_version |
yes | 2 |
min_age |
yes | The youngest age the game suits: 0 (Everyone), 10 (Everyone 10+) or 13 (Teen). It becomes the game's age rating. Adult content is not accepted |
capabilities |
yes | What the game uses (section 4.3) |
width, height |
no | Game size in pixels (16–7680 × 16–4320) |
scale_mode |
no | "fit", "integer" or "responsive" |
orientation |
no | "landscape", "portrait" or "any" |
inputs |
no | "keyboard", "mouse", "touch", "gamepad" |
controls_md |
no | Controls help, at most 2,000 characters |
worlds |
no | Groups of levels |
levels |
no | Without it, the game has one implicit level, main |
leaderboards |
no | At most 20 |
achievements |
no | At most 100 |
save |
no | Required with the save capability |
integrity |
no | Plausibility limits |
IDs of levels, worlds, leaderboards and achievements match ^[a-z0-9][a-z0-9-]{0,39}$. IDs are permanent: progress, results and achievements are stored by ID, so renaming an ID counts as deleting one item and adding another. Display names have 1–40 characters and follow the same name rules as channel names.
3.2 Levels
| Field | Default | Notes |
|---|---|---|
id, name |
— | Required |
world |
none | A world ID |
ranked |
true |
Level leaderboards exist only for ranked levels |
unlock |
"previous" |
"always", "previous", { "after": ["1-2"] } or { "stars": 6 }, used by the portal's level list. Your game enforces its own rules |
hidden |
false |
A secret level, shown in the portal after the player completes it |
par_time_ms |
none | Shown next to the player's best time |
Order in the array is the display order. Up to 500 levels.
3.3 Leaderboards
| Field | Notes |
|---|---|
id, name |
Required |
scope |
"game" (one board) or "level" (one board per ranked level) |
source |
"run" (default: the value you report), "sum_levels" (sum of the player's bests on the level board named in of), "levels_completed" or "stars" (computed by the portal) |
sort |
"desc" (higher is better) or "asc" (lower is better, for times) |
type |
"points", "time" (milliseconds, shown as m:ss.mmm), "count" or "distance" |
unit |
At most 8 characters, for count and distance ("m", "moves") |
min, max |
Required for source: "run". Values outside are refused. Staff review them, so use your real game balance |
max_per_second |
Optional growth limit: a value above max_per_second × run seconds is refused |
outcomes |
Which outcomes count: "complete", "fail". Default: both for points, complete for times |
periods |
"all" (required), "month", "week", "day" |
default |
Exactly one board: it decides the champion on your game page |
Values are integers. For decimals, scale them (centimeters instead of meters) and set unit.
3.4 Achievements
| Field | Notes |
|---|---|
id, name |
Required |
description |
At most 120 characters. Hidden achievements show "Secret" until unlocked |
icon |
Path of a 64×64 PNG (at most 32 KB) in the build |
points |
5–100 experience points for the player |
hidden |
Default false |
rule |
Optional, checked by the server: { "complete": "1-4" }, { "complete_world": "moon-base" }, { "complete_all": true }, { "board": "high-score", "at_least": 10000 }, { "board": "fastest", "level": "1-1", "at_most": 60000 }, { "stars": 30 } |
Prefer rules: the portal unlocks them from recorded results, so they cannot be forged. An achievement without a rule is unlocked by your game with unlock().
3.5 Saves and integrity
"save": { "slots": 3, "max_bytes": 65536, "schema": 1 },
"integrity": { "min_run_ms": 2000 }
slots 1–32; max_bytes up to 262,144 per slot (1 MB per game in total). schema is your save format version, handed back with each save. min_run_ms (default 1,000): runs shorter than this are not ranked.
4. The bridge
4.1 The library
Copy portal-bridge.js (no dependencies) into your build and import it. It must be part of your build: games may only load files from their own host. Outside the portal (your own site, local development) every call still resolves quickly: inPortal is false, levelEnd() answers { recorded: false, reason: "not_in_portal" } and saves stay in memory, so the same build runs anywhere.
4.2 Connecting
const portal = await connectPortal({ timeoutMs: 3000 });
portal.inPortal; // true inside PixelJS
portal.capabilities; // capabilities granted by the reviewed manifest
portal.loading(loadedBytes, totalBytes); // optional: the portal's loading bar
portal.ready(); // your game accepts input
4.3 Capabilities
| Capability | Gives you |
|---|---|
pause |
pause and resume events |
mute |
mute events |
levels |
Your declared levels in levelStart, levels() progress, the portal's level list |
scores |
Results recorded on your leaderboards |
achievements |
unlock() |
save |
save() and load() |
level-select |
select events when a player picks a level in the portal or opens a link to one (?level=1-2) |
4.4 Events from the portal
portal.on("pause", () => game.pause());
portal.on("resume", () => game.resume());
portal.on("mute", ({ muted }) => audio.setMuted(muted));
portal.on("visibility", ({ visible, focused }) => releaseHeldKeys());
portal.on("viewport", ({ width, height, dpr, mode }) => relayout());
portal.on("select", ({ level }) => goToLevelIfUnlocked(level));
portal.on("player", ({ signedIn, handle }) => updateGreeting(signedIn, handle));
Always release held inputs on visibility and pause.
5. Levels and runs
A run is one attempt at one level.
const run = await portal.levelStart("1-2"); // a declared level; no argument means "main"
const result = await portal.levelEnd(run, {
outcome: "complete", // "complete", "fail" or "quit"
scores: { "level-score": 4200, "fastest": 51320 },
timeMs: 51320, // game time in the level, without pauses
stars: 3, // 0–3, optional
stats: { coins: 37, deaths: 1 } // optional: up to 8 numbers, for your statistics
});
- One run at a time: starting a new run ends the previous one as
quit. - Call
levelEndfor everylevelStart, including when the player quits to the menu (outcome: "quit"; quits are never ranked). timeMsis game time and must not exceed the real time sincelevelStartby more than 2 seconds.- Endless games:
levelStart()when a run begins, thenlevelEnd(run, { outcome: "fail", scores }), or the shortcutportal.gameOver({ scores }), when it ends. - Do not start runs on menus, tutorials or attract mode.
levels() returns the player's progress (or the browser's progress for guests), to show stars and unlocks in your own menus:
const progress = await portal.levels();
// { "1-1": { "completed": true, "stars": 3, "best": { "level-score": 4200 } }, "1-2": { "completed": false } }
6. Results and leaderboards
levelEnd answers:
{
recorded: true, // false for guests, refused values, quits, or outside the portal
reason: undefined, // when not recorded: "guest", "quit", "out_of_bounds", "too_short", "too_fast", ...
newBest: { "level-score": true },
best: { "level-score": 4200 },
rank: 17, // on the default board, all-time
unlocked: ["speed-demon"] // achievements unlocked by this run
}
The portal checks that the level and boards are declared, that values are integers within min–max, that the run lasted at least min_run_ms, that game time fits real time, and growth limits. Unusual results may be held for review: the player sees "Under review", your game gets a normal answer. The portal shows its own result message below your game (best, rank, "Sign in to save this score" for guests), so do not build in-game name entry or ranking tables.
| Game | Suggested boards |
|---|---|
| Endless arcade | high-score (game, desc, points, all/week/day, default) |
| Platformer with levels | level-score (level, desc); fastest (level, asc, time, outcomes complete); total (game, sum_levels of level-score, default) |
| Puzzle | fewest-moves (level, asc, count, unit "moves"); stars (game, source stars, default) |
| Racing | best-lap (level, asc, time); trophies (game, levels_completed) |
Compute scores from game state, never from values players can type; set limits from your real game balance; and put a ceiling on scores that keep growing. Read the Fair Play Rules.
6.1 Verified leaderboards
A board declared with "verification": "replay" accepts a replay value in levelEnd (at most 64 KB of JSON, for example a seed and the inputs per update tick). A verification module named in the manifest ("verification": { "module": "verify.js" }) re-runs the game logic on the server and confirms the score; confirmed results show ✓ Verified. The module is a plain script that defines a global function verify(input), where input is { level, replay, scores }, and returns { scores: { ... } }. It runs without a browser, network or clock, within 15 seconds, so the game logic must be deterministic (fixed time steps, a seeded random generator, integer arithmetic). During the beta, the server runs verification modules only for official PixelJS games.
7. Achievements
await portal.unlock("no-damage-run"); // during a run, or within 10 seconds after levelEnd
unlock is ignored for guests, undeclared IDs, and beyond one call per second. It answers { unlocked: true } the first time and { unlocked: false, reason: "already" } afterwards.
8. Saves
const { data, rev, schema } = await portal.load("slot-1"); // data: a string or null
const saved = await portal.save("slot-1", JSON.stringify(state), { rev });
if (!saved.ok && saved.reason === "conflict") { /* changed on another device: reload, merge, retry */ }
Signed-in players get cloud saves on every device. Guests' saves stay in their browser, and the portal offers to move them into the account after sign-in. Save at checkpoints, not every frame (at most one save per slot every 2 seconds). When you change your save format, raise save.schema and migrate old data when you load it.
9. The player
const player = await portal.player(); // { signedIn: true, handle: "moonrunner" } or { signedIn: false }
That is everything a game ever learns about a player. Do not ask players for names, emails, ages, locations or contact details, and do not try to identify them.
9A. Online play (optional)
Games can let players play together online, from 2 up to 16 players per room. It is optional: declare it only if your game supports it. Games still never touch the network: the portal finds the players, shows its own waiting and invite screens, and relays your game's messages between the players' pages.
"capabilities": ["multiplayer", "..."],
"multiplayer": {
"min_players": 2, "max_players": 4,
"modes": [ { "id": "versus", "name": "Versus" }, { "id": "duel", "name": "Duel", "max_players": 2 } ],
"quick_match": true, "private_rooms": true, "join_in_progress": false,
"max_message_bytes": 1024, "max_messages_per_second": 20
}
| Field | Default | Notes |
|---|---|---|
min_players, max_players |
2, required | 2–16 players per room |
modes |
one mode online |
Up to 10, each with its own min_players/max_players within the section's |
quick_match |
true |
Players are matched automatically |
private_rooms |
true |
A player creates a room and invites friends with a code or link |
join_in_progress |
false |
Whether players may join a running match |
max_message_bytes |
1024 | Up to 4,096 bytes per message |
max_messages_per_second |
20 | Up to 60 per player |
const online = portal.multiplayer;
if (online.available) showOnlineButtons();
online.on("room", (room) => drawLobby(room)); // { code, mode, private, state, host, me, min, max, players }
online.on("start", ({ seed, players, me, host }) => startMatch(seed, players, me, host));
online.on("message", ({ from, data }) => applyRemote(from, data));
online.on("left", ({ slot }) => removePlayer(slot));
online.on("end", ({ reason }) => backToMenu());
await online.find({ mode: "versus" }); // quick match: the portal shows "Looking for players…"
const { room } = await online.host(); // private room: the portal shows the invite code and link
online.send({ x, y }, { to: 2 }); // to one slot, or to everyone without "to"
online.result([2, 0, 1]); // the host reports the slots in finishing order
online.leave();
- Only signed-in players can play online. Guests are asked to sign in.
- Every player of a match receives the same random seed: use it for anything random, so every game sees the same world.
- The portal relays messages; it does not run your game logic. Choose your own model: the host (
room.host) sends the state, or every game simulates the same inputs. - Players can join from an invite link (
?room=CODE): your game receivesroomandstartevents even when it did not callfindorhost, so listen to them from the start. - When the host leaves, the next player becomes host, and every game gets a new
roomevent. - Online results are not ranked during the beta. Players earn a little experience for finished matches.
- No chat. Games must not let players send each other free text, names or links; fixed phrases and emotes are fine. Players can block and report each other from the portal's room screen.
10. Messages (without the library)
Messages are plain objects sent with postMessage between the game and its parent window:
type Message = { pjs: 2; type: string; nonce: string; id?: string; re?: string; data?: object };
The nonce comes from the game URL's fragment (#pjs=...). Accept messages only when event.source === window.parent and the nonce matches; send to window.parent with target "*". Requests carry an id (at most 32 characters); the portal answers with type: "reply" and re set to that id.
| Game → portal | data |
Reply |
|---|---|---|
hello |
{ bridge: "2.0.0", capabilities } |
welcome { bridge, capabilities } (a message, not a reply) |
ready, interaction, state, error, progress |
as in bridge 1 | — |
player.get |
— | { ok, signedIn, handle? } |
level.start |
{ level? } |
{ ok, run, level } |
level.end |
{ run, outcome, scores?, timeMs?, stars?, stats?, replay? } |
{ ok, recorded, reason?, newBest?, best?, rank?, unlocked? } |
game.over |
{ scores, timeMs?, replay? } |
as level.end |
levels.get |
— | { ok, levels } |
achievement.unlock |
{ id } |
{ ok, unlocked, reason? } |
save |
{ slot, data, rev? } |
{ ok, rev } |
load |
{ slot } |
{ ok, data, rev, schema } |
mp.find, mp.host |
{ mode? } |
{ ok, room? } |
mp.join |
{ code } |
{ ok, room } |
mp.start |
— | { ok } (host) |
mp.result |
{ placements } |
{ ok } (host) |
mp.send, mp.ready, mp.leave |
{ data, to? }, { ready }, — |
— (no reply) |
Portal → game: welcome, viewport, visibility, pause, resume, mute, player, select, mp.room, mp.start, mp.message, mp.left, mp.end, and reply ({ ok: true, ... } or { ok: false, error: { code, message } }).
Limits: 60 messages per second; 4 KB per message, except save (260 KB) and level.end or game.over with a replay (72 KB); one level.end per run; one unlock per second.
11. Testing
- Signed preview (studio): your build runs as in production, in test mode. Results are validated like on the public page and go only to test leaderboards that you and staff see.
- Bridge inspector (below the preview): every message your game sent and received, with validation errors.
- Outside the portal: your game must still run. Test it on its own too.
12. Updating a published game
- Adding levels, leaderboards or achievements: fine, in a new version. Players who played your game are told about new levels.
- Changing a board's
sort,type,sourceor limits: when you submit, choose keep results (still comparable) or new season (the board starts empty; the old one becomes an archive). Staff review the choice. - Removing a level archives its leaderboards. Removing an achievement keeps it for the players who earned it.
13. Review checklist
- [ ]
pixeljs.jsonvalidates in the studio, withmin_agematching your game's content. - [ ] Every level the game can start is declared, with permanent IDs.
- [ ]
levelStart/levelEndwrap every level and every endless run, and nothing else; quits sendoutcome: "quit". - [ ] Leaderboard limits match real gameplay, and the default board is the one players care about most.
- [ ] Achievements have clear descriptions; rules are used wherever possible.
- [ ] The game pauses, resumes and mutes on portal messages, and releases held keys.
- [ ] Saves load correctly in a fresh browser, signed in and as a guest.
- [ ] The test leaderboard in the preview shows sensible results.
- [ ] The game runs outside the portal.
- [ ] No personal data is requested; no in-game name entry; no network calls.
- [ ] Online play (if declared): works with the minimum and maximum players, survives a player leaving, and offers no free-text chat.
14. Error codes
| Code | Meaning |
|---|---|
not_in_portal |
Not running inside PixelJS |
not_granted |
Capability not in the reviewed manifest |
unknown_level, unknown_board, unknown_achievement |
ID not in the reviewed manifest |
no_active_run, run_finished |
levelEnd without a matching levelStart, or called twice |
out_of_bounds |
Value outside min–max, not an integer, or too large |
too_short |
Run shorter than min_run_ms |
too_fast |
Game time longer than real time, or growth above max_per_second |
guest |
The player is not signed in (results are shown, not stored) |
rate_limited |
Too many messages, runs or saves |
too_large |
Save above max_bytes |
conflict |
The save revision is outdated |
unavailable |
The portal could not reach its servers; the run was not recorded |
invalid |
Malformed message |