Web Games
←back to documentation

Web Games

Ship your own HTML, ThreeJS and JavaScript game on Portals with player identity, multiplayer, coins, avatars, and server logic.

Portals SDK

The Portals SDK connects a hosted web game to the Portals player and host. It supports identity, saved progress, casual scores, leaderboard reads, analytics events, game-specific Coin products, and closing the game from either standalone or in-room play. For setup and the complete purchase contract, see Sell game products for Coins. For real-time multiplayer, in-game text chat, and voice chat, see Multiplayer, Chat, and Voice. To put the player's Portals avatar in a Three.js game — wearables, animation and a character controller — see Portals Avatars.

Portals injects the SDK into every processed preview and published bundle. Your project includes it from its own game origin:

<script src="./_portals/sdk.js"></script>
<script src="./game.js"></script>

Do not download or edit _portals/sdk.js. Portals replaces the managed copy when it processes or publishes the project.

Portals avatars ship as a second, optional managed script. Add it beside this one only if you use it:

<script src="./_portals/guardians-sdk.js"></script>

It gives you the global PortalsGuardians — avatars, wearables, animation and a character controller, gated by await PortalsGuardians.ready() the same way Portals is gated by await Portals.ready(). See Portals Avatars.

Threaded WebAssembly and Unreal web exports

A browser export that uses WASM threads needs shared memory. Opt in by adding crossOriginIsolation to the existing root portals.json alongside any SDK pins:

{
  "crossOriginIsolation": true
}

Keep this file in the built output beside index.html, then push/import that output. Preview the new draft before publishing. The setting belongs to the source snapshot: changing it affects the draft; a published release changes only after a new publish. Set it to false or remove the field to opt out. The value must be a JSON boolean. No new MCP setting or SDK method is required.

Portals serves the opted-in entry with Document-Isolation-Policy: isolate-and-require-corp. This enables isolation inside the existing game iframe while preserving the Portals host and its credential-free postMessage SDK bridge. Creators cannot set server headers using HTML meta tags. Do not install a service-worker header workaround or disable the iframe sandbox.

Supported scope: desktop Chromium browsers with Document Isolation Policy (Chrome 137+; verify the actual capabilities in other Chromium browsers). This is not a promise of Firefox, Safari, iPhone, Android, or embedded-WebView support. Portals adds an early check for crossOriginIsolated and SharedArrayBuffer; when unavailable it navigates to a browser-support notice. No user-agent guess can replace checking these values in the actual game frame:

const supportsThreads = globalThis.crossOriginIsolated === true &&
  typeof SharedArrayBuffer !== "undefined";

A standalone localhost check is not enough: verify a shared WebAssembly.Memory, a real worker, the engine startup, gameplay, and Portals.ready() in the hosted preview. The editor's mobile layout preview does not establish mobile browser support.

For Unreal, upload an already-built browser/WebAssembly export, not a native executable, source project, or .uproject. Record the UE version, export toolchain, required browser/WebGPU features, and memory budget. Enabling threads does not add WebGPU support to a device or certify an Unreal toolchain. The complete game still needs its own qualification.

Keep workers, WASM, JS, and game data in the bundle with relative URLs. Cross-origin resources must satisfy both Portals CSP and CORS/CORP; isolation does not expand the network allowlist. Prefer uncompressed .js/.wasm files (the CDN handles transport compression). Precompressed .br/.gz exports require the correct serving MIME and Content-Encoding and are not made compatible by this isolation flag. Current plugin upload limits still apply; use the website import path for bundles beyond plugin limits.

For local testing, configure your development server to send the same isolation header. Deployment requires both the backend processing changes and the CDN viewer-response function; a source upload alone cannot enable an unconfigured CDN.

Starter

Wait for the host before reading the player or using a hosted capability:

async function start() {
  const session = await Portals.ready();
  console.log("Running in", session.context);

  if (session.player.playerId) {
    console.log("Signed in as", session.player.displayName);
  }
}

start().catch(console.error);

session.context is standalone on a game page and room when the same game is open inside a Portals room. session.matchmaking is non-null when the player arrived through a Portals-managed lobby — see managed lobbies.

Leave room for Portals controls

