| name | portals-multiplayer-and-voice |
| description | Add real-time multiplayer, in-game text chat, and voice chat to a hosted Portals web game with Portals.net and Portals.voice. Use when working with managed lobbies and matchmaking profiles, Portals.matchmaking, the player-support declaration (singleplayer or multiplayer, max players, competitive or co-op), net.join, sessions and channels, global channels, the Latency-sensitive multiplayer game setting and region-based servers, forcing or pinning a session to the US or EU servers, net.send broadcasts, shared state, playerjoin/playerleave events, rate limits, disconnect handling, voice.join, mute, mic pickers, speaking indicators, region- or zone-scoped voice channels, or testing multiplayer locally with a dev token. |
Portals multiplayer, chat, and voice
Portals.net and Portals.voice are the only multiplayer and voice transports available to a hosted game. The sandbox has no outside network access: fetch, WebSocket, and WebRTC to other servers all fail, and networking or voice libraries cannot work. The Portals host page owns the real connections.
Read references/multiplayer-and-voice.md for the full API, code examples, availability matrix, and dev-token setup. It is the official documentation, copied verbatim.
Both transports ship with the SDK the project already includes:
<script src="./_portals/sdk.js"></script>
<script src="./game.js"></script>
Multiplayer rules that are easy to get wrong
- A session is everyone in the same bucket — all players of the game page, or the people in one Portals room.
join({ channel }) makes a private sub-lobby: letters, numbers, colons, underscores, hyphens; must start alphanumeric; max 64 characters.
- Sessions run on US servers by default — one worldwide room per channel, so players everywhere meet. Turning on Latency-sensitive multiplayer in the game's settings on portals.to/my-games places each session on servers near its players instead — one room per region, so nearby players get low latency but players in different regions never meet. Recommend it for fast-paced games; it applies to new sessions immediately, no republish. The toggle is not settable through
update_web_game_settings — the developer flips it on the settings page.
- In a latency-sensitive game a
global: channel prefix shares one worldwide room again. A match arranged in a global lobby needs a global channel too (global:match-x7), or players split back into their own regions and never meet. Global rooms run in the US home region, so keep fast-paced matches regional.
join({ region: "us" | "eu" }) forces the session onto that region's servers for every player and outranks all of the above, including a global: prefix. Pin only when the game needs one known location (a scheduled event, a persistent world, a region the players share) — distant players pay the latency. Pass the same value for every player of a room: differing pins are different rooms, which is how you would deliberately run separate US and EU worlds. A pin never falls back, so join() rejects if that region has no capacity.
join() resolves to a plain snapshot, not a handle — every method stays on Portals.net. Use net.players(), net.self(), net.getState() for live synchronous reads after joining.
send() does not echo to the sender; the state event does fire for the writer. Apply state changes from the event, not at the call site.
- Limits: 8 KB JSON per message and per state value, 128-character keys, 64 keys per session. About 20 broadcasts and 10 state writes per player per second — never send per-frame; sample on a 100–150 ms timer and interpolate locally.
- Anything a late joiner must see belongs in shared state, not in a broadcast.
- There is no automatic reconnect. On status
disconnected, show a rejoin control that calls join() again.
Player support and managed matchmaking
- Every game declares player support before it can publish: singleplayer, or multiplayer with a max player count (2–100) and a mode (competitive or co-op). Set it with
update_web_game_settings (multiplayer, maxPlayers, multiplayerMode) as part of publishing metadata — a multiplayer game left undeclared is rejected at publish, and one declared singleplayer never appears in a party's "multiplayer games for N players" row. Portals reads the values live at the last publish, so a change needs a new publish to reach players. The one exception is a game published before the declaration existed: it has no live values, so declaring it in settings puts it on the party row on save, no republish needed.
- The declaration is the discovery contract; matchmaking profiles are a separate, optional setting that hands the whole lobby flow to Portals (public/private lobbies, server browser, leader or automatic start). Profiles are
matchmakingProfiles in the game's settings, written through Portals' authenticated project-settings API — not from game code, not through update_web_game_settings, and My Games does not expose a form for them yet, so do not send a creator to a settings page for them. A party must fit inside one team, so for a free-for-all set teamSize equal to maxPlayers and give maxPartySize the same value, or larger parties are quietly rejected even though the match has room.
- In a managed match, detect it with
await Portals.matchmaking.current() ({ managed, visibility, phase, region }, or null in a casual session) and then call net.join() at page load with no channel and no region — the assignment is authoritative and a title screen that waits for a click holds everyone's match start hostage.
- Never fall back to local, bots, or same-tab play when a managed join fails; show the error with retry/reload. The managed join deadline is 60 s (a server may be waking), so do not wrap it in a shorter timeout.
- Give
phase: "starting" a warm-up (players are already in the game while a leader-start lobby fills); Portals.matchmaking.onChange() fires when it flips to "in_progress". With join-in-progress on, handle playerjoin mid-match and rebuild state from join().state.
- Managed lobbies admit guests. A signed-out visitor can join an open public lobby without an account (hosting, private lobbies, and mature or paid games still require sign-in), so a matchmade session can contain players with and no / — never assume everyone in a managed match can save, submit scores, or buy products. Show "guest", keep the match playable for them, and offer from a click where an account is needed.
Voice rules that are easy to get wrong
- Portals owns the microphone and consent. Never call
getUserMedia or enumerateDevices, and never add WebRTC or voice libraries.
- Because consent is Portals' job, call
voice.join() automatically at startup — no "join voice" button. The promise stays pending while the player answers the consent card, so never race it against a short timer.
- A rejection must leave the game fully playable. Show a small "voice off — turn on" control that retries.
- Always render an easy-to-reach mute button. Device labels are empty until mic permission is granted, and
devices() returns a snapshot — re-call it when the picker opens so a mid-game headset appears.
- Put voice UI in HTML overlays, not canvas rendering, so it stays clickable and accessible.
Region-based voice
By default the whole session is one voice room. In a game with distinct places — zones, floors, team bases, vehicles, tables — scope voice to the place the player is in by passing a channel to voice.join(), so players only hear the people they are standing with:
let wantedChannel = null;
let mutePreference = false;
async function joinVoiceRegion(regionId) {
const channel = "region-" + regionId;
if (channel === wantedChannel) return;
wantedChannel = channel;
showVoiceRegion(regionId, "connecting");
try {
await voice.leave();
const session = await voice.join({ channel });
if (wantedChannel !== channel) return;
voice.setMuted(mutePreference);
renderVoiceRoster(session.participants);
showVoiceRegion(regionId, "connected");
} catch (error) {
showVoiceOffControl();
}
}
- A voice channel is a sub-lobby inside the session the host already put the player in — it partitions people who could hear each other anyway, and never reaches another session. Region voice is orthogonal to the geographic regions of
net sessions.
- Switching tears down and rebuilds the connection, so the player hears nobody for a moment. Debounce crossings: require ~1 s inside the new region (or an overlap band between regions) before switching, and never drive it from a per-frame position check. Always keep the "newer switch wins" guard above, or a fast walk through three rooms leaves the roster showing the wrong one.
- Derive the channel from a region id both clients compute identically, and sanitize it to the channel charset. Keep the region set small and named — discrete places, not a grid cell per few metres.
- There is no per-participant volume or spatialization: continuous distance falloff is not possible, discrete regions are the approximation.
- Re-render the roster and clear speaking highlights on every switch —
participants() and speaking ids only ever cover the current channel.
- Who is where is game state: put region membership in
net shared state or broadcasts. The voice roster is not a substitute — players who declined the mic never appear in it.
- Never re-join or leave
net when the voice region changes; that would reset the roster and shared state. The two transports move independently.
- Consent is asked once and later re-joins reuse it, but a re-join can still fail. On failure show the "voice off — turn on" retry and leave the player in the game.
Availability
Portals.net works on the game page, inside Portals rooms, and in editor preview. Portals.voice works only on the game page — rooms run their own voice, and preview has none. Outside Portals, net works only with a dev token and voice never does. Both join() calls need a catch that keeps the game playable.
Local testing
A dev token lets a locally served build talk to the real multiplayer service. Mint it against a game you own, declare window.__PORTALS_DEV__ before the SDK script tag, and serve the SDK from _portals/sdk.js so the HTML is identical locally and on Portals. Local sessions are fenced into a dev: channel namespace. The token is a credential valid for 8 hours — never commit it, never ship it in a bundle, and strip it before pushing.
Related
- Identity, saves, and leaderboards: the
portals-sdk skill.
- Lobbies, ready checks, authoritative countdowns, kicking: the
portals-server-scripts skill.
- Server-authoritative real-time simulation — shared physics, snapshots, prediction: the
portals-server-sim skill.