Skip to the content
The room

Documentation · Hosting

Hosting

Your web game plays in Player Two’s player either way. Hosted here, it gets its own address on playertwo.cc and needs no code changes; each update goes up with one command, which your deploy script can run. On your own site, your hosting stays yours and every update is live here at once: Player Two serves it through its own address, read from your site as it is, or your site plays in the player itself and Connect brings the rest. Back to the documentation.

  1. You push its folderOne command, or a zip. Only the files that changed go up.
  2. It gets its own addressyour-game.playertwo.cc, with its build at the top and the SDK put in its page for you.
  3. Players play it hereIn Player Two's player. Its saves follow them to any device; a paid game opens only for its owners.
On this page

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.

What each way gives
On Player Two (recommended)On your own site
How it gets hereUpload a zip, or push the folder with one command. Up to 5 GB.Paste its address.
UpdatesEach 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 addressIts own, on playertwo.ccIts own on playertwo.cc, read from yours (served through Player Two), or yours
Code changesNone. Player Two puts the SDK in its page.None, served through Player Two. With that off, one script tag and Connect on.
SavesWork 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 itOnly its owners can play it. Nothing to build.Your server checks who owns it.
Trophies, leaderboards, friends, invitesBuilt in: a few SDK calls.With Connect, the same calls.
Serving itPlayer 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:

what Player Two puts in your page's head
<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.

allowing the game's address
// 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.

in your game
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).

  1. Put the game on Player Two, with the submit form or p2 push. Hosted games get the SDK automatically.
  2. Write const room = await p2.rooms.join({ mode: "duel", size: 2 }), then room.send(...) and room.on("message:move", ...). For smooth movement, room.sync(object). Private codes and party rooms are one call each.
  3. Test with two browser tabs on localhost: no account, no server.
  4. Push. The rooms just work, your studio shows rooms and players live, and a release never mixes versions in a room.
to start, in your game
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:

in a terminal, or your CI
P2_AGENT_KEY=p2_agent_... node p2.mjs push dist --game your-game --room

It 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_ignore or p2-ignore, the keys and databases you list in your studio, databases named for a cache (Unity’s UnityCache), 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.saves keeps 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.

in a terminal, or your CI
# 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.0
what it says
Hashing 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’t index.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 --post to 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_Store and 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:

1. the list
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:

3. done
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 again

From 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.html in 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:

the Action
# .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 empty

A 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:

the Action
# .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 secret

Releases 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 windows or win64 in 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 .zip is taken for Windows, a .dmg for Mac and an .AppImage for 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

A web build's limits
Up to
A pushed folder5 GB, 20,000 files
One file in it512 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 commit250 MB; its game's folder 300 MB and 3,000 files
One .html page10 MB
A player's copy of localStorage, and of IndexedDB2 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.