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
PlayerTwo.env // "p2" | "local" | "disabled"The 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 wallet.
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”.
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.
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 gameWhen 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. |
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: p2.test is true. 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.
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 -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.
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 } |