Turning it on
In your studio, open your game’s card and set Player Two Connect to On. Player Two then talks to your game at the origin of the address where it plays (its “Where it plays”), and nowhere else; change that address, and the origin follows. Add one line to your game’s page and call PlayerTwo.init(), as a hosted game does:
<script src="https://playertwo.lol/sdk/v1.js"></script>
<script>
PlayerTwo.init()
.then(async (p2) => {
// in Player Two's player: who is playing, signed for your servers
const token = await p2.identity();
await fetch("https://api.yourgame.com/auth/player-two", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ token }),
});
})
.catch((e) => {
// opened on your own site (e.code is "not-on-player-two"): your own sign-in, as always
});
</script>With Connect on, everything in your studio’s services works for your game as for a hosted one: trophies, leaderboards, game keys and test mode, and in the game every call of the SDK.
Where it works
- In Player Two’s player (
PlayerTwo.envis"p2"): your game runs in a frame, at its own address. Players get there from your game’s page on Player Two, a join link, or a friend’s Join. - At its own address, or framed by any other site (
"disabled"):init()rejects withnot-on-player-twoat once, and your game runs as it always has, with your own sign-in. - On localhost (
"local"): the stand-in answers every call from memory. Itsp2.identity()is an unsigned token (alg“none”,iss“local”) that no check against Player Two’s keys accepts; itsp2.invite()is a link to your own page with?p2join=, which it reads back asp2.params(or give itinit({ local: { params } })). - The address where it plays must be https. Only an address on your own computer (localhost, 127.0.0.1) may be http, to try a build there.
The handshake
Your game and Player Two’s page find each other with postMessage, each checking who the other is, and then talk over a private channel:
- The SDK says hello only to a Player Two page: playertwo.lol, demo.playertwo.lol, or the Player Two the SDK file came from. It reads the page around it from
location.ancestorOrigins, or fromdocument.referrerwhere a browser has no such list, and addresses its hello to exactly that origin. Framed by anything else, it says nothing. - Player Two’s page answers a hello only when it comes from your game’s own frame (
event.source) and from the origin your game plays at (event.origin), and addresses every answer to that origin, never to"*". A page your game moves to at another origin isn’t answered; the console says why. - Its answer carries who is playing (
p2.player), the params of the invite they came by (p2.params), and aMessageChannelport. Every call after that goes over the port, each with its own nonce. The play session’s token stays in Player Two’s page: your game never holds it, and doesn’t need it. - When there’s no session to give,
init()rejects with a code:signed-out(the player isn’t signed in),not-yours(they haven’t added or bought it),not-connected(Connect is off). Play on as a guest. - The frame may go full screen, use gamepads, play sound, open popups (outside the sandbox), write to the clipboard and share. It can’t send Player Two’s page anywhere.
The identity
p2.player tells your game’s client who is playing, and the client is the player’s: anything in it can be changed. For your servers, ask Player Two to sign it:
const token = await p2.identity(); // a JWT, good for ten minutes: ask again for a fresh one
// send it to your server, which checks it (below) before it trusts who it namesIt is a JWT signed RS256 with Player Two’s key. The public keys are at /.well-known/p2-keys.json: find the one named by the token’s kid (while Player Two changes keys, the set has both). Hosted games can ask for one too.
What it says
| Claim | What it is |
|---|---|
iss | https://playertwo.lol |
aud | Your game's slug, its address on Player Two (playertwo.lol/game/<slug>). An identity for another game is no identity for yours. |
sub | The player's id for your game: the same every time, different in every other game. Player Two's server calls name players by it. |
typ | p2-id. Launch tickets and licenses are signed with the same key: check the type, and neither ever passes. |
h, name, pic, lvl | Their handle, their name, their picture's address (or null) and their level, as their public profile shows them. |
kind | full (the game is theirs), demo, or test (you, or Player Two’s admins). |
sid | The play session it came from. |
iat, exp | When it was made, and when it runs out: ten minutes on. |
Checking it with jose
import { createRemoteJWKSet, jwtVerify } from "jose";
const PLAYER_TWO = createRemoteJWKSet(new URL("https://playertwo.lol/.well-known/p2-keys.json"));
/** The Player Two player behind an identity your game's client sent; throws if it isn't good. */
export async function playerTwo(token) {
const { payload } = await jwtVerify(token, PLAYER_TWO, {
issuer: "https://playertwo.lol",
audience: "your-game", // your game's address on Player Two: playertwo.lol/game/your-game
algorithms: ["RS256"],
});
if (payload.typ !== "p2-id") throw new Error("That isn't a Player Two identity.");
return { id: payload.sub, handle: payload.h, name: payload.name, picture: payload.pic, level: payload.lvl, kind: payload.kind };
}With node:crypto
import { createPublicKey, createVerify } from "node:crypto";
const KEYS = "https://playertwo.lol/.well-known/p2-keys.json";
let keys = [], fetched = 0;
/* Player Two's public keys, fetched again after an hour, or for a key it doesn't know yet (at most once a minute) */
async function keyFor(kid) {
const age = Date.now() - fetched;
if (age > 3_600_000 || (!keys.some((k) => k.kid === kid) && age > 60_000)) {
keys = (await (await fetch(KEYS)).json()).keys;
fetched = Date.now();
}
const k = keys.find((x) => x.kid === kid);
return k ? createPublicKey({ key: { kty: k.kty, n: k.n, e: k.e }, format: "jwk" }) : null;
}
/** The Player Two player behind an identity for `game` (your game's address on Player Two); throws if it isn't good. */
export async function playerTwo(token, game) {
const [head, body, sig] = String(token).split(".");
const h = JSON.parse(Buffer.from(head ?? "", "base64url").toString());
const key = h.alg === "RS256" ? await keyFor(h.kid) : null;
if (!key || !sig || !createVerify("RSA-SHA256").update(`${head}.${body}`).verify(key, Buffer.from(sig, "base64url"))) {
throw new Error("Player Two didn't sign that.");
}
const c = JSON.parse(Buffer.from(body, "base64url").toString());
if (c.typ !== "p2-id" || c.iss !== "https://playertwo.lol" || c.aud !== game) throw new Error("That isn't an identity for this game.");
if (c.exp * 1000 <= Date.now()) throw new Error("That identity has run out.");
return { id: c.sub, handle: c.h, name: c.name, picture: c.pic, level: c.lvl, kind: c.kind };
}Or ask Player Two
If you’d rather not check JWTs, your server asks with its game key (your studio: Game keys). The answer also says whether the player is still on Player Two, and whether the session is still being played.
curl -X POST https://playertwo.lol/api/v1/sdk/server/identity \
-H "Authorization: Bearer p2g_..." -H "content-type: application/json" \
-d '{ "token": "<the identity your client sent>" }'
# good: { "valid": true, "player": { "id", "handle", "name", "picture", "level" },
# "kind": "full", "game": { "slug" }, "session", "live": true, "exp": 1792557000000 }
# not good: { "valid": false, "code": "expired", "why": "...", "player": null, "kind": null }Carrying accounts over
One more login
Treat a Player Two identity as one more way to sign in, like a Google or a Privy one: your server checks it, then finds the account linked to the login p2:<sub>, or links one. A guest who played before signing in is folded into it, as a guest is into any login’s account. Everything your accounts already hold (progression, items, currency) comes with it.
// your server: the game's client sends its identity (and the guest token it kept in the frame, if any)
app.post("/auth/player-two", async (req, res) => {
const p2 = await playerTwo(req.body.token); // throws: answer 401
const login = `p2:${p2.id}`; // one more login, like a Google or Privy one
const guest = req.body.guest ? accounts.byToken(req.body.guest) : null;
let account = accounts.byLogin(login);
if (!account) {
// a first sign-in: the guest who has been playing becomes the account, or a new one starts
account = guest && !guest.logins.length ? guest : accounts.create({ name: p2.name });
accounts.link(login, account);
} else if (guest && guest !== account && !guest.logins.length) {
accounts.fold(guest, account); // as a guest is folded into any login's account
}
res.json({ session: accounts.sessionFor(account) }); // your own session, used as it is today
});The id is per game: the same player has a different sub in each of your games. Link each game’s own.
Browser storage in the frame
In Player Two’s player your game is a frame from another site, so the browser gives it a storage box of its own, apart from your site’s. Chrome, Edge and Firefox keep a framed site’s storage by the page around it; Safari blocks its cookies, and clears what it keeps after seven days without a visit. So a player who has played on your site arrives in the frame with nothing your site kept in their browser (a guest token in localStorage, a cookie), and a sign-in that redirects (Google, X, Discord) breaks: those pages refuse to be framed, and the frame can’t send Player Two’s page away.
- Keep progress on your server, keyed by the account, and sign the player in with their identity. Then nothing depends on the browser.
- Or in Player Two’s cloud saves (
p2.saves, 8 slots of 1 MB), which follow the player wherever they play. - The frame’s own storage still keeps what can be lost (settings, a cache), for your game inside Player Two only.
- A sign-in of your own inside the frame opens in a popup on a click (
window.open); a redirect never works there. - For players whose account lives in their browser on your site: a one-time linking popup.
Linking a browser's account
A popup is a page of its own, so on your site it sees your site’s storage. The game in the frame opens one with the player’s identity; the popup sends it, with the account the browser kept, to your server, which links the two once.
// in the frame, on a click (browsers block a popup opened any other way)
linkButton.onclick = async () => {
const token = await p2.identity();
window.open(`https://yourgame.com/link-player-two#token=${token}`, "link", "popup,width=480,height=640");
};// https://yourgame.com/link-player-two: a page of your own site, top level, so it sees your site's storage
const token = new URLSearchParams(location.hash.slice(1)).get("token");
history.replaceState(null, "", location.pathname); // the token leaves the address bar
const kept = localStorage.getItem("account-token"); // the account this browser kept on your site
await fetch("/auth/player-two/link", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ token, account: kept }), // the server links p2:<sub> to that account, once
});
window.close(); // the game in the frame signs in again: it's themThe server checks the identity as above, finds the kept account by its token, and links p2:<sub> to it (or folds it into the account the login has already). From then on the player is the same person in the frame and on your site.
Multiplayer
Your servers and rooms stay yours. A player gets into them one of two ways:
- Your own session, as today. Once the identity has signed the player into your accounts, pass your session to your rooms exactly as you do now.
- The identity itself. Join with a fresh
p2.identity()and check it in your room’s auth hook: the room knows who is joining, with no sign-in of yours.
// a Colyseus room: the player joins with a fresh identity, and the room checks it
import { Room } from "colyseus";
export class Arena extends Room {
async onAuth(client, options) {
const p2 = await playerTwo(options.p2); // throws: the join is refused
return { account: await accounts.byLogin(`p2:${p2.id}`), name: p2.name };
}
}
// the game's client, in Player Two's player
const room = await colyseus.joinOrCreate("arena", { p2: await p2.identity() });An identity runs out ten minutes after it is made: ask for one for each join, and never keep it as the player’s session. Inviting friends into a room: presence and invites.
Trophies and leaderboards from your server
With a game key your server gives trophies, server-only ones too, and sends scores to trusted boards, naming the player by their id for your game: the identity’s sub. Whatever your server decides (a match won, a rank) belongs there, never in the client.
const P2 = "https://playertwo.lol/api/v1/sdk/server";
const auth = { authorization: `Bearer ${process.env.P2_GAME_KEY}`, "content-type": "application/json" };
// a trophy your server decides (one marked "only my server can give it", or any other)
await fetch(`${P2}/trophies/tournament-winner`, { method: "POST", headers: auth, body: JSON.stringify({ player: p2.id }) });
// a score on a trusted board: only your server sends its scores
await fetch(`${P2}/boards/ranked`, { method: "POST", headers: auth, body: JSON.stringify({ player: p2.id, value: 1840 }) });In your studio, mark the trophies only your server decides “only my server can give it”, and make competitive boards trusted, so the game in the player’s hands can’t send them. More on your game’s server.
Presence and invites
// a party's link, on Player Two (location.href in the frame is your own site's address, not Player Two's)
const link = await p2.invite({ room: party.code, kind: "party" });
// https://playertwo.lol/game/your-game?join=eyJyb29tIjoiNDgyOTEzIiwia2luZCI6InBhcnR5In0
await navigator.clipboard.writeText(link);
// friends who may see what the player plays get Join beside "Playing <your game>"
p2.presence.set("In a party · 2 of 4", { join: { room: party.code, kind: "party" } });
p2.presence.set("In the menu"); // no join: Join goes
// whoever opens the link, or presses Join, starts the game with the params
const p2 = await PlayerTwo.init();
if (p2.params?.room) joinParty(p2.params.room, p2.params.kind);p2.invite(params)takes up to 16 strings, 512 bytes as JSON, and gives a link to your game’s page on Player Two. Share it instead of your own address: in the frame,location.hrefis your site’s.- Whoever opens it lands on your game’s page on Player Two and the game opens at once (after they add or buy it, if they must), with
p2.paramsset. p2.presence.set(text, { join })puts Join beside “Playing your game” in the player’s friends’ lists, opening the same link;presence.set(text)takes it away.- Only friends who may see what the player plays see the line and Join. A player who keeps their activity private shows neither.
- Anyone can read a link’s params, or make a link: treat them as you treat anything in an address, never as proof. A party code is fine; a secret isn’t.
Worked through: Impact
Impact (impact.dude.work) is played on its own site and keeps its own accounts: a guest token in the browser’s storage, a Privy sign-in its server checks against Privy’s keys (issuer, audience, subject), and an account book that links login ids to accounts, finds an account by its login, and folds a guest into the account of the login it signs in with. Its progression (XP, level, currency, items, feats, missions) lives on its server for its server rooms; its multiplayer is Colyseus rooms that look the player up by the account token they join with; its parties are six-digit codes, shared as #room= links made from its own address. With Connect, Player Two is one more login, and very little changes:
- In the studio: Connect on. Impact plays at impact.dude.work, the one origin Player Two answers.
- The client: load the SDK; as it starts, in Player Two’s player, send
p2.identity()(with the guest token kept in the frame) to a Player Two sign-in on its server, and keep the account token it answers, as after a Privy sign-in. Readp2.paramsas it reads#room=. - The server: check the identity as it checks Privy’s (RS256 from Player Two’s keys instead of ES256 from Privy’s), link
p2:<sub>in the account book, and fold the guest in. - Rooms: nothing changes. The account token joins as it does today.
- Invites: inside Player Two, make party links with
p2.invite, and set presence with the party’s code asjoin. - What the browser keeps (arcade progress, match history): to the server, or to
p2.saves; and a linking popup for players with progress on impact.dude.work. - Trophies and leaderboards (it has none yet): from its server with a game key, naming players by their
sub.
// as the game starts
const p2 = await PlayerTwo.init().catch(() => null);
if (p2) {
// Player Two's player: sign in with the identity, bringing along the guest kept in this frame
const r = await fetch(`${API}/auth/player-two`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ token: await p2.identity(), guest: localStorage.getItem(ACCOUNT_KEY) }),
});
if (r.ok) localStorage.setItem(ACCOUNT_KEY, (await r.json()).session); // the account token rooms take, as today
if (p2.params?.room) joinParty(p2.params.room, p2.params.kind); // as #room= is read today
}
// a party's invite: Player Two's link inside Player Two, the game's own link anywhere else
const inviteLink = (code, kind) => (p2 ? p2.invite({ room: code, kind }) : Promise.resolve(`${location.origin}/#room=${code}&kind=${kind}`));// beside the Privy sign-in: the same three questions, asked of Player Two's keys
// (issuer https://playertwo.lol, audience the game's slug, subject the player's id for it; RS256, typ "p2-id")
app.post("/auth/player-two", async (req, res) => {
let p2;
try { p2 = await playerTwo(req.body.token); } catch { return res.status(401).json({ error: "Not a Player Two identity." }); }
// then what a Privy sign-in does, with the login "p2:<sub>": the account linked to it (byLogin),
// or the guest (or a new account) linked to it (link), and a guest folded into it
const account = signIn(`p2:${p2.id}`, { guest: req.body.guest, name: p2.name });
res.json({ session: book.tokenOf(account) });
});Reference
| Call | Request | Answer |
|---|---|---|
| p2.identity() | GET /api/v1/sdk/identity | { token, exp } |
| p2.invite(params) | POST /api/v1/sdk/invite { params } | { url, params } |
| p2.presence.set(text, { join }) | POST /api/v1/sdk/beat { status, join } | { ok } |
| p2.params | In the player's answer to the game's hello | The params, or null |
| Your server: check an identity | POST /api/v1/sdk/server/identity { token } | { valid, player, kind, live, exp } |
| Code | Status | What it means |
|---|---|---|
not-connected | 409 | The game is played on its own site and Connect is off. |
moved | 409 | The game's address changed since the page opened: open it again. |
bad-params | 422 | Invite params are up to 16 strings, 512 bytes. |
malformed · unknown-key · bad-signature | Server check: not a token Player Two signed with a key it publishes. | |
expired · not-an-identity · wrong-game · gone | Server check: run out, a ticket or a license, for another game, or a player no longer on Player Two. |