The two ways
You choose when you submit the game, and can switch in your studio (its card, under Where it plays). Either way players find it on its page here and play it in Player Two’s player.
| On Player Two (recommended) | On your own site | |
|---|---|---|
| How it gets here | Upload a zip, or push the folder with one command. Up to 5 GB. | Paste its address. |
| Updates | Each one goes up the same way, a zip or the command. Put the command in your deploy script and every release goes up by itself. | Nothing to send: whatever is live on your site is live here, on the next page load. |
| Its address | Its own, on playertwo.cc | Its own on playertwo.cc, read from yours (served through Player Two), or yours |
| Code changes | None. Player Two puts the SDK in its page. | None, served through Player Two. With that off, one script tag and Connect on. |
| Saves | Work as in any browser, and follow players to any device. | Served through Player Two, the same. With that off, your site’s, though a frame keeps its own: see Connect. |
| Selling it | Only its owners can play it. Nothing to build. | Your server checks who owns it. |
| Trophies, leaderboards, friends, invites | Built in: a few SDK calls. | With Connect, the same calls. |
| Serving it | Player Two's, from a CDN. | Yours. |
Your site, served through Player Two
A game on your own site can be served by Player Two from its own address, https://your-game.playertwo.cc, read from your site as it is: turn on Serve it through Player Two when you submit it, or in your studio (its card, under Where it plays). It is on for a new game on its own site. Nothing is copied and nothing needs sending: each page load reads your site as it is then, so every update you put live there is live here at once.
- What it gives: what a game hosted here has, with nothing added to your site. Player Two puts the game SDK first in each of its pages, so saves follow players to any device (below), and trophies, leaderboards, friends, invites, rooms (with your room code, pushed on its own) and the signed identity work. Its own address keeps its storage apart from every other game’s.
- How: a page is read from your site at the same path and served with Player Two’s script first in its head. Every other file (scripts, models, sounds) comes from your site through the same address, as your site sends it, with its own caching. A redirect within your site is followed at the same path here.
- What it needs: an https address with a name. Your own servers (an API, a multiplayer server) accept requests from
https://your-game.playertwo.cc, as for a game hosted here (your own servers). - What doesn’t go through: only reading a file goes on to your site. A form posted to it, or an API at your site’s own address, needs your game to call your site by its full address (with CORS), or leave this off. Sign-in pages that redirect (Google, X, Discord) don’t work inside any frame: use Player Two’s sign-in.
- With it off: your site itself plays in Player Two’s player, at its own address. Updates are live at once too; for saves that follow players, trophies and Player Two’s sign-in, add one script tag and turn on Connect.
Code changes
Hosted here: none
Your export plays as it is. Its paths work as they do on any server, root-absolute ones too (/assets/app.js), and so do its own storage, workers, WebAssembly and audio. Player Two adds one script first in each of its pages, the game SDK in auto mode, which finds Player Two around the game and keeps its saves going:
<script src="https://playertwo.lol/sdk/v1.js" data-p2-auto data-game="your-game"></script>If your game already loads the SDK and calls PlayerTwo.init(), nothing changes: it gets the same answer. A query on a file’s address (app.js?v=3) makes no difference to which file is sent.
Your own servers
A game hosted here talks to your servers (an API, a multiplayer server) from its own origin, https://your-game.playertwo.cc. Add it wherever your servers say which origins may call them: your CORS, and your WebSocket server’s check of the Origin header.
// your API (Express with cors): the game calls it from its own address on Player Two
app.use(cors({ origin: ["https://yourgame.com", "https://your-game.playertwo.cc"] }));
// your WebSocket server: the same address, in its check of where a connection comes from
const ALLOWED = new Set(["https://yourgame.com", "https://your-game.playertwo.cc"]);
wss.on("connection", (socket, req) => {
if (!ALLOWED.has(req.headers.origin)) socket.close(1008, "Not from the game");
});To know who is playing, your servers check a signed identity from p2.identity(), as a game on its own site does: see the identity.
Trophies and leaderboards
A few calls, once you have made the trophies and leaderboards in your studio. The game plays on without them anywhere else.
PlayerTwo.init().then(async (p2) => {
await p2.trophies.unlock("first-win"); // the player sees the toast
await p2.scores.submit("time-attack", 83400); // a leaderboard you made in your studio
}).catch(() => { /* played outside Player Two: the game carries on without them */ });Multiplayer
A game hosted here can be multiplayer with no server of your own: Player Two rooms seat its players together, through the SDK it already has. It’s the choice a hosted game starts with, under Multiplayer when you submit it, and you can change it in your studio, under Rooms. A game that plays alone says Single-player; one with servers of its own says Its own server, and its servers learn who is playing from p2.identity() (your own servers).
- Put the game on Player Two, with the submit form or
p2 push. Hosted games get the SDK automatically. - Write
const room = await p2.rooms.join({ mode: "duel", size: 2 }), thenroom.send(...)androom.on("message:move", ...). For smooth movement,room.sync(object). Private codes and party rooms are one call each. - Test with two browser tabs on localhost: no account, no server.
- Push. The rooms just work, your studio shows rooms and players live, and a release never mixes versions in a room.
const p2 = await PlayerTwo.init();
const room = await p2.rooms.join({ mode: "duel", size: 2 });
room.send("move", { x: 3, y: 1 });
room.on("message:move", (move, from) => apply(from, move));Scripted rooms, for games where cheating matters: put a room.js at your build’s root, and it runs on Player Two’s servers, one copy per room and per build. It stays private: Player Two takes it out of the build as the build goes live, and never serves it to players.
Joining, messages, the shared state, events and limits: Rooms in the SDK docs.
Room code for a game on its own site
A game on its own site that Player Two serves (above) has rooms as a game hosted here does, and its rooms can run your code too. Your site is read as it is, so the code doesn’t come from it: keep room.js (or your room/ folder) off your site and push it on its own, from the folder that has it. Nothing else goes, and nothing about your site changes:
P2_AGENT_KEY=p2_agent_... node p2.mjs push dist --game your-game --roomIt is kept as a build’s is: checked by its hash, private, never served to players. Rooms opened from then on run it; rooms open on the code before play on with theirs until they end. Push again when it changes: each push is its own version, and the one before goes a day later. A game Player Two keeps a build of carries its room.js in the build instead (409 not-followed).
Saves
A game hosted here keeps its saves where it always does, in the browser’s localStorage and IndexedDB, at its own address. Player Two keeps a copy of both in each player’s cloud saves and puts it back before the game runs, on any device they sign in on. No code needed.
- When it goes up: a few seconds after the game writes (at most twice a minute), and at once as the player leaves or closes the game. Another device saving in between is answered by sending again on top of it: the newest write wins. At the start of a page load, the cloud’s copy wins.
- What stays on the device: keys starting
p2_ignoreorp2-ignore, the keys and databases you list in your studio, databases named for a cache (Unity’sUnityCache), and a database holding files (Blobs), which can’t be copied. - How much: up to 2 MB of localStorage and 2 MB of IndexedDB a player. The largest keys and databases are left out until the rest fits, and your studio lists them.
- Your own saves:
p2.saveskeeps named slots in the player’s cloud saves, from any game (the SDK). Turn the copy off in your studio if you would rather keep saves that way only.
Unity, Godot and SharedArrayBuffer
Unity
Export for WebGL and push the folder with index.html at its top. Compressed builds work as they are: a file ending .br or .gz is sent with its encoding, so Unity’s decompression fallback isn’t needed. PlayerPrefs and files under Application.persistentDataPath follow the player; Unity’s download cache stays on the device.
Godot
Export for the Web. Files under user:// follow the player. Godot 4 with threads needs SharedArrayBuffer (below); since Godot 4.3, a build exported without threads needs nothing.
SharedArrayBuffer
A page has SharedArrayBuffer (threads in WebAssembly) only when it is cross-origin isolated. Turn Cross-origin isolation on in your studio: your game’s pages then say so in their headers (COOP and COEP, and Document-Isolation-Policy, which isolates the game’s frame on its own). Files your game loads from other sites then need CORS or a Cross-Origin-Resource-Policy header; choose credentialless to let plain ones load, without cookies.
Chrome and Edge isolate the frame today. In other browsers SharedArrayBuffer isn’t there yet inside a frame, so export without threads where your engine can.
Pushing a build
The tool is one file, p2.mjs, with nothing to install. It sends only the files Player Two doesn’t have yet, eight at a time, picks up where it left off if it is stopped, and makes the build live once every file has arrived. Players get the new build on their next page load; a page already open keeps its own to the end.
# once: the tool (Node 20 or later), and an agent key from your studio
curl -fsSLO https://playertwo.lol/cli/p2.mjs
# then, each time: the folder your engine exported
P2_AGENT_KEY=p2_agent_... node p2.mjs push ./build --game your-game --version 1.4.0Hashing 2,914 files (412.6 MB)...
Build 6f1d0c9a2b7e4f3a8c5d1e0b: sending 38 files (21.4 MB); 2,876 files Player Two has already.
sent 38 of 38 files, 21.4 MB of 21.4 MB
Live: https://your-game.playertwo.cc/ (build 1.4.0)--entry: the page the game opens at, if it isn’tindex.html.--version 1.4.0: what the build is called, in your studio (“Live build 1.4.0”), its list of builds, its testers’ notice and the version email its players get. Up to 32 characters: letters, digits, dots, dashes, plus signs and spaces, starting with a letter or a digit (1.4.0,2.0 beta,v1.3.2+hotfix); anything else is refused before anything is sent (and by Player Two,422 bad-version). Left out, the build is called by its number in the game’s builds: its first is 1, the next 2, and so on, every build counted once it is ready, never reused (builds from before versions were kept were numbered in the order they came). It needn’t be new or in order: it is a label. The same files pushed again with another version rename that build. Not with--room.--note "What changed": a few words on the build (up to 1,000 characters), shown with it in your studio. Add--postto put the note on the game’s Updates too, as a release post, once the build is live; its first line is the post’s title.--testers: to the game’s testers first. Its players keep the build they have; you, your team and your testers play this one from its page, as a test, and each tester is told it’s up. Make it live from the list of its builds when it’s ready (or stop testing it). A newer one takes its place. For a game that is out; before its day, its testers play its build anyway.- Left out:
.git,.DS_Storeand the like, and symbolic links (it says which). - The game must be on Player Two first: submit it with a zip, or with its address on your site, then push. The push makes it hosted here.
Its builds, in your studio under Where it plays, list every build it has played that is still kept: each for 30 days after another took its place, and the last 20 always. Each says its version (“Build 1.4.0”, or “Build 12” by its number), who sent it (you, or which agent key), its size, how many files changed against the build before, its commit and note, and how many players are on it now. Try it opens a build in a tab of its own without making it live; Make live rolls back (or forward) to it, and players get it on their next page load; Name it gives it a version, or another one (a GitHub commit’s build comes in by its number; nobody is told). The one out to its testers says Testers, with Make it live and Stop testing. An upload can go to them first too (To testers first, as you save the page with a new zip), and so can a linked repository’s commits (GitHub pushes go to testers first). Your agent can do the same with its key: the MCP tools list_builds, make_build_live and name_build, or GET, POST and PATCH ({ key, version }) /api/v1/agent/games/<slug>/web/builds. A zip from the form takes a version in the field under it (named as the page saves), and an agent’s upload_web_build (POST /api/v1/agent/builds) takes version too, by the same rule.
The three calls
The tool makes three calls, which your own scripts can make too, with your agent key. First the list of files, each with its size and SHA-256; the answer names the build and the files to send, each with where to send it:
POST https://playertwo.lol/api/v1/agent/games/your-game/web
Authorization: Bearer p2_agent_...
{ "entry": "index.html", "version": "1.4.0",
"files": [{ "path": "index.html", "size": 1841, "sha256": "9f2c...", "type": "text/html" }, ...] }
200 { "build": "6f1d0c9a2b7e4f3a8c5d1e0b",
"upload": [{ "sha256": "9f2c...", "method": "PUT", "url": "https://...", "headers": { ... } }, ...] }Then each of those files, as it is, to its url with its method and headers. The addresses last six hours; ask again with the same list for fresh ones (files already sent aren’t asked for again). Last, the build is checked and made live:
POST https://playertwo.lol/api/v1/agent/games/your-game/web/6f1d0c9a2b7e4f3a8c5d1e0b/done
200 { "live": true, "url": "https://your-game.playertwo.cc/", "version": "1.4.0" }
409 { "code": "missing", "missing": ["path", ...] } # some never arrived: ask again
422 { "code": "mismatch", "mismatched": ["path", ...] } # not what the list said: push againFrom GitHub
Link a public GitHub repository to a game, and every commit to its branch goes live on Player Two a couple of minutes after you push it, with nothing to run. In your studio, open the game’s Where it plays, then Update it from GitHub: give the repository (its address, or owner/name), the branch (its default one if left empty) and the folder that holds the built game, the one with its index.html (empty for the top).
- At once: the branch’s latest commit becomes the game’s build, so a wrong folder is said before anything changes. Linking a game played on your own site makes Player Two host it from the repository.
- Then each commit: Player Two looks at the branch every two minutes, and each new commit becomes the build, as a push does: players get it on their next page load. It is called by its number in the game’s builds; name it in its list of builds (Name it), or push from an Action with
--version(below) to name each one as it goes up. A commit that leaves the game’s files as they were (a README, say) changes nothing. - A commit that can’t be a build (no
index.htmlin the folder, too big) leaves the game playing the build it had; you’re told in your notices and in the studio, and the next commit is tried as it comes. Update now looks at once and tries again. - Files kept with Git LFS are only in GitHub’s archive of a commit once Include Git LFS objects in archives is on, in the repository’s settings.
A game that needs building
Commit the built game, or let a GitHub Action build it into a branch of its own, as GitHub Pages does with gh-pages, and link that branch:
# .github/workflows/player-two.yml: build on every push to main, into the branch Player Two follows
name: Build for Player Two
on: { push: { branches: [main] } }
permissions: { contents: write }
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 22 }
- run: npm ci && npm run build # your build: it ends with the game in dist/
- uses: JamesIves/github-pages-deploy-action@v4
with: { branch: player-two, folder: dist } # then link branch player-two, folder emptyA private repository
Player Two reads public repositories only, the way git itself does, with nothing to sign in to. A private one pushes its build from an Action with the tool and an agent key kept as a repository secret:
# .github/workflows/player-two.yml, for a private repository: the Action pushes the build itself
name: Push to Player Two
on: { push: { branches: [main] } }
jobs:
push:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 22 }
- run: npm ci && npm run build
- run: curl -fsSO https://playertwo.lol/cli/p2.mjs && node p2.mjs push ./dist --game your-game
env: { P2_AGENT_KEY: ${{ secrets.P2_AGENT_KEY }} } # an agent key from your studio, as a repository secretReleases as downloads
A linked game can take its downloads from the repository’s releases too: tick Its releases are its downloads. Within ten minutes of a release being published (not a draft or a pre-release), its file for each system is brought into the game’s downloads as its full game, with the release’s tag as its version (v1.2.0 becomes 1.2.0):
- Named for the system: a file with
windowsorwin64in its name (or an.exe,.msi) is the Windows download;mac,macos,osx(or a.dmg,.pkg) the Mac one;linux(or an.AppImage,.deb,.rpm) the Linux one. Where several fit, a.zipis taken for Windows, a.dmgfor Mac and an.AppImagefor Linux. - Checked as an upload is: up to 2 GB each, they wait for the virus check and Player Two’s look before players can get them, and you’re told in your notices what came in and what was left out.
- Once each: a release already brought in isn’t brought in again; the next one is.
How it's served
- One build a page load. The page is never cached; each file it asks for comes from that build’s own address (
/~<build>/...), cached for a year, since a build’s files never change. A new build is a new address, so no page load mixes two. - Played in Player Two’s player only. Only Player Two can frame the game, and a visit to its address goes to its page here, where saves, trophies and friends work. A free game can be played at its own address too: turn that on in your studio.
- Its files are its own. They are sent to the game’s own pages and to tools, never to another site. Ranges are answered, for audio and video.
A free trial
A game that costs money and plays here can let players try it before they buy it: on its page in the editor, Price, its Free trial, from 15 minutes to 2 hours, half an hour for a new game unless you choose otherwise (also in your studio, with how many tried it and bought it; an admin of your studio can set it too; a developer on your team can’t, as with its price). Players see it as a demo: Play demo with its length beside Buy wherever the game is (the room’s big screen, the store’s spotlight, its page and its bar), then the time they have left, until it’s used up; its card in the store says Demo and its length, and it joins the store’s Try before you buy.
- The whole game, once. A player who doesn’t own it plays the build players get, in Player Two’s player, with its saves, trophies and services; one trial a player.
- A game on its own site can have one too. Served through Player Two, its play link runs out with the trial. Played from its site as it is, the trial is Player Two’s, as its price is: the time counts in Player Two’s player, which closes the game when it’s up, while the site stays as open as you keep it.
- Only the time it’s open counts, and Player Two counts it. The player’s page checks in every thirty seconds, and Player Two pays for the next minute and a half of play each time; the game plays only as long as it’s paid for, so a page that stops checking in stops the game instead of stretching the trial. A computer left asleep uses nothing, and closing the game gives back what was paid and not played. They can stop and come back until it’s used up.
- When it’s up, the game’s last saves go up, it closes, and the player is offered Buy. What they saved carries on when they buy it. Its play link and its session run out with the trial: the session answers the game (saves, trophies, scores) only while its time is paid, and a minute after for its last saves. There’s nothing to change in the game.
- In the studio, how many players tried it, and how many of them have it now.
Limits
| Up to | |
|---|---|
| A pushed folder | 5 GB, 20,000 files |
| One file in it | 512 MB |
| A zip (the submit form, or an agent's upload) | 100 MB; 300 MB and 3,000 files unpacked |
| A linked GitHub repository at a commit | 250 MB; its game's folder 300 MB and 3,000 files |
| One .html page | 10 MB |
| A player's copy of localStorage, and of IndexedDB | 2 MB each |
Paths are the folder’s own: none may go outside it. A build a game no longer plays is kept 30 days after another takes its place, or as one of the last 20 it played, whichever keeps more, to try or make live again (its studio’s Builds, or list_builds); then its files go.