Every launched game keeps a trusted Portals controls button above the game in the top-left corner. It is host UI outside the game iframe, so game code cannot hide, restyle, intercept, or out-z-index it.

The current closed trigger is 44 × 44 CSS pixels. Its top and left positions are each the larger of 12px and the matching device safe-area inset. A party-count badge may extend 4px beyond the button. Keep essential labels and interactive game UI outside that footprint; the canvas or other non-interactive playfield can still remain full-bleed.

For a top HUD, reserve the left edge. For a left HUD, reserve the top edge:

.game-hud--top {
  padding-left: calc(max(12px, env(safe-area-inset-left)) + 56px);
}

.game-hud--left {
  padding-top: calc(max(12px, env(safe-area-inset-top)) + 56px);
}

The additional 56px covers the 44px button, the possible badge extension, and a small visual gap. When the player opens Portals controls, the larger host-owned panel may temporarily cover more of the game and takes pointer and keyboard focus. Keep gameplay safe while it is open and restore the game UI cleanly when focus returns.

Sign in from the game

Call sign-in from a direct player action such as a button click. Portals owns the sign-in interface. Never ask for a Portals password or other account credential inside your game.

const signInButton = document.querySelector("#sign-in");

signInButton.addEventListener("click", async () => {
  try {
    const player = await Portals.identity.requestLogin();
    signInButton.textContent = `Playing as ${player.displayName || "Player"}`;
  } catch (error) {
    console.error("Sign-in was not completed", error);
  }
});

Subscribe when the game UI should react to later sign-in or sign-out changes:

const unsubscribe = Portals.identity.onChange((player) => {
  document.body.dataset.signedIn = player.playerId ? "true" : "false";
});

// Call unsubscribe() if your UI is removed.

playerId is stable for that player within one game. It is deliberately different across games. It is not a Portals account ID and must not be used to join player activity across games.

Read the current username and active avatar

Use Portals.player.get() when a game needs the player's current public profile snapshot rather than only the lightweight identity from Portals.ready():

const player = await Portals.player.get();

const label = player.username
  ? `@${player.username}`
  : player.displayName || "Guest";

console.log(label, player.avatar);

username is the active public profile handle without the @. avatar is the playable look saved on the Portals /avatar screen: Portals avatar body configuration plus selected wearables, or a selected full-avatar replacement. It is null for a guest. The older avatarUrl field is only the player's 2D profile image; do not load it as a 3D character.

The first signed-in read is cached for that hosted-game load. Call it again after Portals.identity.requestLogin(); if the player changes their saved look in another tab, reopen or reload the game. To render it in a Three.js game, pass the result to avatars.createAvatarFromPlayer(player) from the Portals avatar SDK.

Open the trusted avatar picker

Let a signed-in player update their global Portals look without leaving the game:

document
  .querySelector("#customize-avatar")
  .addEventListener("click", async () => {
    try {
      const refreshedPlayer = await Portals.avatar.openPicker();
      await replaceRenderedAvatar(refreshedPlayer);
    } catch (error) {
      console.error("Avatar customization did not finish", error);
    }
  });

Call openPicker() from a direct click or tap. Portals owns the interface and signs the player in when needed. Changes remain a local draft until the player chooses Done; Cancel discards the draft and rejects the promise. A completed picker resolves only after the global /avatar look is saved and returns a refreshed PortalsPlayerProfile, so the game can replace its rendered avatar without reloading.

The picker is unavailable in the editor preview because that host does not open account-wide interfaces. Catch the rejection and keep the preview playable; test this interaction in a published game host.

Games cannot read the player's inventory or directly equip global items. They receive only the sanitized active presentation in player.avatar; its wearables are render data, not ownership proof. The picker only presents eligible items in the player's existing Shop inventory, and the server validates ownership and compatibility on save. The SDK does not expose Marketplace listings, purchases, sales, owned-item records, or a method that accepts arbitrary wearable IDs.

Save progress

Saved state belongs to the signed-in player and the current game:

const previous = await Portals.loadState();
const progress = previous || { level: 1, coins: 0 };

progress.coins += 1;
await Portals.saveState(progress);

State must be JSON-serializable and no larger than 64 KB after JSON encoding. Signed-out players cannot save. loadState() returns null when no saved state exists.

