Quick start
Put the script on your game’s page and call PlayerTwo.init() once, early. It answers with who is playing and every call below.
<script src="https://playertwo.lol/sdk/v1.js"></script>
<script>
PlayerTwo.init()
.then(async (p2) => {
console.log(p2.player.name, "level", p2.player.level);
await p2.trophies.unlock("first-win");
})
.catch((e) => {
/* not on Player Two (e.code is "not-on-player-two"): the game plays without trophies or saves */
});
</script>Where it works
The services are for games Player Two hosts: a web build kept here (one .html, or a .zip of your export), or downloads players get from Player Two and play in its app. A game played on its own site gets them in Player Two’s player once its developer turns on Player Two Connect, with a signed identity its own servers can trust.
- On Player Two (
PlayerTwo.envis"p2"): your game runs in Player Two’s player, at its own address on the games host (hosting), where Player Two puts this script in its page for you. The SDK says hello to the page around it, takes a private channel from it, and every call goes over that channel. In the Player Two app, the same calls go to the app’s local bridge. - On localhost (
"local"): a stand-in answers every call from memory, so you can build without Player Two. - Anywhere else (
"disabled"):init()rejects after 1.5 seconds with codenot-on-player-two, and your game plays on without services. Or, with{ required: true }, the page says “Play this on Player Two” with a link, and stops.
Building without Player Two
On localhost the stand-in knows no trophies unless you tell it. Give it yours, and it refuses a key that isn’t among them, as Player Two would:
const p2 = await PlayerTwo.init({
local: { trophies: [{ key: "first-win", name: "First win", tier: 1 }] },
});
// on localhost: answered from memory; on Player Two: the real thing
// local: true (or { force: true, trophies }) uses the stand-in wherever the game runsEvery call
Every call returns a promise. A refusal rejects with an Error that has a code and a status.
init
const p2 = await PlayerTwo.init({ required: false, timeout: 1500 });
p2.player // { id, handle, name, picture, creature, level } id: theirs for your game only
p2.game // { slug, title }
p2.test // true while its developer (or an admin) plays: see Test mode
p2.demo // true in a demo: demos earn no trophies
p2.params // the invite params the player came with, or null
p2.env // the same as PlayerTwo.env: "p2" | "local" | "disabled"
p2.call("trophies.list") // any call by its name: the helpers here are these
PlayerTwo.ready // a promise of the same p2, once init has run (for code that starts before or apart from it)
PlayerTwo.v // 1: this script's versionThe player’s id is the same every time for your game, and different in every other game, so games can’t line up their players with each other’s. Their handle and name are public. Player Two never tells a game a player’s email or anything about their balance.
me
const { player, game, session, test, demo, from } = await p2.me();
// from: "web" (Player Two's player) or "desktop" (the app)Trophies
const { trophies, xp, level } = await p2.trophies.list();
// each: { key, name, how, tier, xp, hidden, art, earned: { at, n } | null, share, rarity }
// a hidden one the player hasn't earned: name, how and art are empty
const won = await p2.trophies.unlock("flawless-duel");
// { trophy, new, n, xp: { gained, total }, level, complete? }
// new: false if they had it; n: which player to earn it they are (No. 1 is the first)
// complete: the completion trophy, earned with this one
// xp.pending: XP that waits until your game counts toward levelsThe player’s host shows the trophy arriving over your game; your game doesn’t have to. Define trophies in your studio (each game’s card, Trophies) or through your agent: a key your code uses, a name, how to earn it, a tier, hidden or not, its sign, optionally a stat and target, optionally “only my server can give it”.
Add-ons
const list = await p2.addons.list();
// each add-on on sale: { key, name, about, price, owned }
if (await p2.addons.has("night-map")) unlockNightMap();
// true once the player has bought it; one taken off sale stays theirsAdd-ons are more of your game sold on their own (a map, a chapter, a skin pack) to players who have it. Make them in your studio (each game’s card, Add-ons): a key your code asks for, a name, a line about it, a price. Players buy them on your game’s page; ask again when your game opens, or when the player comes back to it. Building without Player Two, the stand-in takes local: { addons: [{ key, name, owned }] }. Where it matters that the player can’t fake it, ask from your server (GET /api/v1/sdk/server/addons, below). On a computer, the Player Two app answers these calls once it is updated.
In-game purchases
// products you made in your studio
const sold = await p2.store.list(); // [{ key, kind, name, about, price, qty?, contents?, owned, count? }]
const mine = await p2.store.owned(); // [{ key, kind, count?, test? }]
const r = await p2.store.buy("blaze-ball");
if (r.ok) equip(r.product.key); // r.receipt: signed, for your server
// or your own shop at your own prices: a charge your server made
const paid = await p2.store.pay(chargeId);
if (paid.ok) show(paid.charge.ref); // your server gives it (paid.receipt, or its webhook)
else if (paid.reason === "cancelled") {} // they said not nowSell inside your game through Player Two’s payments, two ways: products you make in your studio (p2.store.list, p2.store.owned, p2.store.buy), or charges your own server makes at your own prices, for what your game keeps itself (p2.store.pay). Either way Player Two’s own card takes the player’s yes and the money, and you get 80%. How each works, every limit and every refusal: In-game purchases.
Stats
await p2.stats.add("duels-won"); // by 1
await p2.stats.add("gold", -50); // or any whole number
await p2.stats.set("highest-floor", 12);
const { stats } = await p2.stats.get(); // { "duels-won": 3, ... }
const floor = await p2.stats.get("highest-floor");
// set and add answer { stats, unlocked }: a trophy waiting on a stat is earned
// by itself when the stat reaches its target, and comes back in unlockedLeaderboards
const s = await p2.scores.submit("time-attack", 83400);
// { value, best, new, previous, rank, total }: only a player's best is kept
await p2.scores.list("time-attack"); // the top 10
await p2.scores.list("time-attack", { around: "me" }); // around the player
await p2.scores.list("time-attack", { friends: true, limit: 25 });
// { board, rows: [{ rank, value, at, player: { id, handle, name, picture }, me? }], total, me }Define boards in your studio: higher or lower is better, a number, a time in milliseconds or points, the lowest and highest score it takes, how many seconds a player waits between scores, and whether only your server sends its scores (trusted). A player who keeps off leaderboards shows as “A player”.
Saves
const { slots } = await p2.saves.list(); // [{ slot, size, version, at }]
const save = await p2.saves.get("slot-1"); // .json(), .text(), .bytes(), .version
await p2.saves.put("slot-1", { level: 3 }, { version: save.version });
await p2.saves.delete("slot-1");
// put takes text, an object (kept as JSON) or bytes, up to 1 MB
// { version }: refused (409, e.version is the one there now) if another device saved sinceA slot that holds a save is only written over once your game has read it in this session (get, or list), so a game starting blank on a new device can’t wipe a player’s progress. A new slot needs no read.
Presence
p2.presence.set("Chapter 3, The Docks"); // up to 80 characters
p2.presence.set(null); // just the game's name
p2.presence.set("In a party", { join: { room: "482913" } }); // friends get JoinThe player’s friends see that they are playing your game, and this line, unless the player keeps their activity private. Time played counts by itself while the game is open. With join, they also get Join beside it, which opens your game with those params (invites).
Identity and invites
const token = await p2.identity(); // who is playing, signed for your own servers (ten minutes)
const link = await p2.invite({ room: "482913" }); // playertwo.lol/game/<your game>?join=...
p2.params // the params the player came with, or nullAn identity is a JWT signed with Player Two’s key, for your game only, which your servers check before they trust who it names; an invite is a link to your game on Player Two that starts it with the params. Both, and how to carry your own accounts and multiplayer over: Player Two Connect. The player’s critter for your character (p2.avatar()), and doors to other games with their party (p2.travel): Travel and avatars.
Loading and gameplay
p2.loading.done(); // the game is ready to play
p2.gameplay.start(); // a round, a level, a run begins
p2.gameplay.stop(); // ... and ends, or the game pausesPlayer Two’s player and its app use these for what they show over the game. Nothing is kept of them yet.
The overlay
const off = p2.on("overlay", (open) => (open ? pause() : resume()));
// on the web: Player Two's bar over the game; in the app: its window over the gameOn air in the Plaza
p2.onAir // true while a small live picture of the game goes to the Plaza's screensWhile an online game is played in Player Two’s player on a computer, its screen in the Plaza can show it live: your game’s biggest canvas, scaled down to about 480 lines at up to 15 frames a second, so passers-by see it being played before they walk in. Player Two does it, with nothing to add to your game: nothing of the game is read (the browser encodes what the canvas already shows), nothing is recorded, it starts a few seconds into play and pauses while the tab is hidden. Never on a phone, in a test, or for a player who keeps their activity private or turned it off; the player’s On air mark stops it. p2.onAir says whether this play may go on air, should your game want to say so itself.
When a call is refused
| Code | Status | What it means |
|---|---|---|
not-on-player-two | init only: no Player Two around the game. | |
signed-out | 401 | The player isn't signed in (a demo played signed out): play on without services. |
too-soon | 409 | Nothing is earned in a session's first ten seconds. |
unknown-trophy | 404 | No trophy by that key, or it is retired. |
server-only | 403 | Only your server gives this trophy. |
demo | 403 | Trophies come with the full game. |
revoked | 403 | Player Two took this trophy back from this player. |
read-first | 409 | Read the slot before saving over it. |
version | 409 | Another device saved in between; e.version is the one there now. |
full | 409 | Eight save slots (or 200 stats) already. |
too-big | 413 | A save is up to 1 MB. |
out-of-range | 422 | A score outside the board's bounds. |
cooldown | 429 | Too soon after this player's last score on this board. |
trusted | 403 | Only your server sends this board's scores. |
not-connected | 409 | init only: a game on its own site whose developer hasn't turned on Connect. |
trial-over | 402 | A free trial's time is used up: Player Two's player closes the game with Buy, and what was saved carries on if they buy it. |
trial-paused | 409 | A free trial's time isn't being counted (the player's page stopped checking in): Player Two's player stops the game. Play on without services. |
The rules
Trophies
- 1 to 100 a game. Keys are lowercase letters, numbers and dashes, up to 40; names up to 40 characters; how to earn one up to 120.
- Tiers 1 to 4 are worth 10, 25, 50 and 100 XP, and a game’s add up to 1,000 XP at most. Player Two adds the completion trophy (key
complete, 200 XP) once a game has one: it is earned by holding every other trophy that isn’t retired. - An unlock needs a live session at least ten seconds old, and a session earns twenty a minute at most. Demos earn none.
- Every trophy is a relic, a small sculpture of cubes built from its sign: up to 16 by 16 cells you draw in the studio (or pick), sent by an agent as rows of
#and.(..#../.###./#####). Without one, Player Two picks one by its key; the completion trophy is a crown until you give it your game’s own sign. Its cubes are cut from the finish of how rare it turns out to be, from titanium for Common to pearl mended with gold for Divine. - Once a player has earned a trophy, its key, tier, stat, target and server-only setting stay; its name, how to earn it, sign and whether it is hidden can change. It can be retired (nobody earns it from then on; those who have it keep it), never deleted.
- The first player to earn a trophy is No. 1, and every unlock keeps its number.
XP and levels
A player’s level comes from the XP of every trophy they hold, from every game that counts, and from Player Two’s own trophies. XP from level n to n+1 is 100 × n/10 rounded up: 100 a level up to 11, 200 a level up to 21, and so on.
A game’s trophies count toward levels once it is listed and 20 players have played it ten minutes or more each, or once Player Two says so. Until then they are earned and shown, and their XP waits; it arrives when the game starts counting. Player Two can take a game’s trophies out of levels (an XP farm, say), and take back what a cheating player earned.
Rarity
Each trophy’s share of the game’s players (everyone who has played it, not counting tests) is worked out daily, in hundredths of a percent (share: 425 is 4.25%), and named from Player Two’s ladder: Common at 50% and over, Rare 20%, Epic 10%, Legendary 5%, Mythic 1%, and Divine under 1%. Until a game has 10 players, every trophy is New.
Limits
| What | Limit |
|---|---|
| Trophies | 100 a game, 1,000 XP of tiers 1 to 4 |
| Unlocks | 20 a minute per session |
| Stats | 200 per player per game, 20 in one call, 120 calls a minute; whole numbers |
| Leaderboards | 20 a game; 60 scores a minute per session, and each board's own wait |
| Saves | 8 slots per player per game, 1 MB each; 60 saves a minute per session |
| Reading | 240 reads a minute per session (trophies, stats, scores, saves) |
| A game key | 1,200 requests a minute |
Test mode
You play your own game in test mode, and so do Player Two’s admins, and so do your playtesters before it’s out (a game kept private, or set to come out on a day: you invite them in your studio, or say yes to players who ask from its page): p2.test is true. Rooms, voice, saves and every call work for them on Player Two’s own servers, free. What you earn is kept apart, unnumbered and without XP; your scores show only to you. In your studio, each game’s Testing section wipes your test unlocks, stats and scores, so you can play it again as a new player would.
Rooms
Multiplayer with no server of your own, for games hosted on Player Two: Player Two seats your players in rooms, near them, and your game's pages talk through the room. For games where cheating matters, the room runs your own code.
Joining
const room = await p2.rooms.join({ mode: "duel", size: 2 }); // a match: the fullest open room near you, or a new one
const mine = await p2.rooms.create({ mode: "duel", size: 4 }); // a private room; friends join it by mine.code
const theirs = await p2.rooms.join({ code: "K7Q2PX" });
const party = await p2.rooms.party(); // the player's party, and only them
const friends = await p2.rooms.join({ room: friendsRoomId }); // a friend's room by its id (from presence or a message)
room.id; room.code; room.mode; room.size // its name (join({ room: room.id }) comes back to it), a private one's code, its mode and size
room.me; room.host // your id in it, and the host's
room.leave(); // out of it: your seat goes to someone elseRooms are for games hosted on Player Two (a build you push, or your site served through Player Two), with Player Two rooms picked as the game’s multiplayer (the default). A match puts the player in the fullest open room of that mode and size near them (anywhere when none is near), so rooms fill before more open and players meet those nearest first; a room runs in the Cloudflare location nearest its first player. Modes are names you choose ("duel", "coop-hard"); sizes are 1 to 64 players. Every room’s players are its game’s, signed in to Player Two: room.players has each one’s id (the same as p2.player.id), handle, name, picture, critter and level, their team in a room with teams (below) and their rating in a scripted room.
Any room can be joined by its id, a matched one too: pass a friend’s room.id (put it in p2.presence, or send it) to join({ room }). If it can’t be had, the promise rejects with an error whose code says why, so the game can fall back to an ordinary match:
const room = await p2.rooms.join({ room: friend.room }).catch((e) => {
if (["no-such-room", "full", "not-open", "other-build", "not-in-party"].includes(e.code)) return p2.rooms.join({ mode: "casual", size: 4 });
throw e;
});no-such-room: it closed (or never was). full: every seat is taken. not-open: its code closed it to matching (room.open(false): a match under way takes no one new; a player who had a seat in it still comes back). other-build: it is for another version of the game (reload, or match). not-in-party: a party’s own room. bad-room: that isn’t one of this game’s rooms. A guest on X joins by id like anyone else. To look on without a seat, watch({ room }) (below) works on a closed match too. The full list is under limits.
Teams and parties
const room = await p2.rooms.join({ mode: "ranked-3v3", size: 6, teams: 2, together: true, skill: true });
room.teams // 2
room.players.filter((p) => p.team === me.team) // your side: team is 1 or 2
room.on("team", (id, team) => moveToSide(id, team));export function onJoin(room, player) { spawnOnSide(player.id, player.team); }
// room.team(id, 2) moves a player to team 2; every page hears "team"teams splits a match’s size evenly (2 to 16 teams; 6 seats in 2 teams is 3v3), and is part of what a match is, as its mode and size are. Each player is put on a team as they are seated, and team is on each of room.players and on the players your room.js sees; a player on their own goes on the team with the most seats free, and one who joins by the room’s id on the one with the fewest players. Your code can move anyone with room.team(id, n).
together: true matches the player’s whole Player Two party: the first member to ask finds a match with a team that has a seat for every member, the other members’ seats are held there on that team for 30 seconds, and each member who then asks with together (the same mode, size and teams) gets theirs, beside the rest. Players on their own fill the other team. Without teams, the party is simply in one match together. With skill, the party is matched on its members’ mean rating. It rejects with no-party when the player isn’t in one, and party-too-big when the party has more members than a team has seats. Each member’s page asks for itself: tell the party when to look (a message in the party, or each starts when its leader does), since a member who asks after the 30 seconds is matched as a party again, beside any members already in. p2.rooms.party() is still the party’s own private room.
Messages and state
room.send("move", { x: 3, y: 1 }); // to everyone else
room.send("whisper", "gg", { to: otherId }); // to one (or a list)
room.on("message:move", (move, from) => apply(from, move));
room.set("score:" + room.me, 120); // the shared state, a key at a time
room.on("state", (key, value, by) => redraw(key, value));
room.get("round") // what the room has now
room.state; room.owners // all of it, and whose each key is
if (room.isHost()) startRound(); // the host: whoever has been here longest
const ms = await room.ping(); // there and back
room.now() // the room's clock, the same for everyone in it
room.stats() // { ping, jitter, lane, up, down }: for a connection meterMessages are relayed as they are sent and never kept; what a scripted room’s code sends a page at once arrives as one packet. A connection that goes quiet is noticed within about 8 seconds (a ping every 2 seconds, answered by the network without waking the room) and your seat is got back by itself (reconnecting, reconnected). room.stats() says the ping and its jitter, measured all the while, which way messages are going, and the bytes a second each way. The shared state is kept by the room: a player who joins late, or comes back, gets all of it. A key belongs to the player who first wrote it, and the host may write any key; a leaving player’s keys become the host’s. A write to someone else’s key is undone and refused (error with code not-yours). Your rules run in your game: on the host’s page, or on every page.
Smooth movement
const sync = room.sync(me.mesh); // a THREE.Object3D: position.x, .y, .z and rotation.y
sync.on("join", (id) => sync.bind(id, makeAvatar())); // another player's first update: bind their object
sync.on("leave", (id) => removeAvatar(id));
// in your render loop:
sync.step(); // sends yours, and moves everyone else'sYour object’s fields go out 30 times a second while they change (15 in a match of more than 16 players, and once a second while they don’t), stamped with the room’s clock, as bytes rather than text where they go through the room: a move of four values is 18 bytes instead of about 70, and each player in an update about two fifths of its JSON, every value to a thousandth. Your game’s own messages and the shared state go as bytes too: a message is about a third of its JSON (who it is from in a byte or two, not their id), a list of objects with the same keys about half, and a message may carry bytes of its own (an ArrayBuffer or a typed array) as they are. Every other player’s object is drawn a moment in the past, on a smooth curve through their updates that never overshoots, so it glides instead of jumping; if an update is late it carries on a little the way it was going. That moment is each player’s own, measured as you play: as late as their updates come and the time between two, so a player nearby is drawn about 60 to 90 ms behind and one across the world as far as it takes to stay smooth, eased as it changes. A player who stood still and moves again starts when they started, not before. Options: { fields, rate, delay, angles, snap, near }: fields are paths on the object (["x", "y"] for a 2D sprite), delay fixes the moment in ms for everyone, angles turn the short way round, a jump longer than snap is a teleport, and near is how far this page sees (below). sync.values(id) gives a player’s smoothed values without moving anything, and sync.delay(id) how far behind they are drawn.
The fast lane
room.send("aim", { x, y }, { fast: true }); // lossy and quick: the next update replaces a lost oneFast messages and room.sync go player to player over a WebRTC data channel through Cloudflare’s Realtime network, close to every player, unordered and never resent, so one lost packet never holds up the next. Use it for what the next update replaces (where a player is, where they aim), and plain messages for what must arrive (a hit, a score). It is set up by itself the first time you use it, in rooms of up to 16 players; until every other player has one, in a scripted room and in a bigger room, fast messages go the ordinary way.
The ordinary way is a WebSocket, where one lost packet holds up everything after it until it is sent again (a browser can’t send UDP to a server). So where moves go through the room, room.sync opens a second socket by itself and sends its moves by the two in turn, and the room sends its updates the same way: a lost packet holds up only half of them. On a connection losing a packet every second and a half, the time the latest move was over 100 ms old fell from 13% to 2.5%.
A fast message that can’t go player to player (in a scripted room, where it goes to your code, or a room over 16) goes by the same two sockets in turn, numbered: one held up on one socket doesn’t hold up the next on the other, and an older one of its type that arrives after a newer one is dropped, so your code only ever hears the newest. For a player’s controls, use input-run rooms: each input goes by both sockets with the two before it again, and none is ever dropped. Cloudflare’s Realtime network carries data channels only between browsers, not to a room’s server (checked 2026-11-06), so the room’s own lane is these two sockets.
Scripted rooms
export const tickRate = 20; // ticks a second (1 to 60)
export function onCreate(room) { room.state.set("round", 1); }
export function onStart(room) {} // each time the code starts (after onCreate the first time)
export function onJoin(room, player) { room.send("welcome", { id: player.id }); }
export function onMessage(room, player, type, data) {
if (type === "shoot" && canHit(room, player, data)) room.send("hit", { by: player.id, on: data.target });
}
export function onSync(room, player, values) { // optional: check movement; false drops it
return tooFast(room, player, values) ? false : values;
}
export function onTick(room, dt) { stepTheWorld(room, dt); }
export function onLeave(room, player, why) {}Put a room.js (one ES module, bundled, up to 1 MB) at the root of the build you push, and every room of that build runs it on Player Two’s servers: in a sandbox of your build’s own with no network, 100 ms of CPU a call. Each room has its own room, room.data and room.store, but rooms of your build that run on the same machine share your module’s own variables (what you declare at its top level): keep what is one room’s in room.data, or in a Map by room.id (the same id its pages have). In a scripted room your pages’ messages go to your code, not to each other, and only your code writes the shared state, so a player can’t fake a hit or a score. room has id, players, player(id), send(type, data, { to }), state.get and state.set, kick(id, why), close(why), between(), past(id, at) and objects (below), data (yours to keep things in, kept with the room), store (a key-value store for what doesn’t fit in data, read and written at once: store.get, store.set, store.delete and store.keys(prefix, limit); a world’s is kept for good, through every new build), save(id, data) (what you keep of a player, below), now() and log(). Each of room.players has the player’s id, handle, name, level, saved, rating, team, and ping and jitter, their round trip as the room measures it (below). room.scripted is true in your pages. Bundled from TypeScript, defineRoom from playertwo-sdk/room types every hook at once; plain JavaScript gets the same types with // @ts-check and the package’s Room and Player.
room.save(player.id, data) keeps what your game knows of a player on Player Two’s servers, for good (up to 64 KB: their level, their gear, where they were): it comes back as player.saved when they join any of your game’s rooms or worlds, on any device. Players can’t write it; only your code can.
// the page
room.send("shoot", { at: sync.seen(targetId), target: targetId, aim });
// room.js
export function onMessage(room, player, type, data) {
const where = room.past(data.target, data.at); // the target where the shooter saw them
if (type === "shoot" && where && hits(data.aim, where)) room.send("hit", { by: player.id, on: data.target });
}Each page draws the others a moment in the past, so a shot that hits on the shooter’s screen can miss where the target is now. sync.seen(id) on the page is the room’s time that player is drawn at; room.past(id, at) in your code is where they were then (by field name, from their moves of the last second), so you judge the hit as the shooter saw it, the way shooters do it on their own servers.
A room opens near its first player, so when your code decides hits, the player nearest it has the edge. Call room.between() as a match starts: when its players are far apart (Bangkok and Toronto, say), the room is opened again in the region nearest the furthest of them and every page moves there by itself in about a second (“reconnecting”, “reconnected”, then “moved”). Your code starts again there as after a restart, with room.data and the shared state: onCreate doesn’t run again, and what lives only in your module’s own variables doesn’t come along. Players near each other stay where they are.
The file is private: Player Two takes it out of your build as the build goes live and never serves it to players. Each build’s code is its own sandbox, so a new build’s code starts with new rooms and never touches the rooms of the one before; what your code throws shows in your studio. On localhost a page that serves /room.js gets a scripted room too: the first tab runs it, and when that tab closes the next one carries on with its room.data (one that can’t be copied between tabs, with functions in it, starts again).
Corrections
export function onSync(room, player, values) {
if (values.x > 100) return { ...values, x: 100 }; // a wall: corrected
return values;
}
export function onMessage(room, player, type, data) {
if (type === "hit") room.correct(data.target, { x: room.past(data.target).x + 5 }); // knocked back
}sync.on("fix", (values, { by }) => { player.velocity.x = 0; }); // put right: reset your own speed hereWhen your code corrects a move, or moves a player itself with room.correct(id, values), their page is put right the way Unreal puts a character right: as of the move your code corrected, with everything the player has done since played on top. So what shows is only how far off they were (a knock-back of 5 moves them 5), not a jump back by the distance they covered while the correction was on its way. The moves a player makes before their page hears of it are moved by it too, by your code, so your code, the player and everyone watching see them in the same place, and never going back. Pushed 5 along while moving, with 200 ms of lag: the page moved 5.00, and the other player saw them go straight on; put where the room says instead ({ fix: "snap" }, as before), the page moved 4.49 and the other player saw them go back 3.5 first. sync.on("fix", (values, { at, by })) says what changed; if your game keeps its own copy of the player’s position, copy values into it there.
Input-run rooms
export const tickRate = 60;
import { step } from "./fight.js"; // your game's own step, the same file your page runs
export function onJoin(room, player) { (room.data.fighters ??= {})[player.id] = { x: 0, y: 0, hp: 100 }; }
export function onTick(room, dt, inputs) { // one input of each player's a step, in order
for (const p of room.players) {
const f = room.data.fighters[p.id];
step(f, inputs[p.id] ?? null, dt); // none: theirs didn't come in time
room.confirm(p.id, f); // their state now, for their page
room.objects.set(p.id, { x: f.x, y: f.y }); // where everyone else draws them
}
}const me = room.inputs({ state: { x: 0, y: 0, hp: 100 }, input: readPad, step, smooth: 100 });
// each frame:
me.update(); // this tick's input read, sent, and run here at once
draw(me.view()); // you, with no wait; everyone else by room.objectsFor a fight, or anything a player mustn’t be able to fake, run the room on its players’ inputs, as Unreal and Overwatch do: each page sends only what its player pressed, a tick at a time, and your code runs one of each player’s inputs a step, so where everyone is and who hit whom is your code’s alone. The page runs the same step on its own inputs at once, so its player moves with no wait however far the room is; room.confirm says back what your code has, and when that isn’t what the page had after that input, the page starts again from your code’s state and runs its inputs since ("fix"), so a correction shows only as the difference, eased off over smooth ms. A room holds two of a player’s inputs before it starts on them, so a late packet doesn’t leave it a step without one; it says how many waited, and a page that is ahead ticks slower until the wait is gone (the longer the wait, the faster it goes: a slow start or a stall is made up in a second or two). Inputs go by two sockets, each with the two before it again, so one held up on one comes by the other, and a lost packet holds up only half of them. A missing input is never waited for: when its step comes the next one runs, and the missing one, if it arrives after, is dropped. The steps keep to real time: a stall is made up, up to four steps at once. With 200 ms of lag, a page’s inputs ran at once, the room agreed with all of them and none was played again.
Moving objects
export const tickRate = 30;
export function onStart(room) { room.data.orcs ??= [{ x: 0, z: 0 }]; }
export function onTick(room, dt) {
room.data.orcs.forEach((o, i) => {
o.x += dt; // your server moves them
room.objects.set("orc:" + i, { x: o.x, y: 0, z: o.z, ry: o.x }); // every page draws them
});
}
// room.objects.delete("orc:3") ends one; room.objects.get(key), room.objects.keys()const orcs = room.objects(); // on a three.js object, x, y, z and ry go to its position and rotation
orcs.on("add", (key, values) => orcs.bind(key, makeOrc()));
orcs.on("remove", (key) => removeOrc(key));
// in your render loop:
orcs.step();What your server moves (enemies, projectiles, a lift) goes to every page as players’ moves do, and each page draws it smoothly, as late as its updates need, on the same curve. Your code sets each by a key with its values by name; only what changed is sent, and in a room of more than 16 players each page gets those within its near every tick and the rest once a second. A page that comes in gets them all with its welcome. map puts each value on a path of your object; on a three.js object x, y, z, qx to qw and rx to rz go to its position, quaternion and rotation by themselves (anything else: the name itself). ry, yaw, angle and rotation.* turn the short way round, and a quaternion turns the short way at an even pace. objects.seen(key) with room.past(key, at) judges a hit on one as the shooter saw it. Objects live with your code, as its module’s variables do: onStart runs each time it starts (after an update, a restart, or between()), the place to set them again from room.data. Up to 1,000 a room, 32 names for their values.
Compiled engines
// node p2.mjs wasm node_modules/@dimforge/rapier3d-compat/dist/rapier.mjs room/ (writes rapier.js and rapier0.wasm)
import RAPIER from "./rapier.js";
export const tickRate = 60;
const worlds = new Map(); // one physics world a room, by room.id (rooms share the module)
async function ready(room) {
let w = worlds.get(room.id);
if (!w) {
await RAPIER.init();
const world = new RAPIER.World({ x: 0, y: -9.81, z: 0 });
w = { world, crates: [0, 1, 2].map((i) => world.createRigidBody(RAPIER.RigidBodyDesc.dynamic().setTranslation(i * 2, 5, 0))) };
worlds.set(room.id, w); // after a restart it starts again
}
return w;
}
export async function onCreate(room) { await ready(room); }
export async function onTick(room, dt) {
const { world, crates } = await ready(room);
world.step();
crates.forEach((body, i) => room.objects.set("crate:" + i, body)); // a body as it is: every page draws it
}
export function onLeave(room) { if (!room.players.length) worlds.delete(room.id); }A physics engine compiled to WebAssembly (Rapier, Box2D, Jolt) runs in your room as it does in the browser. Put your room’s code in a room/ folder at your build’s root instead of one room.js: room/room.js is where it starts, and it imports the rest by name, other modules and .wasm files (up to 10 MB all told; the folder is taken out of your build and never served, as room.js is). Hooks may be async. A .wasm file comes in as a compiled WebAssembly.Module (import engine from "./engine.wasm") for your code to instantiate: rooms compile no WebAssembly from bytes as they run, so a package that carries its WebAssembly inside it as text, as Rapier’s -compat packages do, goes through node p2.mjs wasm first, which writes it into your folder with its .wasm beside it. Stepping a world of 200 bodies takes a fraction of a millisecond; each call (a tick, a message) has 100 ms. room.objects.set(key, body) takes a body as it is (Rapier’s, or anything with a position and a quaternion, as cannon-es’s and three.js’s have): its x, y, z and quaternion qx to qw (Rapier 2D: x, y and angle), drawn smoothly on every page, so you can set every body every tick: one that hasn’t moved (asleep) is sent once more, then not until it moves. Each room’s code runs on one thread (Cloudflare gives each room one); a world spreads its players over areas, each a room of its own. On localhost the stand-in runs plain JavaScript rooms only (a physics engine in plain JavaScript, cannon-es say, runs there too).
Matching by skill
room.result([winner.id, [second.id, third.id], last.id]); // places, best first (level ones together)
room.open(false); // a match under way: no one new
room.open(true); // a player left: fill their place (backfill)const room = await p2.rooms.join({ mode: "ranked", size: 4, skill: true });Your code says how a match ended and every player’s rating moves by it (Elo, every two players compared by their places; a new player’s moves faster), kept with the game for good, as player.rating in your code and rating on each of room.players. Ratings are kept by mode: a player’s "ranked-1v1" rating and their "ranked-3v3" one are apart, and the room’s mode is the one room.result moves and skill matches by. A player’s first rated match in a mode starts from the rating they had in the game before (1500 if none), counted as new, so it finds its level in a few matches. In a team match, give the places by team: room.result([winners, losers]), each a list of ids. With { skill: true } a player is seated in the room whose players are rated nearest them, near them first, and in one a world apart in skill only when there is no other. room.open(false) keeps a match under way out of matching (and refuses a seat by its id, not-open); room.open(true) lets it fill its empty places first.
A ranked leaderboard, and trophies your server gives
export function onTick(room, dt) {
if (!matchOver()) return;
room.result([winners.map((p) => p.id), losers.map((p) => p.id)]);
for (const p of winners) {
room.score(p.id, "ranked-3v3", p.rating + 16); // on a board only your server may send to
room.trophy(p.id, "first-win"); // a trophy only your server may give
}
}Your room.js has no network of its own, so the room carries what it gives: room.score(id, board, value) sends a player’s score to one of your game’s leaderboards (kept if it is their best, as any score is) and room.trophy(id, key) gives them one of its trophies, both as your game’s own server would. They come from the room, signed by Player Two’s rooms and checked against your build’s code, so a page can’t fake them. Make the board in your studio Trusted (trusted) and the trophy Only my server gives it (serverOnly): then the page’s p2.scores.submit and p2.trophies.unlock are refused for them, and only your room code (or your own server, with a game key) can put anything there. They go to Player Two within a quarter of a second, a hundred at a time; what is refused (a board that isn’t there, a guest who has never played the game on Player Two) shows in your studio with your code’s errors.
Watching and replays
const rooms = await p2.rooms.live({ mode: "ranked" }); // the game's public rooms open now, fullest first
const watching = await p2.rooms.watch({ room: rooms[0].id }); // draw it as you draw a room you play in
const [last] = await p2.rooms.replays();
const replay = await p2.rooms.replay(last.id); // a room that plays itself
replay.replay.speed(2); replay.replay.play(); // pause(), seek(ms), time(), durationAnyone playing your game can watch a room: they hear and draw everything its players do, with no seat, and can say nothing (room.watching), up to 64 a room. A room is kept as a replay when your code calls room.record() (or its first player joins with { record: true }): what every page heard, as it happened, until room.record(false) or the room closes, up to 30 minutes, the newest 200 a game. A replay is a room that plays itself, on its own clock (room.replay is its controls: play(), pause(), speed(x), seek(ms), time(), duration, and on("end")), so the same room.sync, room.objects and room.on draw it; seek to any moment and the room is as it was then. A replay keeps what every page heard: moves, your code’s objects, the shared state, players coming and going, teams, and messages sent to everyone. A message sent to some players only ({ to }, from a page or from your code) isn’t in it, nor are the answers to inputs, so what a replay must show goes to everyone or into the shared state.
A bad connection, on purpose
p2.rooms.simulate({ lag: 150, jitter: 30, loss: 0.02 }); // 150 ms more there and back, give or take 15, 2% lost
p2.rooms.simulate(null); // as it is again
// or with no code: your page's address with ?p2-net=150,30,0.02Try your game the way a far-off player on a bad Wi-Fi plays it, as Unreal’s network emulation does: what goes between this page and its rooms waits half of lag each way, give or take half of jitter, and loss (0 to 1) of it is lost. A lost packet on a socket is held up as TCP holds it, a retransmit’s wait (200 ms, or the lag), and everything behind it with it; on the fast lane it is gone. It works on Player Two and on localhost, in this page only; room.ping() and room.stats() show it.
Timing and the network
export function onTick(room, dt) {
for (const p of room.players) {
p.ping; p.jitter // ms: their way here and back, and how much it varies (null at first)
if (p.ping > 250) room.send("lag", p.ping, { to: p.id });
}
}
export function onMessage(room, player, type, data) {
if (type === "shot") room.send("shot", { by: player.id, late: (player.ping ?? 0) / 2 }); // to every page, the shooter's too
const others = room.players.filter((p) => p.id !== player.id).map((p) => p.id);
if (type === "ready") room.send("ready", player.id, { to: others }); // to everyone but them
}Each player’s ping, in your code. Every player your room.js sees has ping, their way to the room and back in ms, and jitter, how much it varies. The room measures them itself: every 2 seconds it sends each page a probe that the SDK sends straight back, so the figure is the room’s own, over the whole way (through a match’s gates too, and with any bad connection simulated on that page), not what the page says. The first comes about half a second after a player joins, plus their round trip, and is taken as it is; each one after moves ping and jitter a quarter of the way, so a change shows most of the way within about 8 seconds. Until the first, both are null. A page whose SDK is older than the room (from before 2026-11-06) doesn’t answer the probe; the connection it reports itself every 10 seconds stands in. A new figure reaches your code with its next call (a tick, a message), never in a call of its own. One way is about half the round trip, though a route can be slower one way than the other. In a room with no code, each page has its own with room.stats() and room.ping(). On localhost, where the rooms are a browser’s own tabs, both are 0. Rooms opened before 2026-11-06 play on without them.
What leaves when. A player’s message goes to your code as it arrives, not held for a tick. Calls into your code run one at a time, in order, so a message that arrives while a tick is running waits for that tick’s call to end and goes in the next, with whatever else came meanwhile; inputs (room.inputs) are the one thing that waits for the next step. Nothing is sent while your code runs: everything one call sends a page (from a tick or from a message) leaves as one packet as the call returns (the moves’ frame may come by the page’s second socket beside it). room.send(type, data) in your code is one call however many players there are, and goes to every page, the player whose message you are answering included; pass { to }, an id or a list of them, to send to some only (at the same cost), so a player doesn’t hear their own press back. (A page’s room.send in a room with no code goes to everyone else.) What the room sends its players isn’t counted, queued, dropped or cut off by Player Two; the bounds are each call’s 100 ms of CPU and 2 seconds, the shared state’s 96 KB and 512 keys, and each page’s own connection. What a player sends is limited: 60 messages a second, bursts of 120, inputs 130 a second of their own, 16 KB each (limits).
The way there. A page’s connection goes to the Cloudflare location nearest it, then over Cloudflare’s own network to the room (a Durable Object in one location), which relays it, or hands it to your code, at once; what goes to another player goes from the room to the location nearest them and on to their page. A match of over 16 seats goes through its gates, one hop more each way. Player Two publishes no measured figure for these hops: measure your own with room.ping(), player.ping and your rooms’ connections. The sockets end at Cloudflare’s edge, where Player Two can’t set TCP options such as TCP_NODELAY; browsers send a WebSocket’s messages as they are sent, and Cloudflare doesn’t document its side. A browser can’t send UDP to a server, and Cloudflare’s Realtime data channels run only between browsers, so the quick way to the room is two sockets in turn (room.inputs, and { fast: true } messages), and the lossy lane is player to player, in rooms with no code of up to 16 (the fast lane).
Where a room runs. A room opens in the Cloudflare location the connection of the player who opened it came in by, near them, and stays there. Matching seats players in the fullest open room whose first player is within 3,000 km of them, anywhere when none is. room.between() opens a room again in one of Cloudflare’s regions, Western North America (wnam), Eastern North America (enam), South America (sam), Western Europe (weur), Asia-Pacific (apac), Oceania (oc) or the Middle East (me): the one whose furthest player is nearest, and only when that brings the furthest player at least 1,500 km and a fifth nearer than where the room is, at most once a minute. Each seat is held there 20 seconds while its page reconnects by itself.
The room’s clock. room.now() on a page is the room’s time, set the way NTP sets a computer’s: each ping (yours, room.ping(), and the SDK’s own, 50 ms after joining, four more 300 ms apart, then every 15 seconds for two minutes and every minute after) gives the room’s time plus half the round trip, and the quickest of the last 16 is the one used (a slow round trip says least about the time). The first is taken as it is; a later change is eased in at a tenth of the time that passes, so room.now() never goes back, unless it is a second or more off, when it is set at once. It is off by at most half the quickest round trip, and by a few ms on a route as quick each way. In room.js, room.now() (and Date.now()) is the time the call began: it doesn’t move while your code runs (Cloudflare’s clock moves only between a call’s waits), so every step of one call has the same time.
Worlds
const room = await p2.rooms.world("main"); // the game's world, kept for good
const cave = await p2.rooms.world("main", { area: "cave" }); // an area of it: a room of its own
room.world; // true
const sync = room.sync(me.mesh, { near: 80 }); // in a crowd: those within 80 every tickA world is a room your game keeps for good: it never closes, it sleeps for nothing while no one is in it (no ticks then), and it keeps its shared state, and in a scripted world your code’s room.data and room.store. Areas split a big world into rooms of their own (a town, a cave), each up to 256 players; players go from one to the next by leaving one and joining the other, and what you keep of each player with room.save goes with them. A world plays your live build: as you push a new one, the next player to come in moves it on (everyone in it hears close with "update", to reload, and your new code carries on with the same room.data and room.store; onCreate never runs twice), and a page still on the build before is told to reload. room.close() in a world lets its players go and keeps everything else.
In a room of more than 16 players, everyone’s moves go through the room, which sends each page the moves it hasn’t had 30 times a second in one packet: those within the page’s near every time, those further once a second, and at most 64 at a time, the longest waiting first, so a crowd costs each player what they can see. export const near = 80 in your room.js sets it for every page, so a page can’t ask to see further. Distances are measured on the fields named x and z (or y). An area’s connections, and a match’s of more than 16 seats, are held by gates in front of the room (room.gated says so) (one for each 16 players of a match, eight for an area), so the room sends and hears a few packets a tick, not one for every player: 256 players moving 30 times a second each kept their updates coming 30 times a second, a move from one to a player near them about 95 ms.
Voice chat
await room.voice.join(); // asks for the microphone; then everyone in voice hears you
room.on("speaking", (id, on) => glow(id, on));
room.voice.mute(true); // or push to talk: mute(false) while a key is held
room.voice.isMuted(); // whether you are
room.voice.speaking(id); // whether they are talking now
room.voice.volume(id, 0.3); // how loud one player is, to you
room.voice.viaParty(id); // whether they reach you through Player Two's party voice instead
await room.voice.join({ mic: stream }); // or a microphone your page has already (a MediaStream)
room.voice.leave();Voice goes from each player’s microphone to everyone else in voice in the room, through Cloudflare’s Realtime network close to every player, in any room (scripted ones and worlds too), up to 24 in voice a room. The browser asks the player for their microphone the first time; join fails with no-mic when they say no. room.voice.who is who is in voice, and "voice" (id, in or out) and "speaking" (id, on or off) say when that changes; each player can turn anyone down to nothing on their own page. join({ mic }) sends a MediaStream your page has already instead of asking (the room stops it when the player leaves voice: give it a copy, new MediaStream(stream.getAudioTracks().map((t) => t.clone())), to keep your own). Voice needs a room on Player Two; on localhost join says no-voice.
Blocks: two players either of whom blocked the other on Player Two never hear each other in your rooms (rooms from r17, and the worlds): neither is subscribed to the other’s voice, though both stay in the room and in room.voice.who, and nobody is told who blocked whom. Everything else in your room (players, messages, the state) is as before.
Player Two’s own voice: a player can be in their party’s voice (in the chat’s Party panel) while they play your game: it’s Player Two’s, on its page, not your room’s, and your game hears nothing of it. Nobody is heard twice: while a player reaches another through their party’s voice (both of them in it, the speaker’s microphone on there, the listener not deafened and not having turned them down to nothing there), your room’s voice of the one is silent to the other, on that player’s page only; the moment that stops (either leaves the party’s voice, mutes or deafens there, or on push-to-talk lets go of V), they’re heard in your room again, 300 ms later so the end of what they said isn’t doubled. You do nothing for this: they stay subscribed and in room.voice.who, "speaking" still says when they talk, as their party’s voice hears them (your name tags light up as ever), and your own volume() for them is kept for after. room.voice.viaParty(id) says whether it’s so for a player now (to show a party mark, or to explain a slider with no effect); Player Two doesn’t tell your game who is in a party or its voice beyond that. The party’s voice is the party’s, whatever your teams: a party split across your teams still hears each other through it. On push-to-talk the player holds V to talk; your game’s frame has the keyboard, so the SDK in your page tells Player Two when V goes down and up (not while they type in a field of yours). Your game gets the key as well, as ever: if V does something in your game, it happens as they talk.
Events
| Event | With | When |
|---|---|---|
join | player | Someone came in. |
leave | player, why | Someone left ("left") or didn't come back in time ("dropped"). |
away | player | Someone's connection dropped: their seat is held 20 seconds. |
back | player | They came back to it. |
host | id | The host changed. |
team | id, team | The room's code moved a player to another team (room.team). |
state | key, value, by | A key changed (null: taken away). |
message | type, data, from | Any message; message:<type> hears one type. |
reconnecting | Your own connection dropped: the SDK is getting your seat back. | |
reconnected | It did; players and state are as the room has them now. | |
moved | id | The room's code opened it again between far-apart players (room.between()), and you're in it: room.id is new. |
voice | id, in | Someone came into voice (true) or left it. |
speaking | id, on | Someone in voice (or you) started speaking or stopped. |
close | why | You're out: you left, the room closed, or your seat couldn't be got back ("update": a world moved on to your game's newer build; reload). |
Releases
A room is for one build of your game. A page on your new build is only ever seated with others on that build, and one still on the build before keeps playing with its own, so a release never mixes versions in a room: matches running when you push play on until they end. A page that asks for a seat on a build Player Two no longer keeps is told to reload (old-build).
On localhost, without Player Two, the SDK’s rooms are this browser’s other tabs: open your game in two tabs and they are two players.
Your rooms' connections
curl https://playertwo.lol/api/v1/agent/games/your-game/rooms -H "Authorization: Bearer p2_agent_..."
# or the MCP tool get_rooms with { "slug": "your-game" }
# { slug, on, multiplayer, stopped, hosted, script, error, units,
# now: { rooms, players, builds: [{ build, rooms, players }],
# net: { rooms, ping, p90, jitter, down, up, fixes, rewinds, drops },
# top: [{ mode, players, watchers, build, at, pages, ping, p90, jitter, down, up, fixes, rewinds, drops }] } }Your studio’s Rooms panel (in your game’s services) shows your game’s rooms and players now, by build, and how their connections were over the last minute: ping (the middle page’s, and p90, nine in ten at or under it, in ms there and back), jitter, data a second a page each way (down from the room, up to it, in bytes), corrections a minute a page (fixes: a move your code put right) and replays of a page’s own inputs (rewinds: started again from your code’s state), and connections dropped in the minute; for all your rooms (each room’s figures weighed by its players) and the five busiest. Your agent reads the same with your agent key, with the eight busiest rooms and what each figure is in (units): the MCP tool get_rooms, or GET /api/v1/agent/games/<slug>/rooms, read-only, 60 a minute. The figures are what each page measures of its own connection (its room.stats()) and says every 10 seconds; a room passes them on every 30 seconds at most, so they lag by up to about 40 seconds, and a room whose pages stop reporting drops out of them after two minutes. net is null until a page has reported, and now when the rooms service didn’t answer in time. player.ping in your code is the room’s own measure (above).
Limits
| What | Limit |
|---|---|
| Players | 1 to 64 a room; 256 an area of a world |
| Messages | 16 KB each; 60 a second a player, bursts of 120 (inputs: 130 a second of their own) |
| What the room sends | no limit of its own: a scripted room's code sends what it likes each call, as one packet to each page as the call returns (30 snapshots a second and one-off messages beside them all arrived, in order), bounded only by its 100 ms of CPU and 2 s a call; nothing of it is counted, queued, dropped or cut off by Player Two |
| player.ping, player.jitter | measured by the room every 2 s, in room.js; from an older page, its own report every 10 s |
| Shared state | 96 KB and 512 keys a room |
| A dropped seat | held 20 seconds |
| A room | closes a minute after its last player leaves, or after 6 hours; a world never |
| Seats | 30 a minute a session; 500 rooms open at once a game |
| room.js | 1 MB, or a room/ folder of 10 MB with .wasm; 100 ms of CPU a call (a tick, a message); 1 to 60 ticks a second; no network |
| room.save | 64 KB a player, kept for good |
| room.objects | 1,000 a room, 32 names for their values |
| Voice | 24 in voice a room |
| Watching | 64 a room |
| Replays | 30 minutes each, the newest 200 a game |
| room.score, room.trophy | 500 waiting a room, sent a hundred at a time; 3,000 a minute a game |
A room’s messages to its players aren’t counted against anything: the limits above are on what a player sends. What does cost is size: each page gets the bytes your code sends it, so 30 snapshots a second of 1 KB each is 30 KB a second a player, and one player’s slow connection holds up only their own. Matches of over 16 seats go through the room’s gates, which send on what the room says in one call a turn, with no limit of their own either.
| Code | When |
|---|---|
no-such-room | The room asked for by its id or code has closed, or never was. |
full | Every seat in the room is taken (or, to watch, 64 watch it already). |
not-open | Its code closed it to matching (room.open(false)): no one new by its id. |
other-build | The room is for another version of the game. |
not-in-party | It is a party's own room, and the player isn't in that party. |
no-party | party or together, and the player isn't in a party. |
party-too-big | together, and the party has more members than a team (or the match) has seats. |
bad-room, bad-code, bad-teams, bad-ask | What was asked for isn't right: a room of another game, a code that isn't six letters, a size teams don't split evenly, together with a room, code or world. |
old-build | The page's build is no longer kept (reload). |
too-many-rooms | The game has 500 rooms open already. |
rooms-off, rooms-hosted-only, rooms-stopped | The game's multiplayer isn't Player Two's rooms, it isn't served by Player Two, or Player Two stopped its rooms. |
offline | The room couldn't be reached. |
Your studio shows your game’s rooms and how their connections are (above; your agent reads them with get_rooms), and turns rooms off (rooms open then play on until they empty).
Your game's server
Game keys
Make a key for your game’s own server in your studio (Game keys). It starts p2g_, is shown once, and only a hash of it is kept; revoke it there at any time. Keep it on your server: never in your game.
Its calls
curl https://playertwo.lol/api/v1/sdk/server/session \
-H "Authorization: Bearer p2g_..." \
-H "x-player-session: p2s_..."
# { live, player: { id, handle, name, picture, level }, game, session, test, demo, from, startedAt, beatAt }curl -X POST https://playertwo.lol/api/v1/sdk/server/trophies/tournament-winner \
-H "Authorization: Bearer p2g_..." -H "content-type: application/json" \
-d '{ "player": "<the player's id for your game>" }'curl -X POST https://playertwo.lol/api/v1/sdk/server/boards/ranked \
-H "Authorization: Bearer p2g_..." -H "content-type: application/json" \
-d '{ "player": "<the player's id for your game>", "value": 1840 }'curl "https://playertwo.lol/api/v1/sdk/server/addons?player=<the player's id for your game>" \
-H "Authorization: Bearer p2g_..."
# { player: { id }, addons: ["night-map", ...] } (or send x-player-session in place of ?player=)curl -X POST https://playertwo.lol/api/v1/sdk/server/identity \
-H "Authorization: Bearer p2g_..." -H "content-type: application/json" \
-d '{ "token": "<from p2.identity()>" }'
# { valid, player: { id, handle, name, picture, level }, kind, live, exp }Your game’s client gets its session token only on a computer (the app hands it to the game); on the web it stays in Player Two’s page, so a web game’s server learns who is playing from an identity the client sends it (p2.identity(), checked against Player Two’s keys or with the call above), and names players by their id for the game.
The store’s calls (charges, what a player has, consumables used up, receipts checked): In-game purchases.
In-game purchases
Sell inside your game through Player Two's payments. Your shop, its look and your whole buying flow are yours; Player Two's own card takes the player's yes, the money comes from their Player Two balance, and you get 80%.
Two ways to sell
| Charges | Products | |
|---|---|---|
| Who sets what and how much | Your server, each time, by your own rules | You, ahead, in your studio or through your agent |
| Your game asks with | p2.store.pay(id) | p2.store.buy(key) |
| Who keeps what the player has | Your game (Player Two keeps the payment) | Player Two (p2.store.owned(); your server uses up consumables) |
| Price | $0.10 to $500, to the cent | $0.99 to $500 |
| What it can be | Anything: coins, a bundle, a season pass, a price you lowered for this player | A consumable, item, skin, mod or pack (below) |
| You need | A server of your own, with your game key | Nothing more: it works from the game alone |
| Player Two keeps | 20% | 20% |
Both use the same card, the same balance, the same receipts, the same webhook and the same test mode, and a game can use both. A game with a server of its own that wants its own rules uses charges; a game without one uses products.
Charges: your shop, your prices
- 01Your server makes itWith your game key: what (a name the player reads), how much, for which player, and your own reference.
- 02Your game asks to be paidIt hands the id to
p2.store.pay. Player Two’s card shows the name and price; the player holds to pay. - 03Player Two takes the moneyFrom their Player Two balance, once. Your game gets the answer and a signed receipt; your server gets the purchase webhook.
- 04Your server gives itOnce for each charge, after checking it's paid.
curl -X POST https://playertwo.lol/api/v1/sdk/server/store/charges \
-H "Authorization: Bearer p2g_..." -H "content-type: application/json" \
-d '{ "player": "<the player's id for your game>", "name": "5,000 coins", "about": "For the locker room shop.", "price": 4.99, "ref": "order-8812" }'
# 201 { charge: { id, name, about, price, ref, player: { id }, state: "open", test: false, payment: null, created, expires, paid: null } }const { id } = await myServer.post("/shop/coins"); // your server made the charge
const paid = await p2.store.pay(id);
if (paid.ok) myServer.post("/shop/paid", { receipt: paid.receipt }); // or let the webhook tell it
else if (paid.reason === "cancelled") closeShop(); // they said not now
// paid: { ok: true, charge: { id, name, ref }, amount, receipt, exp, test }curl https://playertwo.lol/api/v1/sdk/server/store/charges/<id> -H "Authorization: Bearer p2g_..."
# { charge: { ..., state: "paid", payment: "<the payment's id>", test: false, paid: 1793890000000 } }- The player is named by their id for your game: from an identity your client sends (
p2.identity(), checked with/api/v1/sdk/server/identity), or by the session token a computer’s client sends, inx-player-sessionin place ofplayer. A guest (a player in a card on X without an account) has no id a server can charge. - They must have the game: bought, added free, given, a key or a backer pass. A player on a free trial of a paid game can’t pay until they buy it (409
no-game). A player whose latest session is a test (your team, your testers) may. - The price is $0.10 to $500 in US dollars, rounded to the cent. The name is 2 to 60 characters and about up to 280: the card shows both. ref is up to 200 characters of your own (an item, an order, a cart): it comes back exactly as given with the charge, its receipt and its webhook.
- Open for 30 minutes, then
expired: it can’t be paid from then on (a payment the player started before still counts). Make a new one for a new try. - Paid at most once. Make one for each purchase.
p2.store.payon a charge already paid answers its receipt again, so a game that lost the answer can ask again. - Withdraw an open one with
POST /api/v1/sdk/server/store/charges/<id>/cancel: it can’t be paid from then on, andp2.store.payrejectswithdrawn. A payment that was already on its way finds it withdrawn and goes straight back to the player’s balance. A paid charge can’t be withdrawn (409paid). - Its state is
open,paid(with its payment, ortest: true),cancelledorexpired. - A player’s charges, newest first, come from
GET /api/v1/sdk/server/store/charges?player=<id>: those paid if you don’t say (stateopen, cancelled, expired or all for others), up to 100 a page,before=the answer’snextfor more. So your server needn’t keep its own record of what each player bought: yourrefon each says what it was. - Before your game is out (kept private, or coming on a day), charges can be made, and only test sessions can pay them. A game sells once it is in the store; a raise without a price can’t (409
not-in-store). - Your server learns only the player’s id for your game and what they paid: never their balance or who they are.
Products: made in your studio
| Kind | What it is |
|---|---|
consumable | Bought again and again; each purchase gives its number, 1 to 1,000. Player Two keeps the count, and your server uses them up. |
item | The player's for good, bought once. |
skin | The player's for good, bought once. |
mod | The player's for good, bought once: a change to how the game plays. |
pack | 2 to 10 of the game's other in-game products at once (no add-ons or other packs); a consumable in it gives its number. It can't be bought once everything in it is theirs. |
dlc | An add-on: not in your game’s shop but on its page, split as its sales are (10% Player Two’s). See Add-ons. |
- Make them in your studio (each game’s card, Add-ons and in-game products), or through your agent:
POST /api/v1/agent/games/<slug>/products, or the MCP toolsdefine_productandlist_products. - A key is what your game asks for it by: small letters, numbers and dashes, up to 40, starting with a letter or number. A name is 2 to 60 characters, about up to 280. The price is $0.99 to $500. A game has up to 100 products, add-ons and in-game together.
- Your game names a product by its key, never a price: the price is the one you set.
- Once a player has one, its key and kind stay. Its name, about, price, a consumable’s number and a pack’s contents can change. Retire one to take it off sale: its owners keep it. Products are never deleted.
- A skin, item or mod the player has can’t be bought again:
p2.store.list()saysowned: true, andp2.store.buyrejectsowned. - Only a player who has the game can buy (
no-gameotherwise), and a game has products once it is in the store.
Player Two's step
When your game calls p2.store.buy or p2.store.pay, its frame steps back to one side, dimmed, and Player Two’s card stands beside it. It shows Player Two, your game’s name and what kind of purchase it is; the name and line about it; its price; and the player’s balance now and after. The player holds Hold to buy for under a second (a tap never buys; Enter does too), or presses Not now (or Escape). On a phone the card sits at the foot of the screen. A test session’s card says “A test buy · nothing is paid”. When their balance is too low, the card offers to add funds right there, and goes on once they arrive. Anything that stops the payment itself (payments paused on Player Two, the player’s balance not ready) is said on the card; when they close it, your game hears cancelled.
- While the card is open, your game gets no clicks or touches but keeps running. Pause it, or sell only where nothing happens (between rounds, in a menu).
- One card at a time: a second
buyorpaymeanwhile rejectsbusy. - Your promise waits as long as the player takes, adding funds included, up to 20 minutes (then it rejects
timeout). Don’t time it out yourself. - Then your game is back, and shows the purchase its own way.
Staying right
- Products: read
p2.store.owned()(or, from your server,GET /api/v1/sdk/server/store) when your game starts and after any answer that isn’tok, and show what it says. - Charges: your server gives on its own word: the purchase webhook, a charge read as
paid, or a receipt your client sends. Give each charge once, its id the key. Read the state of charges you made whose answer never came. A server that keeps nothing of its own can ask for everything a player has paid for, each with yourref:GET /api/v1/sdk/server/store/charges?player=<id>. - Consumables: their count is Player Two’s. Use them up with
POST /api/v1/sdk/server/store/use; don’t keep a count of your own. - Anything a player mustn’t fake: decide it on your server. A player can change what your game says it has, but not a receipt, a webhook or your server’s own reads.
- A webhook can come more than once (its
x-p2-deliveryid is the same each time): handle each payment once.
Receipts
Every purchase hands your game a receipt for your server: a JWT typed p2-buy, signed RS256 with Player Two’s keys at /.well-known/p2-keys.json (the same keys as identities and launch tickets), good for an hour. Check it yourself, or send it to POST /api/v1/sdk/server/store/receipt. After its hour, read the charge or what the player has instead.
| Claim | What it is |
|---|---|
iss | https://playertwo.lol |
aud | Your game's slug: a receipt for another game is refused (wrong-game). |
typ | p2-buy: an identity is never a receipt, and a receipt never an identity. |
sub | The player's id for your game. |
kind | A product’s kind, or charge. |
key | A product's key, or a charge's id. |
n | How many it gave: a consumable's number, 1 otherwise. |
count | A consumable's: how many the player has now. |
ref | A charge's: your reference, as your server gave it. |
pay | The payment's id; null for a test. |
test | A test purchase: nothing was paid. |
sid | The play session it was bought in. |
iat · exp | When it was signed, and an hour later (seconds). |
The check answers { valid: true, player: { id }, product: { key, kind, n }, count?, payment, amount, test, at, exp }, for a charge { valid: true, player: { id }, charge: { id, name, ref }, payment, amount, test, at, exp }, or { valid: false, code, why } with code malformed, unknown-key, bad-signature, expired, wrong-game, not-paid or not-a-receipt.
Your server's calls
Each with your game key (Authorization: Bearer p2g_..., made in your studio). The player is player, their id for your game, or their session token in x-player-session.
curl -X POST https://playertwo.lol/api/v1/sdk/server/store/charges \
-H "Authorization: Bearer p2g_..." -H "content-type: application/json" \
-d '{ "player": "<id>", "name": "Season pass", "price": 9.99, "ref": "season-4" }'
# 201 { charge } (404 unknown-player, 409 no-game or not-in-store, 422 bad-name or bad-price)curl "https://playertwo.lol/api/v1/sdk/server/store/charges?player=<id>" -H "Authorization: Bearer p2g_..."
# { player: { id }, charges: [{ id, name, price, ref, state: "paid", payment, paid, ... }], next, test }
# newest first; ?state=paid (if not said), open, cancelled, expired or all; ?limit= up to 100; ?before=<next> for the page aftercurl https://playertwo.lol/api/v1/sdk/server/store/charges/<id> -H "Authorization: Bearer p2g_..."
# { charge: { id, name, about, price, ref, player: { id }, state, test, payment, created, expires, paid } } (404 unknown-charge)curl -X POST https://playertwo.lol/api/v1/sdk/server/store/charges/<id>/cancel -H "Authorization: Bearer p2g_..."
# { charge: { ..., state: "cancelled" } } (409 paid; asked again, the same answer)curl "https://playertwo.lol/api/v1/sdk/server/store?player=<id>" -H "Authorization: Bearer p2g_..."
# { player: { id }, owned: [{ key, kind, count?, test? }], test } (test purchases count if their latest session is a test)curl -X POST https://playertwo.lol/api/v1/sdk/server/store/use \
-H "Authorization: Bearer p2g_..." -H "content-type: application/json" \
-d '{ "player": "<id>", "key": "dunk-boost", "n": 1 }'
# { player, key, used, count } (409 not-enough with their count, 409 not-consumable, 404 unknown-player)curl -X POST https://playertwo.lol/api/v1/sdk/server/store/receipt \
-H "Authorization: Bearer p2g_..." -H "content-type: application/json" \
-d '{ "receipt": "<from p2.store.buy() or p2.store.pay()>" }'
# { valid, player: { id }, product | charge, payment, amount, test, at, exp }The purchase webhook
Your studio’s webhooks hear every purchase as the event purchase, test ones too, signed as every event is:
{ "event": "purchase", "at": "2026-11-05T13:20:00.000Z",
"game": { "slug": "your-game", "title": "Your Game", "url": "https://playertwo.lol/game/your-game" },
"amount": 4.99, "charge": { "id": "<charge id>", "name": "5,000 coins", "ref": "order-8812" },
"player": "<the player's id for your game>", "payment": "<payment id>", "test": false }{ "event": "purchase", "at": "...", "game": { ... },
"amount": 1.99, "product": { "key": "dunk-boost", "kind": "consumable", "name": "Dunk boost", "qty": 10 },
"player": "<the player's id for your game>", "payment": "<payment id>", "test": false }A test purchase has "test": true, "amount": 0 and no payment: leave it out of your accounts.
Testing
- You, your team and your testers play in test sessions (
p2.test), and buy and pay for nothing: the card says it’s a test, the receipt and webhook saytest: true, and nothing is paid or split. - Products bought as a test are seen only by test sessions. A real purchase takes a test one’s place. A test can’t add to real consumables (
owned). A player’s charges paid as tests are listed only while their latest session is a test. - Before your game is out, only test sessions can buy or pay.
- Building without Player Two, the stand-in takes
local: { products: [{ key, kind, name, price, qty }] }and buys at once, as a test.p2.store.pay(id)pays any id at once, as a test (the stand-in can’t know your server’s charges, so its name is empty).
The money
- You get 80% of every in-game purchase, products and charges alike, less your backers’ share if your game raised with a share of its sales; Player Two keeps 20%. A $4.99 charge: $0.998 Player Two’s, $3.992 yours. Add-ons on your game’s page are split as its sales (10% Player Two’s).
- Players pay from their Player Two balance, in USDC on Base; Player Two pays the network fees. It’s paid out with your game’s sales: claim it in your studio. Your studio shows what each product sold and made, and your charges paid and what they made.
- In-game purchases aren’t refunded. A payment that lands on something the player already has (a skin bought twice at once) or on a charge you withdrew goes straight back to their balance.
- Nothing is sold to a guest: every store call rejects
guestin a card on X. - On a computer, in the Player Two app, buying isn’t there yet:
buyandpayrejectno-store. Your server can still read what a player has and use up their consumables.
Refusals
| Code | Status | From | What it means |
|---|---|---|---|
no-store | 409 | buy, pay | Not in Player Two's player on the web (the app on a computer, or a page without Player Two). |
busy | 409 | buy, pay | Player Two's card is open already. |
guest | 403 | every call | A guest, in a card on X: nothing is sold to them. |
no-game | 409 | buy, pay, a charge | The player doesn't have the game yet (a free trial isn't having it). |
unknown-product | 404 | buy | Nothing on sale by that key (or it's retired). |
owned | 409 | buy | A skin, item or mod they have; for a test, real consumables. |
unknown-charge | 404 | pay, a charge | No charge by that id for this player in this game. |
expired | 409 | pay | Not paid within 30 minutes: make a new one. |
withdrawn | 409 | pay | Your server cancelled it. |
paid | 409 | cancel | It's paid: it can't be withdrawn. |
not-in-store | 409 | a charge | The game isn't in the store (archived, taken down, or a raise without a price). |
bad-name · bad-price | 422 | a charge | A name of 2 to 60 characters; a price of $0.10 to $500. |
| (none) | 400 | your server | A field of the wrong type or too long (a charge's about over 280, its ref over 200): the answer's issues say which. |
unknown-player | 404 | your server | No player of this game has that id. |
bad-state | 422 | a player's charges | state is paid, open, cancelled, expired or all. |
not-enough · not-consumable | 409 | use | They have fewer (count says how many); or it isn't a consumable. |
timeout | buy, pay | No answer in 20 minutes. | |
offline | buy, pay | Player Two couldn't be reached. | |
busy | 429 | every call | Past the limits below: wait a moment (your server gets a plain 429). |
Limits
| What | Limit |
|---|---|
| Reading (list, owned, a charge) | 240 a minute per session |
| Receipts | 60 a minute per session |
| Test buys | 60 a minute per session |
| Charges made | 60 a minute for one player, 1,200 a minute for the game |
| A player's charges read | 240 a minute for one player, 100 a page |
| Consumables used | 600 a minute for one player |
| A game key | 1,200 requests a minute |
| Products | 100 a game; $0.99 to $500 |
| Charges | $0.10 to $500; open 30 minutes; paid once |
On a computer
The local bridge
The Player Two app starts your game with three settings in its environment: P2_BRIDGE (a local address with a secret, alive while the game runs), P2_TICKET (its launch ticket) and P2_GAME (its address on Player Two). The same calls go to the bridge as JSON over HTTP: POST ${P2_BRIDGE}/call with { "method": "trophies.unlock", "args": ["first-win"] }, answered as on the web. A game made with web technology on a computer passes the address to init({ bridge }), or sets P2_BRIDGE on globalThis.
Launch tickets
Each time the app starts a game it asks for a launch ticket: a JWT signed RS256 by Player Two, naming the player (their id for your game), the game, the build, the computer and when it runs out, 14 days on, so the game starts offline for two weeks. Player Two’s public keys are at /.well-known/p2-keys.json. Check the ticket and refuse to start without one, or mark your game DRM-free.
Checking tickets in your engine
The helpers for Unity (C#), Godot (GDScript) and plain HTTP come with the Player Two app; this part will show how to use them.
The requests underneath
Each call becomes one request to Player Two, made by the player’s host with the play session (Authorization: Bearer p2s_...). They answer any site, without cookies.
| Call | Request |
|---|---|
| me() | GET /api/v1/sdk/me |
| trophies.list() | GET /api/v1/sdk/trophies |
| trophies.unlock(key) | POST /api/v1/sdk/trophies/<key> |
| stats.get() · set · add | GET · POST /api/v1/sdk/stats { set } | { add } |
| scores.submit · list | POST · GET /api/v1/sdk/boards/<key> |
| saves.list() | GET /api/v1/sdk/saves |
| saves.get · put · delete | GET · PUT · DELETE /api/v1/sdk/saves/<slot> |
| presence.set(line, { join }) | POST /api/v1/sdk/beat { status, join } |
| identity() | GET /api/v1/sdk/identity |
| invite(params) | POST /api/v1/sdk/invite { params } |
| loading.done · gameplay.start · stop | POST /api/v1/sdk/event { event } |
| rooms.join · create · party | POST /api/v1/sdk/rooms/ticket { mode, size, code, create, party } |
| addons.list() · addons.has(key) | GET /api/v1/sdk/addons · GET /api/v1/sdk/addons/<key> |
| store.list() · store.owned() | GET /api/v1/sdk/store · GET /api/v1/sdk/store/owned |
| store.buy(key) | Player Two’s card, then POST /api/v1/sdk/store/receipt { payment } (a test: POST /api/v1/sdk/store/test { key }) |
| store.pay(id) | GET /api/v1/sdk/store/charges/<id>, Player Two’s card, then POST /api/v1/sdk/store/receipt { payment } (a test: POST /api/v1/sdk/store/test { charge }) |
| items.list() | GET /api/v1/sdk/items |