Keep saves compact and version them when their shape may change:

await Portals.saveState({
  schemaVersion: 1,
  level: 3,
  unlocked: ["dash", "double-jump"],
});

Submit and read casual scores

Anyone can post a score. A signed-in player is ranked under their Portals profile; a signed-out player is ranked under a name your game collects from them — see Scores from signed-out players below. Higher values rank first and, by default, only the best score for each player and mode is kept. Pass { replace: true } as the third argument to store a score even when it is worse than the player's stored one — for a game whose score can legitimately go down.

A mode can rank the other way round. In My Games → your game → Settings → Leaderboard ranking, set a mode to Lowest score for golf strokes, move counts, or mistakes: the lowest value ranks first and a new submission replaces the player's stored score only when it is lower. A mode can also rank as a time; see Time leaderboards below.

Draft play — the editor preview and a shared ?draft= link — reads and writes a separate draft leaderboard, so you can play a board through before publishing without preview scores ever reaching the published game's ranking.

await Portals.submitScore(1250);
await Portals.submitScore(48, "daily");
await Portals.submitScore(12, "daily", { replace: true });

A mode may contain lowercase letters, numbers, and hyphens and may be at most 32 characters. Omit it to use default.

Read the top scores after the host has enforced access to the game:

const leaderboard = await Portals.getLeaderboard({
  mode: "daily",
  limit: 10,
});

for (const entry of leaderboard.entries) {
  console.log(entry.rank, entry.displayName, entry.score);
}

The limit defaults to 10 and may be from 1 to 100. Each row contains:

Field Meaning
rank One-based position in the returned board.
playerId Stable identifier scoped to this game.
displayName Current public display name, or the name a signed-out player gave, or null.
avatarUrl Current public avatar URL, or null. Always null for a signed-out player.
score Stored score for the selected mode. Elapsed milliseconds on a time mode.

Game scores are client-reported and intended for social and casual competition. Never use them to award currency, paid prizes, access, or another valuable entitlement.

Scores from signed-out players

A signed-out player has no Portals profile, so there is no name for the board to rank them under. Your game collects one and passes it as options.name:

const player = await Portals.getPlayer();

if (player.playerId === null) {
  // Your own UI — a text input, a three-letter arcade entry, whatever fits
  // the game. Portals does not prompt for this.
  await Portals.submitScore(1250, "daily", { name: enteredName });
} else {
  await Portals.submitScore(1250, "daily");
}

A name is 1 to 24 characters. The submission is rejected for a signed-out player without a usable one, so collect it before you post the score rather than discovering the rejection afterwards. For a signed-in player the option is ignored — their Portals profile name always wins, and a game cannot rename them.

The name is stored on that score alone and is the only thing the board shows for that row: avatarUrl is null and the row does not link to a Portals profile. The player keeps the same board identity across sessions in the same browser, so beating their own score updates their row instead of adding a second one. Clearing browser storage starts a new one.

A signed-out player's name reaches a public leaderboard exactly as typed. If your game's audience makes that a concern, constrain the input — a fixed character set, a short length, or a pick-from-list — rather than accepting free text.

Paid games are unaffected: a signed-out player cannot purchase access, so they never reach the point of posting a score.

Private leaderboards

A private leaderboard is a separate ranking for one group of players — a tournament, a classroom, a stream night — while your public board carries on untouched.

1. Create it. In My Games → your game → Settings → Private leaderboards, create one and give it a name, for example friday-cup. A name may use lowercase letters, numbers, and hyphens, must start with a letter or number, and may be at most 64 characters.

2. Share its link. The settings page gives you the link to copy:

https://portals.to/g/your-game?board=friday-cup

Everyone who follows that link competes on that board. You do not have to change your game for this — every submitScore and getLeaderboard call made in that session already goes there.

To label it, read the board from the session:

const session = await Portals.ready();

if (session.board !== null) {
  showBanner(`playing the ${session.board} board`);
}

Your game can also name a board itself, on either call:

await Portals.submitScore(1250, "daily", { board: "friday-cup" });
const board = await Portals.getLeaderboard({ mode: "daily", board: "friday-cup" });

A private leaderboard is unguessable, not secret. The name is the only thing that reaches it, so treat the link as the invitation: anyone you give it to can read and post there, and a name you publish — in your game's own code, say — is public. Pick something hard to guess for a board that should stay closed.

Some things to know:

  • Only a leaderboard you created exists. A link naming anything else — a typo, or one you have deleted — plays the public board instead. Players are never blocked, but their scores go to the public ranking, so check the name when you share a link and expect old links to feed the public board once you delete a leaderboard.
  • A private leaderboard is never listed on your game's page and can never be the featured board. Its scores are not counted in your leaderboard settings, and session.board is the only place a player can see which board they are on.
  • Mode rankings are shared. A mode set to Shortest time ranks that way on every board.
  • A session on a private leaderboard stays there. There is no way to read the public board from inside it.
  • Draft play keeps the split: ?draft= and ?board= together give you the draft side of that private leaderboard.
  • Deleting one deletes its scores with it.
  • A game may hold up to 50 private leaderboards.
  • If Portals resets your whole leaderboard, private leaderboards are emptied too, without being archived as a version. The leaderboards themselves survive, so their links keep working.

Time leaderboards

A score is only a number; nothing in submitScore says whether it is points or a time. The mode's ranking, a setting you own in the creator dashboard, tells Portals how to treat that number. Every mode ranks as Highest score until you change it to Lowest score, Shortest time, or Longest time.

1. Submit a time from your game. Measure elapsed milliseconds and post them to a mode of your choice. Use a dedicated mode for each time board so it never mixes with point scores.

const startedAt = performance.now();

// ...the player finishes the run...

const elapsedMs = Math.round(performance.now() - startedAt);
await Portals.submitScore(elapsedMs, "speedrun");

2. Set the mode's ranking. Open My Games → your game → Settings → Leaderboard ranking. Every mode your game has submitted to is listed, including modes played only in the editor preview or a draft link. Set the mode to Shortest time or Longest time. The setting autosaves and applies to the live and draft boards immediately.

Do this before you publish. A mode is stored under highest-score rules until you change its ranking, so play the game once in the editor preview to create the mode, set the ranking, then publish.

3. Portals keeps the right time. On a shortest-time mode, a new submission replaces the player's stored time only when it is lower; on a longest-time mode, only when it is higher. { replace: true } still stores the posted time regardless, exactly as it does for scores.

4. The board sorts by the ranking. getLeaderboard and the featured leaderboard on the game page rank a shortest-time mode lowest first and a longest-time mode highest first. Rank 1 is always the winner.

5. The game page formats the value. When the featured leaderboard is a time mode, Portals sends the board's ranking together with its rows, and the game page shows each stored value as a time: 83456 becomes 1:23.456, and times of an hour or more read h:mm:ss.mmm. Point boards keep the plain number format.

getLeaderboard returns the raw millisecond values and no ranking, so an in-game leaderboard formats them itself:

const board = await Portals.getLeaderboard({ mode: "speedrun", limit: 10 });

function formatTime(ms) {
  const total = Math.round(ms);
  const minutes = Math.floor(total / 60000);
  const seconds = Math.floor((total % 60000) / 1000);
  const millis = total % 1000;
  return `${minutes}:${String(seconds).padStart(2, "0")}.${String(millis).padStart(3, "0")}`;
}

for (const entry of board.entries) {
  console.log(entry.rank, entry.displayName, formatTime(entry.score));
}

Always submit milliseconds. A value in seconds still ranks in the right order but displays as a fraction of a second on the game page.

Record analytics events

Every play of your published game is already counted: sessions, active and new players, and play time. Open My Games → your game → Analytics to see them. You can also link directly to the tab at https://portals.to/my-games/web/<gameId>?tab=analytics.

Record your own events to see what players do in the game:

Portals.track("level_complete", { level: 3, character: "knight", hard: true });
Portals.track("shop:open");

track doesn't return anything or wait for a reply. The host batches events and sends them in the background, and outside a Portals host the call does nothing. It throws when the name or properties are invalid, so a mistake shows up while you're developing:

  • Name: starts with a letter and uses only letters, numbers, underscores, colons, or hyphens, up to 64 characters. Names starting with portals: are reserved.
  • Properties: optional, at most 20. Each key starts with a letter and uses only letters, numbers, or underscores, up to 40 characters. Values are strings (up to 256 characters), finite numbers, or booleans.
  • Names per game: up to 100 distinct event names. Events with a new name beyond that are not recorded, so use properties for things that vary, such as { level: 3 } rather than level_3_complete.

For each event, the Analytics tab shows its daily count, unique players per day and across the selected range, and the most recent individual events with their properties. Choose break down by on an event to count its values of one property, for example completions per level. A breakdown counts events recorded from the moment you choose it, and keeps up to 50 distinct values per day; further values are counted as (other).

Every play session also records a built-in portals:session_start event. Its platform property says where the session was played: web on Portals, or tiktok when Portals also publishes your game as a TikTok mini game. TikTok play counts toward the same numbers, and you can ask, for example, "players by platform".

Events from the editor preview and draft links are stored as test events. They appear in the event list when you turn on show test events, and never count toward the published game's numbers.

To answer a question the charts don't cover, type it into ask at the top of the Analytics tab. For example: "how many players finished level 3 this week?", "level completions by character", or "funnel from tutorial_done to level_complete". Portals turns the question into one query over this game's individual events from the last 90 days. It shows the answer as a number, a daily trend, a breakdown by one property, or a funnel of up to 5 events, together with a description of what it counted so you can check it. Answers cover live events unless you ask about test plays, and include events recorded up to about 15 minutes ago. Each answer uses your plan's credits, usually 1, including a reply that explains why a question can't be answered from your events. Nothing is charged when the question can't be turned into a query or the query fails. Play time isn't included in these answers; the charts above show it.

Days are UTC. Individual events are kept for 90 days; daily counts are kept for as long as the game exists. A game records at most 250,000 events per day, and the Analytics tab shows when that limit was reached. Events are reported by the player's browser, like casual scores: use them to understand your game, never to award currency, prizes, or access. Games published before SDK 1.14 need to be published again to use Portals.track.

Close the game

document.querySelector("#quit").addEventListener("click", () => {
  Portals.quit();
});

The current host decides how to close the game and restore player controls.

Follow the player's settings

From SDK 1.22.0, Portals.preferences carries the player's Portals settings. Today that is emoteSounds: whether they hear everyone's emote sounds ("on"), only their own ("others-off"), or none ("off"). The Guardian avatar SDK follows it for you; a game that plays emote sounds its own way reads it here:

applyEmoteSounds(Portals.preferences.get().emoteSounds);
Portals.preferences.on("change", (preferences) => applyEmoteSounds(preferences.emoteSounds));

Outside Portals, and until the host answers, get() returns the defaults.

API reference

Method Result
Portals.ready() Resolves to the current player, host context, and private board.
Portals.getPlayer() Resolves to the latest player.
Portals.player.get() Reads the current public username and playable /avatar look.
Portals.avatar.openPicker() Opens trusted global avatar UI and resolves to the refreshed profile.
Portals.identity.requestLogin() Opens Portals sign-in when needed and resolves to the signed-in player.
Portals.identity.onChange(listener) Subscribes to player changes and returns an unsubscribe function.
Portals.saveState(data) Saves JSON state for the signed-in player.
Portals.loadState() Loads JSON state or returns null.
Portals.submitScore(score, mode?, options?) Records a casual score; keeps the best for the mode's ranking unless { replace: true }. A signed-out player needs { name }.
Portals.getLeaderboard(options?) Reads up to 100 top casual scores. { board } reads a private board.
Portals.track(name, props?) Records an analytics event for the game's Analytics tab.
Portals.preferences.get() Reads the player's Portals settings, such as emoteSounds.
Portals.preferences.on("change", handler) Follows the player's settings while the game runs; off stops.
Portals.economy.getCatalog() Reads products frozen into the current release.
Portals.economy.getInventory() Reads this player's game-specific entitlements.
Portals.economy.purchase(sku) Opens Portals-owned Coin confirmation from a player action.
Portals.economy.consume(sku, quantity, operationId) Idempotently uses a consumable grant.
Portals.quit() Requests that the host close the game.

TypeScript declarations are available at portals.d.ts. They declare the global Portals object and every public SDK type.

Access and error handling

Free games may read leaderboards and post scores while signed out; a signed-out score needs { name }. Saving requires sign-in. Paid games receive SDK capabilities only after Portals verifies purchase access.

Every asynchronous method can reject when the host is unavailable, the request is invalid, access is missing, or a network operation fails. Catch errors at the player action that caused them and keep the game playable when an optional Portals feature is unavailable.

Do not place Firebase tokens, API keys, payment details, or signed asset URLs in game code, saved state, score modes, logs, or leaderboard UI.

Screensharing in a Three.js scene

SDK 1.19 adds Portals.screenshare on the standalone game page. The creator and current authorized game collaborators may present; admitted players may watch. A game cannot grant presenting permission itself. Screens are partitioned by game, session and screenId, with one presenter per screen. In managed games the host uses the exact connected matchmaking assignment.

await Portals.ready();
const screen = await Portals.screenshare.join({ screenId: "stage" });
const surface = Portals.screenshare.createThreeTexture(THREE);
const material = new THREE.MeshBasicMaterial({ map: surface.texture });
const mesh = new THREE.Mesh(new THREE.PlaneGeometry(3.2, 1.8), material);
scene.add(mesh);

shareButton.hidden = !screen.canPresent;
shareButton.onclick = () => Portals.screenshare.start().catch(showError);
stopButton.onclick = () => Portals.screenshare.stop().catch(showError);
Portals.screenshare.on("status", state => {
  audioLabel.textContent = state?.audio === "shared" ? "Shared audio" : "No shared audio";
});

// When this scene closes:
async function disposeScreen() {
  await Portals.screenshare.leave();
  surface.dispose();
  material.dispose();
  mesh.geometry.dispose();
  scene.remove(mesh);
}

Call start() from a player click to open the browser's tab/window/screen chooser directly. Browsers that require a click in the host document show a compact inline Share screen action only after the direct call is rejected. Capture stops when the presenter clicks Stop sharing, uses the browser's stop control, leaves the scene, closes the game, or loses the media connection. Permission or lease-renewal failures also stop local capture; reconnecting requires another explicit start and chooser.

Audio is requested but depends on the browser, OS and source. Enable Share audio when the browser offers it. state().audio reports the actual shared audio track; video-only capture stays usable and the host offers instructions to stop and retry with another source. This audio remains separate from Portals.voice microphone chat. Viewers may need to click Enable sound because of browser autoplay rules. setVolume(0…1) controls shared audio playback.

Capture requests up to 3840×2160 at 30 fps, with a single resolution-preserving stream rather than lower-resolution simulcast layers. The actual source, browser and network determine delivered quality; bandwidth pressure can reduce frame rate or add compression. The host preserves aspect ratio on a fixed 3840×2160 surface. createThreeTexture(THREE) uploads transferred image bitmaps directly when supported, with a canvas fallback. canvas() draws frames on demand for other renderers. One transfer may be outstanding, and hidden host tabs pause frame copying. Dispose the texture handle when removing its surface.

Small text remains small when a whole desktop fits onto a distant mesh. Give viewers a closer view or zoom into the texture using Three.js UV transforms; pan by changing offset within the visible bounds:

// Show the middle third of the shared screen; reset with repeat(1, 1), offset(0, 0).
surface.texture.repeat.set(1 / 3, 1 / 3);
surface.texture.offset.set(1 / 3, 1 / 3);

join({ screenId, channel? }), start(), stop(), leave(), state(), canvas(), setVolume(value), createThreeTexture(THREE), and on/off("status"|"frame", handler) form the API. IDs use 1–32 letters, numbers, underscores or hyphens and start with a letter or number. One SDK instance watches one named screen at a time; call leave() before switching. status emits the current snapshot or null after disconnect, and frame emits the delivered frame dimensions.

Desktop browser capture is required to present; browsers without display capture may still watch. Firefox and Safari can provide video-only capture. Shared system/window audio is offered only where the browser and OS support it; the SDK cannot add audio the browser omitted. Screen capture is unavailable in editor previews, room overlays, qualification hosts and standalone local dev-token sessions in this release. LiveKit Cloud is required for presenter-token revocation; a self-hosted media deployment fails closed.