# CLAUDE Source: https://docs.playflowcloud.com/CLAUDE # CLAUDE.md # Mintlify documentation ## Working relationship * You can push back on ideas-this can lead to better documentation. Cite sources and explain your reasoning when you do so * ALWAYS ask for clarification rather than making assumptions * NEVER lie, guess, or make up information ## Project context * Format: MDX files with YAML frontmatter * Config: docs.json for navigation, theme, settings * Components: Mintlify components ## Content strategy * Document just enough for user success - not too much, not too little * Prioritize accuracy and usability of information * Make content evergreen when possible * Search for existing information before adding new content. Avoid duplication unless it is done for a strategic reason * Check existing patterns for consistency * Start by making the smallest reasonable changes ## docs.json * Refer to the [docs.json schema](https://mintlify.com/docs.json) when building the docs.json file and site navigation ## Frontmatter requirements for pages * title: Clear, descriptive page title * description: Concise summary for SEO/navigation ## Writing standards * Second-person voice ("you") * Prerequisites at start of procedural content * Test all code examples before publishing * Match style and formatting of existing pages * Include both basic and advanced use cases * Language tags on all code blocks * Alt text on all images * Relative paths for internal links ## Git workflow * NEVER use --no-verify when committing * Ask how to handle uncommitted changes before starting * Create a new branch when no clear branch exists for changes * Commit frequently throughout development * NEVER skip or disable pre-commit hooks ## Do not * Skip frontmatter on any MDX file * Use absolute URLs for internal links * Include untested code examples * Make assumptions - always ask for clarification This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## Repository Overview This is the documentation repository for PlayFlow Cloud, a game-server orchestration platform for multiplayer games. The documentation is built using Mintlify, a modern documentation platform optimized for developer docs. ## Common Development Commands ```bash theme={null} # Install Mintlify CLI (required for local development) npm i -g mintlify # Run local development server (default port 3000) mintlify dev # Run on custom port mintlify dev --port 3333 # Check for broken links mintlify broken-links # Troubleshooting: Reinstall dependencies if dev server fails mintlify install ``` ## Architecture & Structure ### Core Configuration * **docs.json** - Main configuration file that defines: * Navigation structure (tabs, groups, pages) * Theme settings (colors, icons, logos) * SEO metadata * API reference integration via OpenAPI specs ### Content Organization * **MDX Format**: All documentation uses MDX (Markdown + JSX components) * **Frontmatter**: Each page includes metadata (title, description, icon) * **Navigation**: Two-tab structure - "Documentation" and "API Reference" ### Key Directories * `/api-reference/` - API reference pages * `introduction.mdx` - API overview, auth, rate limits, error format * Endpoint pages are generated from the live OpenAPI spec at `https://api.computeflow.cloud/api/doc` (configured in `docs.json`). Do not check in local OpenAPI JSON files — the Hono engine serves the canonical spec. * `/unity/` - Unity SDK documentation with lobby system and matchmaking guides * `/guides/` - Tutorials including WebGL deployments and migration guides * `/fundamentals/` - Core concepts like plan instance types * `/images/` - Documentation images and diagrams * `/logo/` - Brand assets (light/dark mode SVGs) ### Mintlify Components Common components used throughout the docs: * `` and `` - Feature cards with icons * ``, ``, `` - Callout boxes * `` - Multi-language code examples * `` - API response examples ### Deployment * Automatic deployment via GitHub App integration * Changes to main branch auto-deploy to production * No manual build process required ## PlayFlow Context This documentation covers: * **Game Server Management**: On-demand server spinning, auto-scaling * **Matchmaking System**: ELO-based, region-aware player matching * **Lobby System**: WebSocket-powered lobbies with chat and ready-checks * **Unity SDK**: Primary SDK focus with comprehensive guides * **WebGL Deployments**: Browser-based game hosting * **Global Infrastructure**: Multi-region, multi-cloud support ## Development Guidelines 1. **MDX Best Practices**: * Use appropriate Mintlify components for better UX * Include code examples in relevant languages (Unity C#, REST API) * Add icons to pages via frontmatter for visual hierarchy 2. **API Documentation**: * API reference endpoint pages are auto-generated from the live OpenAPI spec served by the engine at `https://api.computeflow.cloud/api/doc`. * API contract changes happen in `playflow-core/engine/` (routes, Zod schemas). Regenerate the local snapshot with `npm run generate:openapi` for inspection. * Don't manually edit generated API reference pages. 3. **Navigation Updates**: * Modify `docs.json` to add/remove pages or reorganize structure * Maintain logical grouping (Getting Started → SDK → Advanced) * Use descriptive icons from Lucide icon set 4. **Local Development**: * Always run `mintlify dev` to preview changes * Test navigation and links before committing * Verify images load correctly from `/images/` directory # Create build from Docker image Source: https://docs.playflowcloud.com/api-reference/builds/create-build-from-docker-image https://api.computeflow.cloud/api/doc post /api/v3/builds/docker-image Creates a build from an existing Docker image instead of uploading a ZIP. PlayFlow pulls the image and makes it available for deployment. Processing is triggered automatically — no need to call `/process` separately. Poll `GET /builds/{build_id}` until status is `ready`. For private registries, provide `registry_credentials` with username and password/token. Public images (Docker Hub, ghcr.io public repos) don't need credentials. # Delete a build Source: https://docs.playflowcloud.com/api-reference/builds/delete-a-build https://api.computeflow.cloud/api/doc delete /api/v3/builds/{build_id} Soft-deletes a build and cleans up its stored artifacts. The build status is set to `deleted` and it will no longer appear in list responses or be usable for starting new servers. **Requires a server key (pf_...); client keys (pfclient_*) are rejected with 403.** **Note:** Servers already running with this build are unaffected — deletion only prevents new servers from using it. # Get build details Source: https://docs.playflowcloud.com/api-reference/builds/get-build-details https://api.computeflow.cloud/api/doc get /api/v3/builds/{build_id} Returns the full details of a specific build including its status, version, and type. Use this to poll for status changes during processing (`uploading` → `processing` → `ready` or `failed`). # Get build processing logs Source: https://docs.playflowcloud.com/api-reference/builds/get-build-processing-logs https://api.computeflow.cloud/api/doc get /api/v3/builds/{build_id}/logs Returns paginated build processing logs in chronological order. Each log entry includes a phase (e.g., download, extract, compress, upload), a message, and a severity level. Useful for debugging failed builds — check for `error` level entries. Build logs are also available for live streaming in the PlayFlow dashboard. # Get presigned upload URL Source: https://docs.playflowcloud.com/api-reference/builds/get-presigned-upload-url https://api.computeflow.cloud/api/doc post /api/v3/builds/upload-url Generates a presigned upload URL for uploading your game server as a ZIP file. Upload flow: 1. **Call this endpoint** to get a `build_id` and `upload_url` 2. **PUT your ZIP** directly to the `upload_url` (expires in 5 minutes) 3. **Done!** Processing starts automatically when the upload completes 4. **Poll GET /builds/{build_id}** until status is `ready` Versions auto-increment per build name: first "default" build is version 1, next is version 2, etc. # List builds Source: https://docs.playflowcloud.com/api-reference/builds/list-builds https://api.computeflow.cloud/api/doc get /api/v3/builds Returns a paginated list of builds for your project, sorted by creation date (newest first). Soft-deleted builds are excluded. Use the `name` filter to list versions of a specific build name. # API Introduction Source: https://docs.playflowcloud.com/api-reference/introduction Authenticate, explore endpoints, and download the OpenAPI spec for the PlayFlow REST API. PlayFlow is a game server hosting and orchestration platform. Deploy, manage, and scale multiplayer game servers globally with built-in matchmaking, lobby management, and billing. Download the machine-readable OpenAPI 3.1 spec for use with Postman, code generators, or custom tooling. Try out endpoints directly in the Scalar API playground with live request/response previews. *** ## Authentication All API endpoints (except Health) require the `api-key` header: ```bash theme={null} curl https://api.computeflow.cloud/api/v3/servers \ -H "api-key: pf_your_project_api_key" ``` Each project has two keys: | Key Type | Prefix | Use Case | | :------------- | :----------- | :----------------------------------- | | Server API Key | `pf_*` | Backend services, CI/CD, admin tools | | Client API Key | `pfclient_*` | Game clients, browser apps | Find your API keys in the [PlayFlow Dashboard](https://app.playflowcloud.com) under **Project Settings**. Never expose your **Server API Key** in client-side code. Use the **Client API Key** for anything that ships to players. A few operations — deleting builds and updating project settings — require a **Server API Key**. A Client API Key on these endpoints returns `403`. *** ## Quick Start `POST /v3/builds/upload-url` — get a presigned URL, then `PUT` your ZIP to it. Processing starts automatically when the upload completes. Poll `GET /v3/builds/{id}` until `status` is `ready`. `POST /v3/servers/start` with a `name`, a `region`, and the build's `version_tag` (its name). The response includes a `network_ports` array with the connection details. Read `host` and `external_port` from `network_ports[]` in the response to connect players. Ports are proxy-allocated, so always read them from the response rather than hardcoding a fixed port. For detailed examples with cURL, C#, and Python, see the [full API guide](/fundamentals/api). *** ## Key Concepts * **Builds** — Your game server binary packaged as a ZIP or Docker image. Versioned per name per project. * **Servers** — Running game server instances. Each gets dedicated compute and network ports with automatic health monitoring. Servers launch on-demand and cold-start. * **Regions** — 13 global regions. `region` is required when you start a server. See [Server Regions](/fundamentals/regions) for the full list. * **Compute Sizes** — 9 launchable sizes, from `micro` (1 shared vCPU / 512MB) to `dedicated-xlarge` (2 perf vCPU / 16GB). The default is `small`. See [Plan & Instance Types](/fundamentals/plan-instance-types). *** ## Lifecycles ### Server Lifecycle ``` launching → running (game ports open) → stopped (game exits, TTL expires, or manual stop) ``` ### Build Lifecycle ``` uploading → processing (build pipeline creates deployable image) → ready | failed ``` *** ## Rate Limits | Endpoint Type | Limit | | :------------------------------- | :---------------------- | | Read endpoints (list, get, logs) | 100 req/sec per API key | | Write endpoints (start, upload) | 10 req/sec per API key | Rate limit status is returned via `X-RateLimit-Limit` and `X-RateLimit-Remaining` headers. Exceeding the limit returns `429`. *** ## Plan Limits | Plan | Servers | TTL | Compute Sizes | | :---------------- | :----------- | :---------------------- | :------------ | | **Free** | 1 active max | Forced 1 hour (3600s) | `small` only | | **Pro** (\$20/mo) | Unlimited | Up to 24 hours (86400s) | All sizes | *** ## Error Format All errors return a consistent JSON shape: ```json theme={null} { "error": "Human-readable message", "detail": "Additional context", "status": 404 } ``` Validation errors (422) include structured details: ```json theme={null} { "error": "Validation error", "detail": [ { "loc": ["region"], "msg": "Required", "type": "invalid_type" } ], "status": 422 } ``` *** ## Base URL ``` https://api.computeflow.cloud/api ``` | Version | Prefix | Notes | | :----------------- | :---------- | :--------------------------------------- | | **V3** (canonical) | `/api/v3/*` | OpenAPI-validated, clean response shapes | | **V2** (legacy) | `/api/v2/*` | Compatibility layer for older SDKs | # Browse lobbies Source: https://docs.playflowcloud.com/api-reference/lobbies/browse-lobbies https://api.computeflow.cloud/api/doc get /api/v3/lobbies/{config} Returns a paginated list of public lobbies for the given config. Private (invite-only) lobbies are excluded. Use `status` to filter by lobby state (`waiting`, `in_queue`, `in_game`). Use `region` to filter by region. **`includePrivate`:** Set to `true` to also return private lobbies. Honored ONLY when authenticated with a server key (`pf_*`) — the project owner's own dashboard/admin view. Client keys (`pfclient_*`) always get public-only results, so invite codes stay meaningful. **No x-player-id required.** This endpoint is designed for lobby browsers and server-side matchmaking systems. Results are ordered by creation time (newest first). # Cancel matchmaking Source: https://docs.playflowcloud.com/api-reference/lobbies/cancel-matchmaking https://api.computeflow.cloud/api/doc delete /api/v3/lobbies/{config}/me/matchmaking **Host only.** Removes the lobby from the matchmaking queue and returns to `waiting` status. This clears the `matchmaking` field and stops queue stat updates via SSE. **Note:** If a match has already been found (`match_found` status), cancellation may not be possible and the game will proceed. # Confirm a found match Source: https://docs.playflowcloud.com/api-reference/lobbies/confirm-a-found-match https://api.computeflow.cloud/api/doc post /api/v3/lobbies/{config}/me/confirm-match Accepts a match while the lobby is in `match_found` state (CS2-style 'Accept Match' flow). **Who can call:** Any player in the lobby. Confirmation is per-lobby (parties confirm together). **Behavior:** Records the confirmation. When every lobby in the match has confirmed, the engine automatically launches the game server and transitions every lobby to `in_game`. **Match-confirmation requires the mode to be configured with `matchConfirmation.enabled: true`.** If a mode does NOT enable confirmation, matches transition directly from queue to `in_game` and this endpoint returns `409`. # Create a lobby Source: https://docs.playflowcloud.com/api-reference/lobbies/create-a-lobby https://api.computeflow.cloud/api/doc post /api/v3/lobbies/{config} Creates a new lobby and makes the caller the host. The caller is identified by the `x-player-id` header. The lobby starts in `waiting` status. Other players can join via `POST /{config}/join` using the lobby ID or invite code (if the lobby is private). **Lobby config:** The `{config}` path parameter maps to a lobby configuration defined in your project settings. Each config controls timeout, server size, matchmaking modes, and custom properties. If no matching config exists, sensible defaults are used. **Player state:** Optionally pass `state` to set initial player state (ready status, team, loadout, MMR, etc.). Other players see this in the `players` array. **Private lobbies:** Set `isPrivate: true` to hide the lobby from browsing and generate an invite code. # Decline a found match Source: https://docs.playflowcloud.com/api-reference/lobbies/decline-a-found-match https://api.computeflow.cloud/api/doc delete /api/v3/lobbies/{config}/me/confirm-match Declines a match. **Cancels the match for every participating lobby** — all of them return to `waiting`. Players explicitly re-queue when ready. **Who can call:** Any player in any lobby of the match. **Compare to `cancel-matchmaking`:** Cancel-matchmaking exits the queue entirely (returns to `waiting`). Decline-match also returns to `waiting` but cancels the match for every participating lobby, not just yours. # Delete a lobby by ID (admin) Source: https://docs.playflowcloud.com/api-reference/lobbies/delete-a-lobby-by-id-admin https://api.computeflow.cloud/api/doc delete /api/v3/lobbies/{config}/{id} Force-deletes a lobby by ID. Removes all players and cancels any active matchmaking. Unlike `DELETE /{config}/me` (which makes the caller leave), this bypasses ownership checks — use from server-side admin tools only. **Requires a server key (pf_...); client keys (pfclient_*) are rejected with 403.** **Behavior:** - Stops any running game server best-effort - Deletes the lobby row (cascades all player and matchmaking state stored on it) - Connected SSE clients receive a `lobby_deleted` event and disconnect cleanly Returns `404` if no lobby with the given ID exists in the caller's project for the specified config. # End the current match Source: https://docs.playflowcloud.com/api-reference/lobbies/end-the-current-match https://api.computeflow.cloud/api/doc post /api/v3/lobbies/{config}/me/end-match **Host only.** Ends the current in-game match and returns the lobby to `waiting` status so the same players can start another round (or queue again). **What it does:** 1. Atomically transitions lobby status `in_game` → `waiting` 2. Clears `server`, `matchId`, and `matchmaking` fields 3. Stops the associated game server best-effort 4. Preserves players, settings, and lobby metadata All players receive a `lobby_updated` SSE event with the reset state. **When to use:** Rematch flows, party-persistent games, or any scenario where the host wants to reuse the lobby without forcing players to rejoin. **Natural server exits:** If your game server shuts down on its own, the lobby transitions to `completed` and is cleaned up. Use `end-match` only when the host explicitly wants to rematch with the same group. # Get lobby by ID Source: https://docs.playflowcloud.com/api-reference/lobbies/get-lobby-by-id https://api.computeflow.cloud/api/doc get /api/v3/lobbies/{config}/{id} Returns the full state of a specific lobby by its ID. No `x-player-id` required. **Requires a server key (pf_...); client keys (pfclient_*) are rejected with 403.** Use this for admin dashboards, debugging, and server-side systems that need to inspect lobby state without being a participant. **Note:** This endpoint matches any `{id}` path segment under `{config}`, so it must be registered last to avoid shadowing other routes. # Get my lobby Source: https://docs.playflowcloud.com/api-reference/lobbies/get-my-lobby https://api.computeflow.cloud/api/doc get /api/v3/lobbies/{config}/me Returns the full state of the lobby the caller is currently in. The caller is identified by `x-player-id`. If the lobby is in `in_queue` status (matchmaking active), the response includes live `matchmaking.queueStats` with the number of players searching, lobbies in queue, and average wait time. **Use this to poll lobby state.** For real-time updates, use `GET /{config}/me/events` (SSE) instead. Returns `404` if the player is not currently in a lobby. # Join a lobby Source: https://docs.playflowcloud.com/api-reference/lobbies/join-a-lobby https://api.computeflow.cloud/api/doc post /api/v3/lobbies/{config}/join Joins an existing lobby by its ID or invite code. The caller is identified by `x-player-id`. **By ID:** Set `lobbyId` to the UUID from the browse or create response. **By invite code:** Set `code` to the invite code shared by the host (e.g. `"MEOW-42"`). Exactly one of `lobbyId` or `code` must be provided. **Player state:** Optionally pass `state` to set initial player state on join. **Errors:** - `404` — Lobby not found or invalid invite code - `400` — Lobby is full or not accepting players - `409` — Player is already in a lobby for this config # Kick a player Source: https://docs.playflowcloud.com/api-reference/lobbies/kick-a-player https://api.computeflow.cloud/api/doc delete /api/v3/lobbies/{config}/me/players/{playerId} **Host only.** Removes a player from the lobby by their player ID. The kicked player receives a `lobby_updated` event via SSE showing they are no longer in the lobby. The host cannot kick themselves (use `DELETE /{config}/me` to leave instead). If the kicked player is the last non-host player, the lobby continues with just the host. # Leave a lobby Source: https://docs.playflowcloud.com/api-reference/lobbies/leave-a-lobby https://api.computeflow.cloud/api/doc delete /api/v3/lobbies/{config}/me Removes the caller from their current lobby. If the caller is the host, host duty is transferred to the next player. If the caller is the last player, the lobby is deleted. **Response:** - If the lobby still has players: returns the updated lobby state. - If the lobby was deleted (last player left): returns `{ "status": "lobby_deleted" }`. Players who leave while matchmaking is active are removed from the queue. # Send a player heartbeat Source: https://docs.playflowcloud.com/api-reference/lobbies/send-a-player-heartbeat https://api.computeflow.cloud/api/doc post /api/v3/lobbies/{config}/me/heartbeat Updates the caller's `lastHeartbeat` timestamp. Only needed if the lobby config has `heartbeat: true` enabled — in that case, players whose heartbeat is older than `heartbeatTimeout` seconds are evicted by the `lobby-sweep` cron. Use this for polling clients where the SSE connection (which acts as an implicit heartbeat) isn't available. Send every few seconds (well under `heartbeatTimeout`). # Start matchmaking Source: https://docs.playflowcloud.com/api-reference/lobbies/start-matchmaking https://api.computeflow.cloud/api/doc post /api/v3/lobbies/{config}/me/matchmaking **Host only.** Enters the lobby into the matchmaking queue for the specified mode. The `mode` must be a matchmaking mode defined in the lobby config (e.g. `"ranked_2v2"`, `"casual_ffa"`). **How it works:** 1. Lobby status transitions to `in_queue` 2. The matchmaking system immediately attempts to find compatible opponents 3. If no immediate match, the lobby waits in the queue 4. When matched, status transitions to `match_found` and a game server is started **Queue stats:** The response includes `matchmaking.queueStats` with the number of players searching and average wait time. Players receive live updates via SSE (`queue_stats` events every 10 seconds). **Cancel:** Use `DELETE /{config}/me/matchmaking` to leave the queue and return to `waiting`. # Start the game Source: https://docs.playflowcloud.com/api-reference/lobbies/start-the-game https://api.computeflow.cloud/api/doc post /api/v3/lobbies/{config}/me/start **Host only.** Starts the game for the lobby. This triggers a three-step process: 1. Lobby status transitions to `starting` 2. A game server is provisioned (uses the build and compute size from the lobby config) 3. Lobby status transitions to `in_game` with server connection details The response includes `server.ports` with host and port for game client connections. All players receive a `lobby_updated` event via SSE with the server details. **Server provisioning** typically takes 10-30 seconds (or ~5 seconds with pool servers). The lobby stays in `starting` status until the server is ready. If server provisioning fails, the lobby rolls back to `waiting` status. # Subscribe to lobby events (SSE) Source: https://docs.playflowcloud.com/api-reference/lobbies/subscribe-to-lobby-events-sse https://api.computeflow.cloud/api/doc get /api/v3/lobbies/{config}/me/events Opens a Server-Sent Events (SSE) stream for real-time lobby updates. The caller is identified by `x-player-id`. **Event types:** | Event | Description | Frequency | |-|-|-| | `connected` | Initial event with full lobby state | Once on connect | | `lobby_updated` | Lobby state changed (player joined/left, settings changed, game started, etc.) | On every change | | `lobby_deleted` | Lobby was deleted (all players left or timeout) | Once, then stream closes | | `queue_stats` | Live matchmaking queue statistics | Every 10s while `in_queue` | | `ping` | Keep-alive heartbeat | Every 30s | **Usage (JavaScript):** ```javascript const es = new EventSource('/api/v3/lobbies/casual/me/events', { headers: { 'api-key': 'pf_...', 'x-player-id': 'player123' } }); es.addEventListener('lobby_updated', (e) => { const lobby = JSON.parse(e.data); updateUI(lobby); }); ``` **Connection lifecycle:** The stream stays open until the client disconnects, the lobby is deleted, or the player is kicked. Reconnect automatically on disconnect. # Update lobby settings Source: https://docs.playflowcloud.com/api-reference/lobbies/update-lobby-settings https://api.computeflow.cloud/api/doc patch /api/v3/lobbies/{config}/me/settings **Host only.** Updates the lobby's shared settings (name, max players, region, game settings, etc.). All fields are optional. Omitted fields keep their current values. The `settings` object is merged with the existing settings (existing keys are preserved unless overwritten). **Example:** Change the map and toggle private mode: ```json { "isPrivate": true, "settings": { "map": "desert" } } ``` All players see setting changes in real-time via the SSE stream. # Update my player state Source: https://docs.playflowcloud.com/api-reference/lobbies/update-my-player-state https://api.computeflow.cloud/api/doc patch /api/v3/lobbies/{config}/me Updates the caller's player state within the lobby. State is merged with existing state (existing keys are preserved unless explicitly overwritten). Use this to signal ready status, select a team, set a loadout, report MMR, or store any per-player data that the host or game server needs. **Example states:** - `{ "ready": true }` - `{ "team": "blue", "role": "healer" }` - `{ "mmr": 1500, "loadout": "sniper" }` All players in the lobby see state changes in real-time via the SSE stream (`GET /{config}/me/events`). # Get project settings Source: https://docs.playflowcloud.com/api-reference/projects/get-project-settings https://api.computeflow.cloud/api/doc get /api/v3/projects/settings Returns the complete project configuration including network ports, player authentication, default environment variables, server pool settings, and lobby/matchmaking rules. These settings are applied as defaults to every server started in this project. Individual settings can be overridden per-server at start time. # Get project-wide log-volume histogram Source: https://docs.playflowcloud.com/api-reference/projects/get-project-wide-log-volume-histogram https://api.computeflow.cloud/api/doc get /api/v3/projects/log-histogram Returns a stacked info/warn/error log-volume time-series across ALL servers in the project over the same window as the project logs. Overlay it on fleet resource charts to spot which time bucket a freeze/error spike falls in. Bins are `step` seconds wide (default 60). Empty bins may be omitted — treat gaps as zero. # Get project-wide server logs Source: https://docs.playflowcloud.com/api-reference/projects/get-project-wide-server-logs https://api.computeflow.cloud/api/doc get /api/v3/projects/logs Retrieves aggregated runtime logs from all servers in the project. Each log entry includes the server name, making it easy to filter and search across your entire fleet. Use `start_time` and optionally `end_time` to define the time range. Returns the **most recent** entries within that window (up to `limit`), ordered oldest-to-newest. Narrow results server-side with `search` (case-insensitive substring) and/or `level` (error/warn/info) — both apply across the ENTIRE window, not just the returned page, so you can find a specific error or session ID anywhere in the range. Set `include_counts=true` to also get per-level totals for the window. **Paging back:** if the window holds more than `limit` entries, pass the response's `older_token` as `end_time` (keeping the same `start_time`) to fetch the next older page. **Live tail:** poll with `next_token` as the next `start_time`; it points just after the newest entry returned, so each poll fetches only newer logs. # Update project settings Source: https://docs.playflowcloud.com/api-reference/projects/update-project-settings https://api.computeflow.cloud/api/doc post /api/v3/projects/settings Updates project-level configuration. All fields are optional — only include what you want to change. Omitted fields remain unchanged. **Requires a server key (pf_...); client keys (pfclient_*) are rejected with 403.** **port_configs:** Network ports allocated for every server (max 10). Define game ports, query ports, etc. **auth_config:** Player authentication provider (none, custom_jwt, playfab, steam). **environment_variables:** Default env vars injected into every game server. **pool_config:** Pre-provisioned server pool sizes per region and compute size. **lobby_configs:** Lobby types with matchmaking rules, team structures, and invite codes (max 20). # Close backfill (server-authoritative) Source: https://docs.playflowcloud.com/api-reference/servers/close-backfill-server-authoritative https://api.computeflow.cloud/api/doc post /api/v3/servers/{instance_id}/backfill/lock Closes backfill for the match backing this server — no further joiners are admitted. Convenience shorthand for `PATCH /{instance_id}/backfill` with `accepting: false`. Requires a server API key (`pf_...`). # Get server details Source: https://docs.playflowcloud.com/api-reference/servers/get-server-details https://api.computeflow.cloud/api/doc get /api/v3/servers/{instance_id} Returns the full details of a specific server instance, including its current status, network ports, configuration, and metadata. Use this to poll for status changes (e.g., wait for `launching` → `running`) or to get connection info for game clients. # Get server log-volume histogram Source: https://docs.playflowcloud.com/api-reference/servers/get-server-log-volume-histogram https://api.computeflow.cloud/api/doc get /api/v3/servers/{instance_id}/log-histogram Returns a stacked info/warn/error log-volume time-series for a single server over the same window as the logs. Use it to correlate a spike in error volume with a freeze: overlay these buckets on the CPU/memory/network charts on a shared time axis. Bins are `step` seconds wide (default 60). Empty bins may be omitted — treat gaps as zero. # Get server resource metrics Source: https://docs.playflowcloud.com/api-reference/servers/get-server-resource-metrics https://api.computeflow.cloud/api/doc get /api/v3/servers/{instance_id}/metrics Returns CPU, memory, network, load, and connection metrics for a running server over a configurable time window. Each metric is a time series of `{t, v}` data points where `t` is a Unix timestamp and `v` is the metric value. Use `period` to control the time window (e.g., `1h` for last hour) and `step` for data point resolution (e.g., `60s` for one point per minute). Only available for servers that are currently running. # Get server runtime logs Source: https://docs.playflowcloud.com/api-reference/servers/get-server-runtime-logs https://api.computeflow.cloud/api/doc get /api/v3/servers/{instance_id}/logs Retrieves runtime logs (stdout/stderr) from a game server, including timestamps, log levels, and region info. **Pagination:** Use the `next_token` from the response as `start_time` in the next request to fetch subsequent logs. Returns an empty logs array if the server has never launched. # Get the authoritative match roster Source: https://docs.playflowcloud.com/api-reference/servers/get-the-authoritative-match-roster https://api.computeflow.cloud/api/doc get /api/v3/servers/{instance_id}/roster Returns the authoritative roster of players admitted to the match backing this server. Game servers call this (with their server API key and their own `INSTANCE_ID`) to know exactly which players to admit — this is the source of truth for admission and fixes reconnect / "denied by proxy" edge cases. The response includes per-team slots, total `capacity`, `current_players`, backfill state, and a monotonically increasing `roster_version`. Poll `roster_version` cheaply to detect roster changes (e.g., when a backfill joiner is admitted). Only available for servers that back a match (created through matchmaking/lobbies). # List servers Source: https://docs.playflowcloud.com/api-reference/servers/list-servers https://api.computeflow.cloud/api/doc get /api/v3/servers Returns a paginated list of game servers for your project. By default, only servers in `running` status are returned. Use `include_launching=true` to also see servers that are still starting up, or `include_pool=true` to include pre-provisioned pool machines. # Override backfill state (server-authoritative) Source: https://docs.playflowcloud.com/api-reference/servers/override-backfill-state-server-authoritative https://api.computeflow.cloud/api/doc patch /api/v3/servers/{instance_id}/backfill Lets an advanced game server take control of its match's backfill. Set `accepting` to open or close backfill joiners, and optionally replace the `skillBand` that gates joiners. This is an optional override — the engine manages backfill automatically for modes with backfill enabled. Use this only when the game server has better knowledge of when it can accept joiners (e.g., between rounds). Requires a server API key (`pf_...`). # Restart a server Source: https://docs.playflowcloud.com/api-reference/servers/restart-a-server https://api.computeflow.cloud/api/doc post /api/v3/servers/{instance_id}/restart Restarts a server with a fresh instance. The old machine is replaced and new network ports are allocated. You can optionally change the build version, compute size, TTL, startup args, and other settings during restart. All fields in the request body are optional — omitted fields keep their current values. The server returns to `launching` status. **Note:** Pool servers cannot be restarted. # Start a new server Source: https://docs.playflowcloud.com/api-reference/servers/start-a-new-server https://api.computeflow.cloud/api/doc post /api/v3/servers/start Creates and launches a new game server instance. The server starts in `launching` status and transitions to `running` when the game process opens its configured ports (typically 10–30 seconds, or ~5 seconds if a pool server is available). The response includes `network_ports` with the connection details (host + external_port) that game clients need to connect. **Plan limits:** Free plan allows 1 active server with a 1-hour TTL and `small` compute. Pro plan unlocks unlimited servers with all compute sizes. **Build resolution:** Uses the latest `ready` build matching `version_tag` (defaults to `"default"`). Pin to a specific build with the `version` parameter. # Stop a server Source: https://docs.playflowcloud.com/api-reference/servers/stop-a-server https://api.computeflow.cloud/api/doc delete /api/v3/servers/{instance_id} Stops a running server. The status is updated to `stopped` immediately, and infrastructure cleanup happens in the background. Optionally provide a `shutdown_reason` for analytics (e.g., `PROCESS_EXITED`, `TTL_EXPIRED`). PlayFlow also calls this automatically when the game process exits or TTL expires. # Update server custom_data Source: https://docs.playflowcloud.com/api-reference/servers/update-server-custom_data https://api.computeflow.cloud/api/doc post /api/v3/servers/{instance_id}/update Updates the `custom_data` field on a running server. Use this to store match state, player counts, map info, or any metadata that your game server or matchmaking system needs to expose via the API. The data is immediately visible in list and get responses. # REST API Reference Source: https://docs.playflowcloud.com/fundamentals/api Authenticate, manage game servers, upload builds, and configure projects using the PlayFlow Cloud REST API. The PlayFlow Cloud API gives you full programmatic control over your game server infrastructure. You can start and stop servers, upload and manage builds, configure project settings, and monitor server health -- all through standard HTTP requests. The API is designed for consumption by game clients, backend services, CI/CD pipelines, and AI agents alike. Every endpoint returns predictable JSON with machine-readable status fields and clear error messages. Explore the interactive API reference with schemas, parameters, and try-it-out functionality for every endpoint. ## Prerequisites Before you make your first request, make sure you have: * A PlayFlow account with an organization and a project. * An API key for that project (found under **Project Settings** in the dashboard). Use a **Server API Key** (`pf_*`) for backend and CI/CD calls, or a **Client API Key** (`pfclient_*`) for code that ships to players. * A processed build (`status: ready`) if you plan to start servers -- see [Upload a Build](#upload-a-build). Deleting builds and updating project settings require a **Server API Key**. A Client API Key receives a `403` on those operations. *** ## Authentication All API endpoints (except `/api/health`) require an API key passed via the `api-key` HTTP header. ```bash theme={null} curl https://api.computeflow.cloud/api/v3/servers \ -H "api-key: pf_your_server_api_key" ``` ### API Key Types PlayFlow projects have two API keys, each with a different access level: | Key Type | Prefix | Access Level | Use Case | | :------------- | :----------- | :----------------------------- | :----------------------------------- | | Server API Key | `pf_*` | Full read/write | Backend services, CI/CD, admin tools | | Client API Key | `pfclient_*` | Limited client-safe operations | Game clients, browser apps | You can find and regenerate your API keys in the [PlayFlow Dashboard](https://app.playflowcloud.com) under **Project Settings**. Never expose your **Server API Key** in client-side code or public repositories. Use the **Client API Key** for any code that ships to players. *** ## Base URL All API requests are made to: ``` https://api.computeflow.cloud/api ``` The canonical API version is **v3**. A v2 compatibility layer is available for legacy SDKs but all new integrations should target v3. | Version | Path Prefix | Notes | | :------------- | :---------- | :------------------------------------------------- | | V3 (canonical) | `/api/v3/*` | Zod-validated, OpenAPI spec, clean response shapes | | V2 (legacy) | `/api/v2/*` | Compatibility layer for older SDKs | *** ## Endpoints Overview ### Servers Manage the full lifecycle of your game servers -- create, inspect, update, restart, and stop. | Method | Endpoint | Description | | :------- | :--------------------------------- | :-------------------------------- | | `GET` | `/v3/servers` | List servers for your project | | `POST` | `/v3/servers/start` | Launch a new game server | | `GET` | `/v3/servers/:instance_id` | Get details for a specific server | | `POST` | `/v3/servers/:instance_id/update` | Update a server's `custom_data` | | `DELETE` | `/v3/servers/:instance_id` | Stop a running server | | `POST` | `/v3/servers/:instance_id/restart` | Restart a server | | `GET` | `/v3/servers/:instance_id/logs` | Retrieve server logs | | `GET` | `/v3/servers/:instance_id/metrics` | Retrieve server resource metrics | ### Builds Upload game server binaries and manage the build pipeline. | Method | Endpoint | Description | | :------- | :-------------------------- | :--------------------------------- | | `GET` | `/v3/builds` | List builds for your project | | `POST` | `/v3/builds/upload-url` | Get a presigned URL for ZIP upload | | `POST` | `/v3/builds/docker-image` | Create a build from a Docker image | | `GET` | `/v3/builds/:build_id` | Get build details | | `DELETE` | `/v3/builds/:build_id` | Soft-delete a build | | `GET` | `/v3/builds/:build_id/logs` | Get build processing logs | ### Projects Read and update project-level configuration, and query logs across all servers. | Method | Endpoint | Description | | :----- | :---------------------- | :---------------------------- | | `GET` | `/v3/projects/settings` | Get project configuration | | `POST` | `/v3/projects/settings` | Update project configuration | | `GET` | `/v3/projects/logs` | Query logs across all servers | *** ## Common Operations ### List Servers Retrieve all running servers for your project. By default, only servers with status `running` are returned. ```bash cURL theme={null} curl "https://api.computeflow.cloud/api/v3/servers?include_launching=true&limit=20" \ -H "api-key: pf_your_server_api_key" ``` ```csharp C# theme={null} using var client = new HttpClient(); client.DefaultRequestHeaders.Add("api-key", "pf_your_server_api_key"); var response = await client.GetAsync( "https://api.computeflow.cloud/api/v3/servers?include_launching=true&limit=20" ); var json = await response.Content.ReadAsStringAsync(); ``` ```python Python theme={null} import requests response = requests.get( "https://api.computeflow.cloud/api/v3/servers", headers={"api-key": "pf_your_server_api_key"}, params={"include_launching": "true", "limit": 20}, ) data = response.json() ``` **Query parameters:** Include servers that are still in the `launching` state. Include pre-warmed pool servers in the results. Pre-warmed pooling is not currently enabled, so this filter returns nothing today; every server cold-starts on demand. Maximum number of servers to return (1-100). Number of servers to skip for pagination. **Response:** ```json theme={null} { "total": 3, "total_servers": 3, "servers": [ { "instance_id": "abc123", "name": "us-east-lobby-1", "status": "running", "region": "us-east", "compute_size": "small", "version_tag": "default", "version": 4, "network_ports": [ { "name": "game", "internal_port": 7777, "external_port": 30412, "protocol": "udp", "host": "37.16.24.10", "tls_enabled": false } ], "started_at": "2026-03-15T10:30:00.000Z", "stopped_at": null, "custom_data": { "map": "arena_01" }, "ttl": 3600, "is_pool_server": false, "match_id": null, "auto_restart": false, "created_at": "2026-03-15T10:29:55.000Z", "updated_at": "2026-03-15T10:30:00.000Z" } ], "limit": 50, "offset": 0, "has_more": false } ``` *** ### Start a Server Launch a new game server instance. The server is provisioned immediately and moves through `launching` to `running` status. ```bash cURL theme={null} curl -X POST "https://api.computeflow.cloud/api/v3/servers/start" \ -H "api-key: pf_your_server_api_key" \ -H "Content-Type: application/json" \ -d '{ "name": "us-east-lobby-1", "region": "us-east", "compute_size": "small", "version_tag": "default", "ttl": 3600, "custom_data": { "map": "arena_01", "max_players": 16 } }' ``` ```csharp C# theme={null} using var client = new HttpClient(); client.DefaultRequestHeaders.Add("api-key", "pf_your_server_api_key"); var body = new StringContent( JsonSerializer.Serialize(new { name = "us-east-lobby-1", region = "us-east", compute_size = "small", version_tag = "default", ttl = 3600, custom_data = new { map = "arena_01", max_players = 16 } }), Encoding.UTF8, "application/json" ); var response = await client.PostAsync( "https://api.computeflow.cloud/api/v3/servers/start", body ); ``` ```python Python theme={null} import requests response = requests.post( "https://api.computeflow.cloud/api/v3/servers/start", headers={ "api-key": "pf_your_server_api_key", "Content-Type": "application/json", }, json={ "name": "us-east-lobby-1", "region": "us-east", "compute_size": "small", "version_tag": "default", "ttl": 3600, "custom_data": {"map": "arena_01", "max_players": 16}, }, ) server = response.json() print(f"Instance ID: {server['instance_id']}") ``` **Request body:** A display name for the server instance. The deployment region. See [Server Regions](/fundamentals/regions) for the full list. Server instance size. One of: `micro`, `small`, `medium`, `large`, `xlarge`, `dedicated-small`, `dedicated-medium`, `dedicated-large`, `dedicated-xlarge`. See [Pricing & Plan Types](/fundamentals/plan-instance-types) for specs. The build name to deploy. Defaults to the latest build if not specified. A specific build version number. If omitted, the latest version for the given `version_tag` is used. Command-line arguments passed to your game server executable on launch. Time-to-live in seconds (60-86400). The server automatically stops after this duration. If `true`, the server restarts automatically if the game process exits. Arbitrary key-value data attached to the server. Useful for passing map names, game modes, or match metadata to your game server at runtime. An optional match identifier to associate this server with a specific match or lobby. Key-value string pairs set as environment variables on the server instance. Override the project-level port configuration for this server. Each entry specifies a `name`, `internal_port`, `protocol` (`udp` or `tcp`), and optional `tls_enabled` flag. **Response (201 Created):** ```json theme={null} { "instance_id": "abc123", "name": "us-east-lobby-1", "status": "launching", "region": "us-east", "compute_size": "small", "version_tag": "default", "version": 4, "network_ports": [ { "name": "game", "internal_port": 7777, "external_port": 30412, "protocol": "udp", "host": "37.16.24.10", "tls_enabled": false } ], "startup_args": null, "service_type": "match_based", "started_at": null, "stopped_at": null, "auto_restart": false, "custom_data": { "map": "arena_01", "max_players": 16 }, "ttl": 3600, "is_pool_server": false, "pool_claimed_at": null, "match_id": null, "created_at": "2026-03-15T10:29:55.000Z", "updated_at": "2026-03-15T10:29:55.000Z" } ``` The server starts in `launching` status. Once the game process is ready, the status changes to `running` and `started_at` is populated. You can poll `GET /v3/servers/:instance_id` or use `include_launching=true` when listing servers to track this transition. Always read the connection host and port from `network_ports[]` in the live API response -- never hardcode them. The proxy allocates a fresh `external_port` per server (it is not the same as your `internal_port`, and it is not a fixed value like `7777`). TCP ports resolve through a relay hostname (`relay.computeflow.cloud`); UDP ports return a raw proxy IPv4 address. Read `host` + `external_port` together for each port your client needs. *** ### Stop a Server Shut down a running server and release its resources. ```bash cURL theme={null} curl -X DELETE "https://api.computeflow.cloud/api/v3/servers/abc123" \ -H "api-key: pf_your_server_api_key" ``` ```python Python theme={null} response = requests.delete( "https://api.computeflow.cloud/api/v3/servers/abc123", headers={"api-key": "pf_your_server_api_key"}, ) ``` **Query parameters:** An optional reason for stopping the server, recorded for logging purposes. **Response:** The `status` field is a human-readable confirmation message, not a state enum -- do not branch on it matching `"stopped"`. ```json theme={null} { "status": "Server stopped successfully" } ``` *** ### Upload a Build Uploading a build is a two-step process: request a presigned upload URL, then upload your ZIP file. Processing starts automatically when the upload finishes. Call the upload-url endpoint to get a presigned R2 URL and a `build_id`. The `executable_path` query parameter is optional. If omitted, PlayFlow runs `Server.x86_64` inside your ZIP. Set it when your server binary has a different name. ```bash theme={null} curl -X POST "https://api.computeflow.cloud/api/v3/builds/upload-url?name=default&executable_path=GameServer.x86_64" \ -H "api-key: pf_your_server_api_key" ``` **Response (201 Created):** ```json theme={null} { "build_id": "build_abc123", "name": "default", "upload_url": "https://r2.cloudflarestorage.com/...", "message": "Upload URL generated. PUT your ZIP to upload_url within 60 minutes." } ``` The presigned `upload_url` expires 60 minutes after it is issued. Use the presigned URL to upload your game server build as a ZIP archive. Processing begins automatically once the upload completes. ```bash theme={null} curl -X PUT "https://r2.cloudflarestorage.com/..." \ -H "Content-Type: application/zip" \ --data-binary @my-game-server.zip ``` Poll `GET /v3/builds/{build_id}` until `status` is `ready` (or `failed`). ```bash theme={null} curl "https://api.computeflow.cloud/api/v3/builds/build_abc123" \ -H "api-key: pf_your_server_api_key" ``` You can track build progress by polling `GET /v3/builds/:build_id` or by streaming build logs from `GET /v3/builds/:build_id/logs`. *** ### Create a Build from Docker Image If you already have a containerized game server, you can create a build directly from a Docker image instead of uploading a ZIP. ```bash theme={null} curl -X POST "https://api.computeflow.cloud/api/v3/builds/docker-image" \ -H "api-key: pf_your_server_api_key" \ -H "Content-Type: application/json" \ -d '{ "name": "default", "image_url": "ghcr.io/my-org/game-server:latest", "executable_path": "/app/GameServer", "registry_credentials": { "username": "my-user", "password": "my-token" } }' ``` The build name (used as the `version_tag` when starting servers). The full Docker image URL including tag. Path to the game server executable inside the container. Credentials for private registries. Include `username` and `password` fields. *** ## Response Shapes ### Server Object Every server endpoint returns or includes the following shape: Unique identifier for the server instance. Display name of the server. Current server state: `launching`, `running`, or `stopped`. List of port mappings. Each entry contains `name`, `internal_port`, `external_port`, `protocol`, `host`, and `tls_enabled`. Command-line arguments passed to the game server. Either `match_based` or `persistent_world`. Instance size (e.g., `small`, `large`, `dedicated-medium`). Deployment region identifier. The build name deployed on this server. The build version number, if applicable. ISO 8601 timestamp when the server became `running`. Null while `launching`. ISO 8601 timestamp when the server was stopped. Whether the server restarts automatically on process exit. Arbitrary metadata attached to the server. Time-to-live in seconds, if set. Whether this server was provisioned from the pre-warmed pool. Pre-warmed pooling is not currently enabled, so this is always `false` today. ISO 8601 timestamp when a pool server was claimed. Always `null` while pooling is disabled. Associated match or lobby identifier. ISO 8601 timestamp when the instance record was created. ISO 8601 timestamp of the last update. ### Build Object Unique identifier for the build. Build name, used as the `version_tag` when starting servers. Auto-incremented version number within this build name. Build state: `uploading`, `processing`, `ready`, `failed`, or `deleted`. Either `zip` (uploaded archive) or `docker_image`. Path to the game server executable. Docker image URL, if the build was created from a container image. ISO 8601 timestamp when the build was created. ISO 8601 timestamp of the last update. *** ## Server Logs and Metrics ### Server Logs Retrieve stdout/stderr logs from a running or recently stopped server. ```bash theme={null} curl "https://api.computeflow.cloud/api/v3/servers/abc123/logs?limit=100" \ -H "api-key: pf_your_server_api_key" ``` ISO 8601 timestamp to begin retrieving logs from. ISO 8601 timestamp to stop retrieving logs at. Filter to log lines containing this substring. Filter by log level (e.g. `info`, `warn`, `error`). Number of log entries to return (1-1000). **Response:** ```json theme={null} { "logs": [ { "timestamp": "2026-03-15T10:30:05.123Z", "message": "Server started on port 7777", "level": "info", "instance": "abc123", "region": "us-east" } ], "instance_id": "abc123", "machine_id": "fly-machine-id", "next_token": null, "older_token": null } ``` ### Server Metrics Retrieve CPU, memory, network, and connection metrics for a running server. ```bash theme={null} curl "https://api.computeflow.cloud/api/v3/servers/abc123/metrics?period=1h&step=60s" \ -H "api-key: pf_your_server_api_key" ``` Time window for metrics. One of: `5m`, `15m`, `1h`, `6h`, `24h`. Resolution between data points. One of: `15s`, `30s`, `60s`, `300s`. **Response:** ```json theme={null} { "instance_id": "abc123", "machine_id": "fly-machine-id", "region": "us-east", "period": { "start": 1742036400, "end": 1742040000, "step": "60s" }, "cpu": { "usage_percent": [{ "t": 1742036400, "v": 12.5 }] }, "memory": { "used_mb": [{ "t": 1742036400, "v": 256 }], "total_mb": 1024 }, "network": { "rx_bytes_per_sec": [{ "t": 1742036400, "v": 1024 }], "tx_bytes_per_sec": [{ "t": 1742036400, "v": 2048 }] }, "load": { "average_1m": [{ "t": 1742036400, "v": 0.5 }] }, "connections": { "tcp": [{ "t": 1742036400, "v": 8 }] } } ``` *** ## Error Handling All error responses share a consistent JSON structure: ```json theme={null} { "error": "Human-readable error message", "detail": "Same as error (for legacy SDK compatibility)", "status": 400 } ``` ### Common Status Codes | Status | Meaning | | :----- | :------------------------------------------------------------------- | | `200` | Success | | `201` | Resource created (server started, upload URL generated) | | `202` | Accepted (build processing triggered) | | `401` | Missing or invalid `api-key` header | | `404` | Server, build, or resource not found | | `422` | Validation error (malformed request body or missing required fields) | | `429` | Rate limit exceeded | | `500` | Internal server error | ### Validation Errors When request validation fails, the response includes structured error details: ```json theme={null} { "error": "Validation error", "detail": [ { "loc": ["region"], "msg": "Required", "type": "invalid_type" } ], "status": 422 } ``` *** ## Rate Limiting The API uses token-bucket rate limiting per API key. Rate limit status is returned in response headers. | Endpoint Type | Limit | | :------------------------------------------- | :------------------ | | Read endpoints (list, get, logs, metrics) | 100 requests/second | | Write endpoints (start server, upload build) | 10 requests/second | **Response headers:** | Header | Description | | :---------------------- | :--------------------------------- | | `X-RateLimit-Limit` | Maximum tokens in the bucket | | `X-RateLimit-Remaining` | Tokens remaining before throttling | When the rate limit is exceeded, you receive a `429` response: ```json theme={null} { "error": "Rate limit exceeded", "detail": "Rate limit exceeded. Please retry after a short wait.", "status": 429 } ``` *** ## Regions Deploy your servers across 13 global regions for low-latency gameplay. Pass any of these region IDs to the `region` field when starting a server. | Region | ID | | :------------------------- | :--------------------- | | US East | `us-east` | | US South (Dallas) | `us-south` | | US West | `us-west` | | Europe North (Sweden) | `eu-north` | | Europe West (Germany) | `eu-west` | | Europe (United Kingdom) | `eu-uk` | | Asia South (India) | `ap-south` | | Southeast Asia (Singapore) | `sea` | | Asia North (Japan) | `ap-north` | | Australia | `ap-southeast` | | South Africa | `south-africa` | | South America (Brazil) | `south-america-brazil` | | South America (Chile) | `south-america-chile` | See [Server Regions](/fundamentals/regions) for the full details on each region. *** ## SDK and Tooling Use the PlayFlow Unity SDK for C# wrappers around the REST API, including server management from your game client. Explore every endpoint with the full OpenAPI specification, request/response schemas, and a try-it-out console. # Pricing & Plan Types Source: https://docs.playflowcloud.com/fundamentals/plan-instance-types Explore PlayFlow Cloud's flexible and transparent pricing for game server instances.

Flexible, Transparent Game Server Pricing

PlayFlow Cloud offers usage-based billing with no long-term commitments. Only pay for the compute time your servers actually run.

## Instance Types & Pricing PlayFlow Cloud offers a comprehensive range of server options to power your game's multiplayer infrastructure: * **Standard Instances** - Efficient shared CPU resources with quick deployment. Optimized for development environments, testing, and games with variable player loads. Provides an excellent balance of performance and cost-effectiveness. * **Dedicated Instances** - Guaranteed CPU resources that deliver consistent performance. Ideal for production environments where reliable, predictable performance is critical for player experience. Ensures stability during high-demand periods.

Our complete range of game server instances, from low-cost development options to high-performance dedicated resources.

| Instance Type | CPU Type | vCPU | RAM | Price (USD/hr) | | :----------------- | :-------- | :--- | :---- | :------------- | | `micro` | Standard | 1 | 512MB | \$0.01 | | `small` | Standard | 1 | 1GB | \$0.02 | | `medium` | Standard | 1 | 2GB | \$0.04 | | `large` | Standard | 2 | 4GB | \$0.06 | | `xlarge` | Standard | 4 | 8GB | \$0.12 | | `dedicated-small` | Dedicated | 1 | 2GB | \$0.07 | | `dedicated-medium` | Dedicated | 1 | 4GB | \$0.09 | | `dedicated-large` | Dedicated | 1 | 8GB | \$0.13 | | `dedicated-xlarge` | Dedicated | 2 | 16GB | \$0.26 | `small` is the default compute size if you don't pass `compute_size` when starting a server. Standard instances use shared vCPU; dedicated instances use performance (dedicated) vCPU.

Standard instances use shared CPU resources and are ideal for development, testing, and games with moderate performance requirements.

| Instance Type | vCPU | RAM | Price (USD/hr) | | :------------ | :--- | :---- | :------------- | | `micro` | 1 | 512MB | \$0.01 | | `small` | 1 | 1GB | \$0.02 | | `medium` | 1 | 2GB | \$0.04 | | `large` | 2 | 4GB | \$0.06 | | `xlarge` | 4 | 8GB | \$0.12 |

Dedicated instances provide guaranteed CPU resources, ensuring consistent performance for production games and demanding workloads.

| Instance Type | vCPU | RAM | Price (USD/hr) | | :----------------- | :--- | :--- | :------------- | | `dedicated-small` | 1 | 2GB | \$0.07 | | `dedicated-medium` | 1 | 4GB | \$0.09 | | `dedicated-large` | 1 | 8GB | \$0.13 | | `dedicated-xlarge` | 2 | 16GB | \$0.26 |
## Plans Every new organization starts on the **Free** plan. Upgrade to **Pro** when you need more concurrent servers, longer-running sessions, or access to every compute size. Best for prototyping and testing. * **1** active server at a time * Forced `small` compute size * Forced **1 hour** TTL (server auto-stops after 3600s) * No compute billing — you're never charged * No credit card required Best for launching and scaling a live game. * **Unlimited** active servers * Every compute size (`micro` through `dedicated-xlarge`) * TTL up to **24 hours** (86400s) * Usage-metered compute billing at the per-hour rates above, on top of the \$20/mo base The per-hour instance rates in the tables above apply to metered compute on the Pro plan. Free-plan servers run the `small` size at no charge. ## Key Features Pay only for the compute time your servers run, metered proportionally to runtime. No flat monthly server fees. Transparent pricing with no surprise charges. Unlimited data transfer included with all plans. Launch servers when you need them through the dashboard or API. Servers start on demand and are billed only while they run. Deploy your game servers across 13 regions worldwide to reduce latency for all your players. A `region` is required when you start a server. Get started for free with 1 active server, a forced `small` size, and a 1-hour TTL — no credit card and no compute charges. ## Frequently Asked Questions

PlayFlow Cloud meters your server usage while each server is running. When you start a server, we begin counting its runtime and stop when the server is terminated (or auto-stops at its TTL). Usage is billed proportionally to runtime at the per-hour rate for the compute size, and totals accrue over your billing cycle. Free-plan servers are never charged.

There is no minimum usage requirement or contract. Compute is billed proportionally to how long your server runs (fractional-hour windows at the per-hour rate), so a server that runs for a few minutes costs only a small fraction of its hourly price. Very short sessions may be absorbed rather than metered.

No, PlayFlow Cloud is entirely pay-as-you-go. There are no contracts, minimum spending requirements, or termination fees. You can start or stop using our services at any time without penalty.

Yes! All PlayFlow plans include unlimited data transfer at no extra cost. You'll never be charged for bandwidth — only for the compute time your servers are running. A soft fair-use limit applies to prevent abuse, but it's more than enough for the vast majority of games.

Yes, for large-scale deployments or games with specific requirements, please contact our sales team to discuss custom plans and potential volume discounts. We can tailor solutions for studios of any size and games of any scale.

Reach out via our Discord community or through the support options in your dashboard.

# Server Regions Source: https://docs.playflowcloud.com/fundamentals/regions Deploy your game servers across the globe to ensure the lowest latency for your players. PlayFlow operates a global network of data centers, allowing you to deploy your dedicated servers as close to your players as possible. Choosing the right region is the most effective way to reduce latency and provide a smooth gameplay experience. The `region` field is **required** when you start a server. You select it when launching a new server via the dashboard or through the [Game Servers API](/unity/game-servers). ## Available Regions Below is the full list of currently supported deployment regions. | Region Name | Region ID | Location | Flag | | :------------------------- | :--------------------- | :------------------- | :--: | | North America (US East) | `us-east` | Secaucus, New Jersey | 🇺🇸 | | North America (US Central) | `us-south` | Dallas, Texas | 🇺🇸 | | North America (US West) | `us-west` | Los Angeles | 🇺🇸 | | Europe (Sweden) | `eu-north` | Stockholm | 🇸🇪 | | Europe (Germany) | `eu-west` | Frankfurt | 🇩🇪 | | Europe (United Kingdom) | `eu-uk` | London | 🇬🇧 | | Asia (India) | `ap-south` | Mumbai | 🇮🇳 | | Asia (Singapore) | `sea` | Singapore | 🇸🇬 | | Asia (Japan) | `ap-north` | Tokyo | 🇯🇵 | | Australia | `ap-southeast` | Sydney | 🇦🇺 | | Africa (South Africa) | `south-africa` | Johannesburg | 🇿🇦 | | South America (Brazil) | `south-america-brazil` | São Paulo | 🇧🇷 | | South America (Chile) | `south-america-chile` | São Paulo (nearest) | 🇨🇱 | Region IDs are case-insensitive — pass the exact `Region ID` value shown above (lowercase) when you start a server. # Docker Image Builds Source: https://docs.playflowcloud.com/guides/docker-builds Deploy game servers from your own Docker images. # Docker Image Builds PlayFlow supports two ways to deploy your game server: uploading a ZIP of your server build, or importing a pre-built Docker image. This guide covers the Docker image approach, which gives you full control over your server environment and enables tighter CI/CD integration. ## How It Works When you import a Docker image, PlayFlow pulls it from your container registry and makes it available for server launches. Servers started from a Docker image build skip the ZIP download and extraction step entirely -- the image is ready to run immediately. Docker image builds are ideal for teams that already have a containerized build pipeline. If you are new to containers or prefer a simpler workflow, the [ZIP upload method](/unity/game-servers) handles containerization for you automatically. ## Prerequisites Before you begin, make sure you have: * A PlayFlow project with a **server API key** (the `pf_`-prefixed key from **Project Settings** on the dashboard). Keep it server-side — in CI, store it as a secret. Client keys (`pfclient_`) are meant for game builds and cannot manage builds. * A Docker image pushed to an accessible container registry (Docker Hub, GitHub Container Registry, AWS ECR, etc.) * If your registry is private, the credentials to pull the image ## Importing a Docker Image Build and push your game server image to any Docker-compatible registry. Make sure the image contains everything your server needs to run. ```bash theme={null} docker build -t registry.example.com/my-game-server:v1.0 . docker push registry.example.com/my-game-server:v1.0 ``` Call the Docker image import endpoint with your image URL and, optionally, registry credentials and an executable path. ```bash curl (public registry) theme={null} curl -X POST https://api.computeflow.cloud/api/v3/builds/docker-image \ -H "api-key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "my-server-build", "image_url": "registry.example.com/my-game-server:v1.0" }' ``` ```bash curl (private registry) theme={null} curl -X POST https://api.computeflow.cloud/api/v3/builds/docker-image \ -H "api-key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "my-server-build", "image_url": "registry.example.com/my-game-server:v1.0", "executable_path": "/app/server", "registry_credentials": { "username": "your-username", "password": "your-access-token" } }' ``` The build is created with status `processing`. PlayFlow pulls the image and prepares it for server launches. You can poll the build status endpoint or watch for updates on the dashboard. ```bash theme={null} curl https://api.computeflow.cloud/api/v3/builds/BUILD_ID \ -H "api-key: YOUR_API_KEY" ``` When processing finishes, the status changes to `ready`. Once the build is ready, start a server using it just like any other build. PlayFlow uses the Docker image directly -- no ZIP download or extraction required. You reference a build **by the name you gave it at import**, not by its ID. Pass that name as `version_tag`, and always include a `name` and `region` (both required): ```bash theme={null} curl -X POST https://api.computeflow.cloud/api/v3/servers/start \ -H "api-key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "my-match-1", "region": "us-east", "version_tag": "my-server-build" }' ``` `version_tag` is the build name you chose when importing (here, `my-server-build`) — PlayFlow launches the latest `ready` build with that name. To pin an exact build version, also pass `version` (a number). The `BUILD_ID` you polled in the previous step is only for checking build status; it is not used to launch a server. Read the connection host and port from the `network_ports[]` array in the start response (each entry has `host` and `external_port`) rather than assuming a fixed port — PlayFlow allocates the external port dynamically, and it differs from your internal port. ## Request Body Reference | Field | Type | Required | Description | | :------------------------------ | :----- | :---------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------- | | `name` | string | No (defaults to `"default"`) | The name for this build — this is what you pass as `version_tag` when launching a server. Builds with the same name auto-increment their version number. | | `image_url` | string | Yes | The full image URL including tag (e.g., `registry.example.com/my-server:v1.0`). | | `executable_path` | string | No | The path to the server executable inside the container. Only needed if it cannot be inferred automatically. | | `registry_credentials` | object | No | Credentials for pulling from a private registry. | | `registry_credentials.username` | string | Yes (if credentials provided) | Registry username or service account name. | | `registry_credentials.password` | string | Yes (if credentials provided) | Registry password, access token, or personal access token. | ## Docker Image vs. ZIP Upload **Best for:** advanced users, custom runtimes, CI/CD pipelines, non-standard server setups. * You build and manage the Docker image yourself * Full control over the base image, dependencies, and runtime environment * Faster server startup since the image is pre-built * Integrates naturally with CI/CD -- push to your registry, then import to PlayFlow * Supports any language, framework, or game engine **Best for:** simple deployments, Unity/Godot builds, getting started quickly. * Upload a ZIP of your server binary and PlayFlow handles containerization * No Docker knowledge required * Great for standard Unity or Godot dedicated server builds * PlayFlow manages the image build and optimization ## CI/CD Integration Docker image builds fit naturally into automated pipelines. Here is an example GitHub Actions workflow that builds your server image, pushes it to a registry, and imports it into PlayFlow: ```yaml github-actions.yml theme={null} name: Deploy Game Server on: push: branches: [main] jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Build and push Docker image run: | docker build -t ghcr.io/${{ github.repository }}/server:${{ github.sha }} . echo "${{ secrets.GHCR_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin docker push ghcr.io/${{ github.repository }}/server:${{ github.sha }} - name: Import to PlayFlow run: | curl -X POST https://api.computeflow.cloud/api/v3/builds/docker-image \ -H "api-key: ${{ secrets.PLAYFLOW_API_KEY }}" \ -H "Content-Type: application/json" \ -d '{ "name": "production-server", "image_url": "ghcr.io/${{ github.repository }}/server:${{ github.sha }}", "registry_credentials": { "username": "${{ github.actor }}", "password": "${{ secrets.GHCR_TOKEN }}" } }' ``` ## Tips Always use a specific image tag (e.g., `:v1.0` or a commit SHA) rather than `:latest`. This ensures that your servers run the exact version you tested, and makes rollbacks straightforward. * **Keep images small.** Smaller images pull faster, which reduces build processing time. Use multi-stage builds and minimal base images (e.g., `debian-slim`, `alpine`) where possible. * **Signal readiness from your server.** PlayFlow does not probe a custom health endpoint in your game. Server readiness is reported by the PlayFlow agent sidecar via a status callback once your game process is up, so make sure your server starts and binds its ports promptly. * **Test locally first.** Run your image with `docker run` before importing to PlayFlow to catch configuration issues early. *** ## Next Steps Choose the best deployment region for your players. Review available compute sizes and pricing. # EOS Lobby Integration Source: https://docs.playflowcloud.com/guides/eos-lobby-integration Use Epic Online Services (EOS) lobbies for grouping and identity, and launch the match server on PlayFlow when the lobby owner starts the game. This guide shows you how to pair **Epic Online Services (EOS) lobbies** with PlayFlow game-server hosting. Players group up in an EOS lobby; when the lobby owner starts the match, a dedicated server is launched on PlayFlow and its address is shared back through **EOS Lobby Properties** so everyone can connect. The PlayFlow side is a handful of REST calls, so this works with any engine — Unreal, Unity, or custom — that can call the EOS lobby APIs and make HTTP requests. **Never ship a PlayFlow server key (`pf_...`) inside a game client.** A client holding it could start unlimited servers on your account. Route the "start the server" call through a **trusted service you control** (your backend, or a serverless function). See [Keeping the API key safe](#keeping-the-api-key-safe) below for the exact options PlayFlow gives you. Before getting started, we expect that: * You have a working EOS lobby flow (players can create/join a lobby and read [lobby & member properties](https://dev.epicgames.com/docs/game-services/lobbies)). * You have a PlayFlow project and an uploaded **dedicated Linux server build**. See [Docker builds](/guides/docker-builds). ## How it works ```mermaid theme={null} sequenceDiagram participant O as Lobby Owner (client) participant SV as Your trusted service participant PL as PlayFlow API participant M as Lobby Members (EOS) O->>SV: "Start match" (lobby id, region) SV->>PL: POST /v3/servers/start PL-->>SV: instance_id + network_ports (status: launching) SV->>PL: Poll GET /v3/servers/{id} until status = running SV->>M: Set host:port as EOS Lobby Properties M->>PL: Connect to server Note over O,SV: Match ends SV->>M: Unset EOS Lobby Properties SV->>PL: DELETE /v3/servers/{id} ``` ## Step 1 — Choose a region Edgegap places servers from a list of player IPs. **PlayFlow instead takes an explicit `region`** (one of [13 regions](/fundamentals/regions)) — there is no IP-based auto-placement. Pick one of these approaches: * **Simple:** use a fixed region per lobby (for example, a region the players chose in a menu). * **Geo-aware:** have each member store a coarse location (from their platform or a client-side lookup) as an EOS **member property**, then have your service average them and pick the nearest PlayFlow region. The nearest-region helper from the [PlayFab Bridge guide](/guides/playfab-bridge) works unchanged here. `region` is **required** on `POST /v3/servers/start`. If you can't determine a good region, default to the one closest to most of your player base. ## Step 2 — Start the server when the owner starts the match When the lobby owner decides the match is ready, your trusted service starts a PlayFlow server. PlayFlow allocates the network ports up front, so the response already contains the address — you just have to wait for the server to finish booting before handing it out. ```bash Start the server theme={null} curl -X POST "https://api.computeflow.cloud/api/v3/servers/start" \ -H "api-key: pf_your_server_key" \ -H "Content-Type: application/json" \ -d '{ "name": "lobby-", "region": "us-east", "version_tag": "your-build-name", "ttl": 3600, "environment_variables": { "EOS_LOBBY_ID": "" } }' ``` ```json Response (abridged) theme={null} { "instance_id": "a1b2c3d4-...", "status": "launching", "region": "us-east", "network_ports": [ { "name": "game", "internal_port": 7777, "external_port": 30412, "protocol": "udp", "host": "203.0.113.10", "tls_enabled": false } ] } ``` Pass a `ttl` (seconds) as a safety net so an abandoned lobby can't leave a server running forever. `version_tag` is the **name** of the build you uploaded to PlayFlow. ## Step 3 — Wait for `running`, then publish the address The server starts as `launching`. Poll its status until it reports `running` — that's your "ready" signal — then read the address for your named port from `network_ports` and store it as EOS Lobby Properties. ```bash Poll until ready theme={null} curl "https://api.computeflow.cloud/api/v3/servers/a1b2c3d4-..." \ -H "api-key: pf_your_server_key" # repeat every ~2s until "status": "running" ``` From the port named `game`, take `host` and `external_port` and set them as EOS Lobby Properties (for example `SERVER_HOST` and `SERVER_PORT`). EOS notifies every member of the property change. For **UDP** ports (most game netcode), `host` is a proxy IP and `external_port` is the allocated port. For **TCP** ports, `host` is a PlayFlow relay hostname. Always read both from the response — never hardcode a port; it is allocated per server. ## Step 4 — Members connect When members receive the EOS property-changed notification for `SERVER_HOST` / `SERVER_PORT`, they connect their netcode transport to that address and join the match. Because you only publish the address once the server is `running`, members never receive a dead endpoint. ## Step 5 — End the match (and rematch) When the match is over, your service clears the EOS Lobby Properties (signalling everyone to disconnect gracefully) and terminates the PlayFlow server: ```bash Terminate the server theme={null} curl -X DELETE "https://api.computeflow.cloud/api/v3/servers/a1b2c3d4-...?shutdown_reason=MATCH_ENDED" \ -H "api-key: pf_your_server_key" ``` If the players stay in the lobby, repeat from **Step 2** to run a rematch with the same group. You can also let the server terminate itself — every PlayFlow server is handed `PLAYFLOW_API_KEY`, `PLAYFLOW_API_URL`, and `PLAYFLOW_INSTANCE_ID`, so it can call `DELETE /v3/servers/$PLAYFLOW_INSTANCE_ID` when the last player leaves. ## Keeping the API key safe The security concern is the same as any host: a client that can start servers can be abused. PlayFlow gives you two key types: | Key | Prefix | Use | | -------------- | ----------- | ----------------------------------------------------------------------------------------------------------------- | | **Server key** | `pf_` | Full access. Keep it **only** in a trusted server-side context (your service). | | **Client key** | `pfclient_` | Safe to ship in clients. Can use player-facing lobby/read endpoints; **cannot** touch project settings or builds. | By default a client key *can* start/stop servers. If you want a trusted service to be the **only** thing that can launch servers, set `client_key_server_control` to `false` in your project settings — server lifecycle calls then require a server key, closing the door on client-triggered starts entirely. **Recommended:** the lobby owner's client asks your trusted service to start the match; the service holds the server key and makes the PlayFlow calls. The client never sees `pf_...`. Prefer not to run your own service? PlayFlow's [native lobbies and matchmaking](/lobbies/overview) launch the server for you and deliver connection details over a real-time channel — no external key handling required. # Quick Start - Godot Multiplayer Source: https://docs.playflowcloud.com/guides/godot-first-multiplayer-game Step-by-step tutorial for creating your first multiplayer game in Godot with PlayFlow Cloud # Creating Your First Multiplayer Game in Godot This comprehensive guide shows you how to create a simple multiplayer game from scratch in Godot that connects to a dedicated server hosted on PlayFlow Cloud. This setup is compatible with both web and desktop platforms using TCP + TLS ("wss" protocol). **Godot 4.x** - This guide uses Godot 4.4, but any 4.x version should work. We'll be using the Compatibility renderer for web platform support. Download the complete project files from GitHub (community-maintained sample) ## What You'll Build By the end of this tutorial, you'll have: * A 3D multiplayer game with player movement * Connection UI for server details * WebSocket networking with TLS support * Multiplayer synchronization using Godot's built-in systems * A working dedicated server setup on PlayFlow ## Before We Begin Create a new Godot project and select the **Compatibility** renderer. This ensures web platform compatibility. Create a new scene, save it as "main.tscn" and select **3D Scene** as the root type. Right-click the file after saving and select **Set as Main Scene**. ## Part 1: Building the Connection UI Let's start by creating the user interface that players will use to connect to your PlayFlow server. 1. Open `main.tscn` and add a child node of type **Control** 2. Rename it to "ConnectionDisplay" 3. Set **Custom Minimum Size** to **X: 256.0px** and **Y: 128.0px** 1. Add a child node to "ConnectionDisplay" of type **VBoxContainer** 2. Set **Layout Mode** to **Anchors** and **Anchors Preset** to **Full Rect** 1. Add a child node to "VBoxContainer" of type **HBoxContainer** 2. Add a **TextEdit** child to "HBoxContainer" and rename it to "UrlInput" 3. Set **Placeholder Text** to "Url" 4. Toggle on **Fit Content Height** 5. Set **Custom Minimum Size** to **X: 192.0px** and **Y: 32.0px** 6. Toggle on **Expand** under **Horizontal** 1. Add another **TextEdit** child to "HBoxContainer" and rename it to "PortInput" 2. Set **Placeholder Text** to "Port" 3. Toggle on **Fit Content Height** 4. Toggle on **Expand** under **Horizontal** 1. Add a **Button** child to "VBoxContainer" and rename it to "ConnectButton" 2. Set **Text** to "Connect" 3. Add another **Button** child to "VBoxContainer" and rename it to "DisconnectButton" 4. Set **Text** to "Disconnect" Right-click each input and button node and select **Access as Unique Name**. This allows us to reference them easily in scripts. 1. Right-click "ConnectionDisplay" and select **Attach Script** 2. Create a new script called "connection\_display.gd" 3. Add the following code: ```gdscript theme={null} extends Control @onready var url_input: TextEdit = %UrlInput @onready var port_input: TextEdit = %PortInput @onready var connect_button: Button = %ConnectButton @onready var disconnect_button: Button = %DisconnectButton func _ready() -> void: disconnect_button.visible = false multiplayer.connection_failed.connect(_on_connection_failed) func _on_connect_button_pressed() -> void: connect_button.visible = false disconnect_button.visible = true url_input.editable = false port_input.editable = false MultiplayerManager.start_client(url_input.text, port_input.text) func _on_disconnect_button_pressed() -> void: connect_button.visible = true disconnect_button.visible = false url_input.editable = true port_input.editable = true MultiplayerManager.disconnect_from_server() # If we are unable to connect then we enable the connect button and text inputs again. func _on_connection_failed() -> void: connect_button.visible = true disconnect_button.visible = false url_input.editable = true port_input.editable = true ``` 1. Select the "ConnectButton" and go to the **Node** tab next to the Inspector 2. Connect the "pressed()" signal to "ConnectionDisplay" 3. Repeat for "DisconnectButton" 1. Add a child node to "Main" of type **MeshInstance3D** and rename it to "Ground" 2. Assign a new **PlaneMesh** to the Ground 3. Set the PlaneMesh **Size** to **X: 10m** and **Y: 10m** 4. Add a child node to "Main" of type **Node3D** and rename it to "Players" 5. Add a child node to "Main" of type **MultiplayerSpawner** 6. Add a **Camera3D** as a child to "Main" and position it as desired 7. Add a **DirectionalLight3D** as a child to "Main" and rotate it (suggested: **X: -60.0**) ## Part 2: Creating the Multiplayer Manager Now we'll create a Multiplayer Manager to handle all networking aspects of our game. 1. Create a new scene, save it as "multiplayer\_manager.tscn" 2. Select **Node** as the root type 1. Right-click the root node and select **Attach Script** 2. Create a new script called "multiplayer\_manager.gd" 3. Add the following code: ```gdscript theme={null} extends Node # Internal server port const PORT: int = 8080 @onready var peer = WebSocketMultiplayerPeer.new() func _ready() -> void: # The server should always automatically start if is_server(): start_server() func start_server() -> void: var err: Error = peer.create_server(PORT) if err == 0: multiplayer.multiplayer_peer = peer multiplayer.peer_disconnected.connect(_on_peer_disconnected) func start_client(url: String, port: String) -> void: # If you want to use localhost (i.e you run a windows server export on your PC for testing), then it must be ws:// instead. var err: Error = peer.create_client("wss://" + url + ":" + port) if err == 0: multiplayer.multiplayer_peer = peer func disconnect_from_server() -> void: peer.close() func is_server() -> bool: # Exports have tags and this lets us identify if the build is a dedicated server. return OS.has_feature("dedicated_server") func _on_peer_disconnected(id: int) -> void: var nodes = get_tree().get_nodes_in_group("Players") for node in nodes: if node.get_multiplayer_authority() == id: node.queue_free() break ``` 1. Go to **Project → Project Settings → Autoload** 2. Set **Path** to "res\://multiplayer\_manager.tscn" 3. Set **Node Name** to "MultiplayerManager" 4. Click **Add** This makes the MultiplayerManager available globally throughout your project. ## Part 3: Creating the Player Let's create the player character with movement and networking synchronization. 1. Create a new scene, save it as "player.tscn" 2. Select **3D Scene** as the root type (this creates a Node3D) 3. Add **MeshInstance3D** as a child to Player 4. Assign a **CapsuleMesh** to the MeshInstance3D 5. Move the MeshInstance3D up by **Y: 1.0m** so it sits on the ground 1. Add a **MultiplayerSynchronizer** as a child to Player 2. In the MultiplayerSynchronizer, click **Add property to sync** 3. Select **Player → position** under **Node3D** This ensures player positions are automatically synchronized across all clients. 1. Select the Player root node 2. Go to the **Groups** tab next to the Inspector 3. Add the player to group "Players" (type "Players" and click **Add**) 1. Right-click the Player root node and select **Attach Script** 2. Create a new script called "player.gd" 3. Add the following movement code: ```gdscript theme={null} extends Node3D @export var speed: float = 5.0 func _process(delta: float) -> void: if not is_multiplayer_authority(): return var input_dir: Vector2 = Input.get_vector("move_left", "move_right", "move_forward", "move_backward") var offset: Vector3 = (transform.basis * Vector3(input_dir.x, 0, input_dir.y)).normalized() * speed * delta translate(offset) ``` ## Part 4: Input Mapping and Player Spawning 1. Go to **Project → Project Settings → Input Map** 2. Add these input actions (click **Add New Action** for each): * `move_forward` - Assign W key and Up Arrow * `move_backward` - Assign S key and Down Arrow * `move_left` - Assign A key and Left Arrow * `move_right` - Assign D key and Right Arrow 1. In `main.tscn`, select the **MultiplayerSpawner** node 2. Right-click and **Attach Script**, create "spawner.gd" 3. Add the following code: ```gdscript theme={null} extends MultiplayerSpawner func _ready() -> void: spawn_function = spawn_player # We only want the server spawning new players when new peers connect if MultiplayerManager.is_server(): multiplayer.peer_connected.connect(_on_peer_connected) func spawn_player(peer_id: int) -> Node: var player: Node = load(get_spawnable_scene(0)).instantiate() player.set_multiplayer_authority(peer_id) return player func _on_peer_connected(id: int) -> void: spawn(id) ``` 1. Set **Spawn Path** to "Players" (the Node3D we created earlier) 2. Under **Auto Spawn List**, click **Add Element** 3. Set the scene path to "res\://player.tscn" This tells the spawner which scene to instantiate and where to place new players. ## Part 5: Server Export Configuration 1. Go to **Project → Export** 2. Click **Add...** and select **Linux/X11** 3. Rename the preset to "Linux Server" 4. Set the export path filename to "Server". Note the exact name you use here — when you upload the build, the executable path you configure must match this exported binary name so PlayFlow knows which file to run. 1. In the Linux Server preset, go to the **Resources** tab 2. Set **Export Mode** to "Export as dedicated server" 3. This will create a headless server build suitable for PlayFlow 1. Click **Export Project...** 2. Choose your export location 3. Toggle **Export With Debug** for development builds 4. Click **Save** Create a .zip archive of all exported files. This is what you'll upload to PlayFlow. PlayFlow expects a .zip file containing your server executable and any required assets. ## Part 6: Deploy to PlayFlow and Test 1. Create your PlayFlow account at [app.playflowcloud.com](https://app.playflowcloud.com/) 2. Create a new project for your Godot game 3. Open the project's **Builds** page and upload your server .zip file there Prefer automation? You can upload builds through the API instead: request an upload URL with `POST /api/v3/builds/upload-url?name=YOUR_BUILD` and `PUT` the zip to the returned `upload_url`. See the [API guide](/fundamentals/api) for details. **CRITICAL**: When configuring the port in PlayFlow: 1. Set **Port Number** to **8080** (matches our server code) 2. Set **Protocol** to **TCP** 3. **Enable TLS** - This is required for "wss\://" protocol used in browser games Without TLS enabled, web clients cannot connect to your server. This is essential for browser compatibility. 1. Create and start a server instance in PlayFlow 2. Copy the **Host** URL and **External Port** from the server details The **External Port** is allocated by PlayFlow and will differ from your internal `8080`. Always read the host and external port from the running server (the `network_ports[]` array in the API response, or the server details in the dashboard) rather than assuming a fixed port. 1. Run your Godot project locally (you can enable **Multiple Instances** in Debug settings for testing) 2. Enter the PlayFlow Host URL in the "Url" field 3. Enter the External Port in the "Port" field 4. Click **Connect** 5. Your player should appear and you can move around with WASD keys! **Testing Multiple Players**: Enable **Debug → Customize Run Instances → Enable Multiple Instances** and set instance count to 2. Run the game and connect both instances to see multiplayer in action. ## Technical Architecture ### Network Setup * **Protocol**: WebSocket with TLS ("wss\://") * **Server Port**: 8080 (internal) * **Synchronization**: Player position only * **Authority**: Server-authoritative with client input ### Key Components 1. **MultiplayerManager**: Handles connection logic and server detection 2. **Player**: Networked player with movement and authority management 3. **ConnectionDisplay**: UI for entering server connection details 4. **MultiplayerSpawner**: Automatically spawns players when clients connect ### Platform Support * **Desktop**: Windows, Linux, macOS * **Web**: Browser support via WebSocket + TLS * **Server**: Headless Linux builds for PlayFlow ## Next Steps Add lobbies and matchmaking Deploy for web browsers ## Common Issues * Verify the server URL and port are correct * **Ensure TLS is enabled** in PlayFlow port configuration * Check that the PlayFlow server instance is running * For local testing, use "ws\://" instead of "wss\://" (localhost only) * Check that MultiplayerSynchronizer is properly configured on the player * Verify the player scene is added to MultiplayerSpawner's Auto Spawn List * Ensure players are being added to the "Players" group * Check that spawn\_function is set correctly in spawner.gd * Verify MultiplayerManager is added as an Autoload singleton * Check that `OS.has_feature("dedicated_server")` returns true in server builds * Ensure the "Export as dedicated server" option is enabled in export settings * Check that input actions are properly defined in Project Settings → Input Map * Verify `is_multiplayer_authority()` check in player movement code * Ensure the player has authority set via `set_multiplayer_authority()` ## Resources * **Godot Documentation**: [docs.godotengine.org](https://docs.godotengine.org/) * **Godot Multiplayer**: [docs.godotengine.org/en/stable/tutorials/networking](https://docs.godotengine.org/en/stable/tutorials/networking/) * **PlayFlow Documentation**: [docs.playflowcloud.com](https://docs.playflowcloud.com/) * **Complete Project**: [GitHub Repository](https://github.com/Xaimelee/playflow-quick-start-guide) # Installation Guide Source: https://docs.playflowcloud.com/guides/installation How to install the PlayFlow Unity SDK. You can install the PlayFlow Unity SDK directly from the Unity Package Manager using a Git URL. This is the recommended method as it makes updates easier. ## Prerequisites * **Git** installed on your machine and available on your `PATH`. Unity's Package Manager shells out to Git for git-URL installs, and the install fails silently if Git is missing. * A **supported Unity version**. Check the version matrix in the [SDK repository README](https://github.com/PlayFlowCloud/PlayFlow-Multiplayer-Unity-SDK). ## Installing from Git URL To install the PlayFlow Multiplayer Unity SDK, follow these steps, which are also available on our official [GitHub repository](https://github.com/PlayFlowCloud/PlayFlow-Multiplayer-Unity-SDK). 1. In your Unity project, open the Package Manager by navigating to **Window > Package Manager**. 2. Click the **`+`** button in the top-left corner of the Package Manager window. 3. Select **"Add package from git URL..."** from the dropdown menu. 4. Enter the following URL in the text field: ``` https://github.com/PlayFlowCloud/PlayFlow-Multiplayer-Unity-SDK.git ``` 5. Click **Add**. Unity will now download and install the package and its dependencies into your project. The URL above tracks the tip of the default branch. For reproducible builds, pin a specific release by appending `#` to the URL (for example, `...PlayFlow-Multiplayer-Unity-SDK.git#v1.0.0`). Browse available tags on the [repository's releases page](https://github.com/PlayFlowCloud/PlayFlow-Multiplayer-Unity-SDK/releases). *** ## Next Steps Once the PlayFlow SDK is installed, you are ready to configure it and start building your multiplayer game. Get an overview of the SDK's features and initial setup. Install the required Linux modules for building dedicated servers. # Migration Guide Source: https://docs.playflowcloud.com/guides/migration-guide Migrate your existing game servers from PlayFlow 1.0 to PlayFlow Cloud. # Migrate from PlayFlow 1.0 If you're currently using PlayFlow 1.0, moving to PlayFlow Cloud is a clean re-onboarding: you create a new account, project, and API keys, then re-upload your existing server build. There is no automatic import that reaches back into PlayFlow 1.0 — you bring your build zip forward and launch it against the new engine. **What changed in 2.0.** PlayFlow Cloud is a ground-up rebuild. Servers are launched through a REST engine at `https://api.computeflow.cloud/api`, builds are selected by their **name** (`version_tag`) rather than an internal build ID, every server start now **requires a region**, and connection details (host + port) are returned in the API response instead of being fixed. The steps below map your old workflow onto the new one. ## [Prerequisites](#prerequisites) Before you start, have the following ready: * Your PlayFlow 1.0 **server build zip** (a Linux dedicated-server build). If you no longer have the zip, rebuild it from your game project. * A machine with `curl` (or your language's HTTP client) for the upload and launch steps. * A few minutes to create a new account — there is no data carried over automatically from PlayFlow 1.0. ## [Create your PlayFlow Cloud account](#create-your-playflow-cloud-account) To begin migrating to PlayFlow Cloud, you'll need to create a new account: 1. Visit [app.playflowcloud.com](https://app.playflowcloud.com) 2. Sign up with your email or connect with your GitHub/Google account 3. Verify your email address if prompted Even if you had a PlayFlow 1.0 account, you'll need to create a new account for PlayFlow Cloud. This ensures you get access to all the new features and improved infrastructure. ## [Set up your organization](#set-up-your-organization) Organizations help you manage your game projects and team collaborations: 1. After logging in, you'll be prompted to create an organization 2. Give your organization a descriptive name (e.g., your studio name) 3. *Optional:* Invite team members by entering their email addresses * Team members will receive an invitation email * They can access all projects within your organization ## [Create your project](#create-your-project) Set up a project for your migrated game: 1. From your organization dashboard, click **Create Project** 2. Enter the same name as your PlayFlow 1.0 project (or choose a new one) 3. *Optional:* Add a description to help identify the project 4. Click **Create Project** to proceed ## [Get your API key](#get-your-api-key) Server and build operations run through the engine API, so grab a key first: 1. Open the **API Keys** tab in your new project 2. Copy the **server key** (prefix `pf_`) — it has full access and must stay server-side 3. Note the **client key** (prefix `pfclient_`) for anything you ship inside a game build Authenticate every request with the `api-key` header. Uploading and deleting builds and updating project settings require the **server key** (`pf_`). A client key (`pfclient_`) is rejected with `403` on those operations. Never embed a server key in a shipped game client. ## [Re-upload your build](#re-upload-your-build) There is no legacy importer — you upload your existing build zip to the new project. This is a two-step flow: request a one-time upload URL, then `PUT` your zip to it. Pick a build **name** (`?name=`); PlayFlow auto-increments the version each time you upload under the same name. ```bash Request an upload URL theme={null} curl -X POST "https://api.computeflow.cloud/api/v3/builds/upload-url?name=my-server" \ -H "api-key: pf_your_server_key" ``` ```json Response theme={null} { "build_id": "b3f1c2a4-...", "name": "my-server", "upload_url": "https://...r2.cloudflarestorage.com/...", "message": "Upload URL generated" } ``` ```bash Upload the zip theme={null} curl -X PUT "" \ --data-binary @my-server.zip ``` The `upload_url` expires **60 minutes** after it is issued. Upload your zip before then; if it expires, request a new one. PlayFlow processes the build after upload and moves it from `processing` to `ready`. ## [Verify your build](#verify-your-build) After the upload: 1. Open the **Builds** tab and confirm your build reached **ready** status 2. Check that the build name matches what you'll pass as `version_tag` when launching servers 3. Note the version number — you can pin a specific version at launch, or omit it to use the latest under that name ## [Configure network settings](#configure-network-settings) Port configuration lives under the **Settings** tab, not a separate Configuration tab: 1. Go to the **Settings** tab in your project 2. In the port configuration section, add each port your server listens on: * Set the protocol with the **TCP/UDP** toggle * Enable the separate **TLS** checkbox if that port needs TLS termination 3. Save your settings Ports are **allocated by PlayFlow** at launch and do not match your internal port — don't assume `7777` or any fixed value. When you connect a client, read the public host and port from the server's API response: iterate `network_ports[]` and use its `host` and `external_port`. TCP ports resolve to a relay host; UDP ports resolve to a proxy IPv4. ## [Deploy and test](#deploy-and-test) Now you're ready to launch your migrated server. From the dashboard, open the **Servers** tab and start a server: give it a name, choose a **region**, select your build, and pick a compute size. Or launch it directly through the API: ```bash Launch a server theme={null} curl -X POST "https://api.computeflow.cloud/api/v3/servers/start" \ -H "api-key: pf_your_server_key" \ -H "Content-Type: application/json" \ -d '{ "name": "my-server-1", "region": "us-east", "version_tag": "my-server", "compute_size": "small", "ttl": 3600 }' ``` `name` and `region` are **required**. Select your build with `version_tag` (the build **name** you uploaded) plus an optional `version`. There is no `build_id` field on server start. Region must be one of the 13 canonical regions (for example `us-east`, `eu-west`, `ap-south`, `sea`). Read the connection host and port back from `network_ports[]` in the response, then test with your game client. **Free plan limits.** New organizations start on the Free plan: at most **1 active server**, compute size forced to **small**, and TTL forced to **1 hour** (`3600` seconds). Upgrade to **Pro** (\$20/mo) for all compute sizes, TTL up to 24 hours (`86400` seconds), and unlimited active servers. ## [What's improved in PlayFlow Cloud](#whats-improved-in-playflow-cloud) PlayFlow Cloud offers several advantages over PlayFlow 1.0: Rebuilt server infrastructure with global multi-region deployment Real-time server metrics and detailed logging capabilities Streamlined dashboard and easier project organization Verified player identity via JWT, PlayFab, or Steam providers ## [Need help?](#need-help) If you encounter any issues during migration: * Check our [Discord community](https://discord.gg/P5w45Vx5Q8) for support * Review the [Quick Start guide](/quickstart) for additional setup help * Contact our support team through the dashboard ## [Next steps](#next-steps) Once your migration is complete, explore PlayFlow Cloud's new features: Optimize your servers for browser-based games Simplify deployment directly from Unity Editor # Player Authentication Source: https://docs.playflowcloud.com/guides/player-authentication Configure player identity verification for lobbies and matchmaking. PlayFlow supports authenticating players who connect through the Lobby and Matchmaking system. You configure an auth provider for your project via the engine API, and PlayFlow validates player identity tokens before allowing lobby and matchmaking operations. ## Prerequisites * A PlayFlow project with an API key. Player-token verification only runs for **client keys** (`pfclient_*`, the key you ship in your game build). See [Sending the Player Token](#sending-the-player-token) for why the key type matters. * A **server key** (`pf_*`) to configure the provider — updating project settings requires a server key (a client key returns `403`). * If you use a verified provider, a way for your game client to obtain a token (a JWT, PlayFab entity token, or Steam session ticket). ## How It Works When a player connects to a lobby or enters matchmaking with a client key, their game client sends an identity token in the `x-player-token` header. PlayFlow validates that token against your configured provider, creates or updates a player record, and grants access to the session. When your project uses the default `none` provider, PlayFlow instead trusts the `x-player-id` header verbatim. ```mermaid theme={null} sequenceDiagram participant Client as Game Client participant Provider as Auth Provider participant PF as PlayFlow participant DB as Players Table Client->>Provider: Authenticate (login, session, ticket) Provider-->>Client: Identity token Client->>PF: Lobby/matchmaking request
api-key + x-player-token PF->>Provider: Verify token (JWKS / Steam API) Provider-->>PF: Verified identity PF->>DB: Upsert player record DB-->>PF: Player ID PF-->>Client: Access granted ``` Set the provider on your project with a server key via `POST /api/v3/projects/settings`. See [API Configuration](#api-configuration) below. Your game client authenticates with your chosen provider and receives a token -- a JWT, PlayFab entity token, or Steam session ticket. Include the token in the `x-player-token` header when your client connects to lobbies or matchmaking. PlayFlow verifies the token, creates or updates a player record with the provider's user ID, and grants access. You can view authenticated players in the **Players** tab on the dashboard. ## Auth Providers PlayFlow supports four authentication providers. Choose the one that matches your game's identity system. No server-side verification. In `none` mode, the player's identity comes from the `x-player-id` header, which PlayFlow trusts verbatim. The `x-player-token` header is not consulted in this mode. This is the default for all projects. It is suitable for development and testing but should not be used in production. ### Configuration No configuration is needed. All new projects start with this provider. ```json theme={null} { "provider": "none" } ``` ### Behavior * Send the player's unique identifier in the `x-player-id` header on every player-scoped lobby and matchmaking request. * The `x-player-id` value is used directly as the player identity — there is no verification. * Player-scoped requests without an `x-player-id` header fail with `400 Missing x-player-id header`. * `x-player-token` is ignored in `none` mode. None mode performs no verification. Any client can impersonate any player by sending an arbitrary `x-player-id` value. Use a verified provider (Custom JWT, PlayFab, or Steam) before shipping to production. Validate JWTs from any OIDC-compliant provider using a JWKS (JSON Web Key Set) endpoint. This works with Auth0, Firebase Auth, Supabase Auth, Clerk, and any other provider that publishes a JWKS URL. PlayFlow fetches your public keys from the JWKS endpoint, verifies the JWT signature and expiry, and extracts the `sub` claim as the player identifier. ### Configuration ```json theme={null} { "provider": "custom_jwt", "jwks_url": "https://your-auth-provider.com/.well-known/jwks.json", "issuer": "https://your-auth-provider.com", "audience": "your-app-id" } ``` | Field | Required | Description | | ---------- | -------- | ------------------------------------------------------------------------ | | `jwks_url` | Yes | The URL where your auth provider publishes its public keys. | | `issuer` | No | Expected `iss` claim in the JWT. Rejects tokens from other issuers. | | `audience` | No | Expected `aud` claim in the JWT. Rejects tokens intended for other apps. | ### Common JWKS URLs | Provider | JWKS URL | | ------------- | ------------------------------------------------------------------------------------------- | | Auth0 | `https://YOUR_DOMAIN.auth0.com/.well-known/jwks.json` | | Firebase Auth | `https://www.googleapis.com/service_accounts/v1/jwk/securetoken@system.gserviceaccount.com` | | Supabase Auth | `https://YOUR_PROJECT.supabase.co/auth/v1/.well-known/jwks` | | Clerk | `https://YOUR_DOMAIN.clerk.accounts.dev/.well-known/jwks.json` | JWKS keys are cached in memory and rotated automatically when your provider publishes new keys. You do not need to restart or redeploy after a key rotation. Native integration with Microsoft PlayFab. PlayFlow verifies PlayFab entity tokens (JWTs) against PlayFab's published JWKS endpoint and extracts the `eid` (entity ID) claim as the player identifier. ### Configuration ```json theme={null} { "provider": "playfab", "title_id": "YOUR_TITLE_ID" } ``` | Field | Required | Description | | ---------- | -------- | ---------------------------------------------------------- | | `title_id` | Yes | Your PlayFab Title ID (found in the PlayFab Game Manager). | PlayFlow automatically constructs the JWKS URL from your Title ID: ``` https://{title_id}.playfabapi.com/jwks ``` ### Getting a PlayFab Entity Token In your game client, call `GetEntityToken` after the player logs in: ```csharp theme={null} PlayFabAuthenticationAPI.GetEntityToken( new GetEntityTokenRequest(), result => { string entityToken = result.EntityToken; // Pass this as x-player-token when connecting to PlayFlow }, error => Debug.LogError(error.GenerateErrorReport()) ); ``` Validates Steam authentication tickets via the Steamworks Web API. Unlike the JWT-based providers, this makes an HTTP call to Steam's servers for each authentication. ### Configuration ```json theme={null} { "provider": "steam", "app_id": "YOUR_APP_ID", "web_api_key": "YOUR_WEB_API_KEY" } ``` | Field | Required | Description | | ------------- | -------- | --------------------------------------------------------------------------------------------------------------------- | | `app_id` | Yes | Your Steam Application ID. | | `web_api_key` | Yes | Your Steam Web API Key (from the [Steamworks partner site](https://partner.steamgames.com/doc/webapi_overview/auth)). | PlayFlow calls the `ISteamUserAuth/AuthenticateUserTicket/v1/` endpoint and extracts the `steamid` as the player identifier. ### Getting a Steam Auth Ticket In your game client, use the Steamworks API to get a session ticket: ```csharp theme={null} // Using Steamworks.NET byte[] ticketData = new byte[1024]; uint ticketLength; HAuthTicket ticket = SteamUser.GetAuthSessionTicket(ticketData, 1024, out ticketLength); string hexTicket = BitConverter.ToString(ticketData, 0, (int)ticketLength).Replace("-", ""); // Pass hexTicket as x-player-token when connecting to PlayFlow ``` Your Steam Web API Key is stored in your project's `auth_config`. Set it only with a server key, treat it as a secret, and never expose it in a client build. ## API Configuration Configure player authentication by writing an `auth_config` to your project settings via the engine API. This is the only supported way to set the provider. Updating project settings requires a **server key** (`pf_*`). A client key (`pfclient_*`) returns `403`. Keep your server key server-side — never ship it in a game build. ```bash None theme={null} curl -X POST https://api.computeflow.cloud/api/v3/projects/settings \ -H "api-key: YOUR_SERVER_KEY" \ -H "Content-Type: application/json" \ -d '{ "auth_config": { "provider": "none" } }' ``` ```bash Custom JWT theme={null} curl -X POST https://api.computeflow.cloud/api/v3/projects/settings \ -H "api-key: YOUR_SERVER_KEY" \ -H "Content-Type: application/json" \ -d '{ "auth_config": { "provider": "custom_jwt", "jwks_url": "https://your-auth.com/.well-known/jwks.json", "issuer": "https://your-auth.com", "audience": "your-app" } }' ``` ```bash PlayFab theme={null} curl -X POST https://api.computeflow.cloud/api/v3/projects/settings \ -H "api-key: YOUR_SERVER_KEY" \ -H "Content-Type: application/json" \ -d '{ "auth_config": { "provider": "playfab", "title_id": "YOUR_TITLE_ID" } }' ``` ```bash Steam theme={null} curl -X POST https://api.computeflow.cloud/api/v3/projects/settings \ -H "api-key: YOUR_SERVER_KEY" \ -H "Content-Type: application/json" \ -d '{ "auth_config": { "provider": "steam", "app_id": "YOUR_APP_ID", "web_api_key": "YOUR_WEB_API_KEY" } }' ``` You can verify the current configuration by reading your project settings: ```bash theme={null} curl https://api.computeflow.cloud/api/v3/projects/settings \ -H "api-key: YOUR_SERVER_KEY" ``` The response includes your current `auth_config` alongside other project settings. Changes take effect immediately for all new player connections. ## Sending the Player Token **Token verification only runs for client keys (`pfclient_*`).** Ship a client key in your game build so PlayFlow verifies each player's `x-player-token` against your provider and derives identity from the verified token. A **server key (`pf_*`) bypasses verification** even when a provider is configured — it is trusted to assert identity directly via `x-player-id` (for trusted server-side matchmakers and admin tooling). If you test a verified provider with a server key, verification silently passes through and you may wrongly conclude it works. Once your provider is configured, your game client (using a client key) must include the player's identity token in the `x-player-token` header on every player-scoped lobby and matchmaking request. In `none` mode, send `x-player-id` instead (no token). The PlayFlow SDK handles the token header automatically when you set the player token during initialization: ```csharp theme={null} // After authenticating with your provider, pass the token to PlayFlow string playerToken = GetTokenFromYourAuthProvider(); PlayFlowLobbyManagerV2.Instance.Initialize(playerToken, () => { Debug.Log("PlayFlow SDK initialized with authenticated player."); }); ``` With a verified provider and a client key, send the token on a player-scoped request. PlayFlow verifies it and derives the player identity — no `x-player-id` needed: ```bash theme={null} curl -X POST https://api.computeflow.cloud/api/v3/lobbies/default \ -H "api-key: YOUR_CLIENT_KEY" \ -H "x-player-token: PLAYER_IDENTITY_TOKEN" \ -H "Content-Type: application/json" \ -d '{"name": "My Lobby", "maxPlayers": 4}' ``` In `none` mode, identify the player with the `x-player-id` header: ```bash theme={null} curl -X POST https://api.computeflow.cloud/api/v3/lobbies/default \ -H "api-key: YOUR_API_KEY" \ -H "x-player-id: player-123" \ -H "Content-Type: application/json" \ -d '{"name": "My Lobby", "maxPlayers": 4}' ``` ## Players Table Players verified by a provider are automatically tracked in a per-project players table. Each time a player is verified against **Custom JWT, PlayFab, or Steam**, PlayFlow upserts a record with the following fields: | Field | Description | | -------------- | ------------------------------------------------------------------------------------------------- | | `id` | Unique player ID (UUID), auto-generated by PlayFlow. | | `provider` | The auth provider that verified this player (`custom_jwt`, `playfab`, `steam`). | | `provider_uid` | The player's identifier from the auth provider (e.g., JWT `sub`, PlayFab `eid`, Steam `steamid`). | | `display_name` | Display name extracted from the token, if available. | | `metadata` | Provider-specific metadata (e.g., `owner_steamid` for Steam, `title_id` for PlayFab). | | `last_seen_at` | Timestamp of the player's most recent authentication. | Players are uniquely identified by the combination of `project_id` and `provider_uid`. If the same player authenticates again, their existing record is updated rather than duplicated. `none`-mode players are **not** written to the players table — no persisted record is created for them. They may appear transiently in a live lobby or match roster, but only verified-provider players are persisted. You can view your authenticated players in the **Players** tab on the project dashboard. ## Choosing a Provider Use **None** to get started quickly without any auth infrastructure. Switch to a verified provider before launching. Use **Custom JWT** if you already have an auth provider (Auth0, Firebase, Supabase, Clerk, or any OIDC-compliant service). Use **PlayFab** if your game uses Microsoft PlayFab for player management and you want native integration. Use **Steam** if your game is distributed on Steam and you want to verify player identity through Steam session tickets. ## Next Steps Set up lobbies and start using player authentication with the lobby system. Add skill-based matchmaking with authenticated players. # PlayFab Bridge Source: https://docs.playflowcloud.com/guides/playfab-bridge Keep PlayFab for matchmaking and identity while hosting your dedicated game servers on PlayFlow. This guide shows you how to automatically deploy a PlayFlow game server when players are matched through **PlayFab's matchmaker**, and how clients discover the server through **PlayFab Player Data** — which the server itself updates once it is ready. It's the same pattern popularized by other hosting providers, mapped onto PlayFlow's API. If you're already invested in PlayFab for matchmaking and identity, this lets you adopt PlayFlow for server hosting without moving off PlayFab. **PlayFlow also has a native [lobby and matchmaking system](/lobbies/overview).** If you're starting fresh, that path is simpler — no PlayFab required. Use this bridge when you specifically want to keep PlayFab's matchmaker or player identity. Before getting started, we expect that: * You currently use PlayFab (a title, a matchmaking queue, and some registered players). * You have a PlayFlow project and a **dedicated Linux server build** ready to upload. See the [Docker builds guide](/guides/docker-builds) or the [Unity quickstart](/quickstart/fishnet) if you don't yet. ## How it works PlayFab's matchmaker fires a `playfab.matchmaking.match_found` event. A PlayFab Automation rule runs a CloudScript function that calls `POST /api/v3/servers/start`, passing the PlayFab match ID, title secret, and player list as environment variables. PlayFlow allocates the network ports immediately and boots your build. It injects everything the server needs as environment variables (`PLAYFLOW_PORTS`, your custom vars, and API credentials). On startup, the server reads its own public `host:port` from `PLAYFLOW_PORTS` and writes it into each matched player's PlayFab Player Data — keyed by the match ID. Each client polls its PlayFab ticket until matched, then reads the connection info from Player Data and connects. Because the server only writes Player Data once it's actually running, clients never get a stale address. ```mermaid theme={null} sequenceDiagram participant C as Game Client participant PF as PlayFab participant CS as PlayFab CloudScript participant PL as PlayFlow API participant S as Game Server C->>PF: CreateMatchmakingTicket PF-->>PF: match_found event PF->>CS: Automation rule → StartPlayFlowDeployment CS->>PL: POST /api/v3/servers/start (env: match id, secret, players) PL->>S: Boot build, inject PLAYFLOW_PORTS + env S->>PF: UpdateUserData → "host:port" (keyed by match id) C->>PF: Poll ticket → Matched → GetUserData PF-->>C: host:port C->>S: Connect ``` ## Step 1 — Prepare your PlayFlow project In your PlayFlow dashboard, open your project's **API Keys** and copy a **server key** (`pf_...`). This key can start servers and must stay secret — treat it exactly like your PlayFab Title Secret. Never ship it in a client build. Upload your dedicated Linux server build and give it a **name** (for example `space-edge`). PlayFlow references builds by this name — the value becomes the `version_tag` you pass when starting a server, and each upload under the same name auto-increments its version. See [Docker builds](/guides/docker-builds) for the upload flow. In your project settings, add a port config and give it a **name** you'll look up later — this guide uses `game`. Choose `UDP` for most game netcode (for example FishNet's Tugboat transport), or `TCP` (optionally with TLS) for WebSocket-based clients. PlayFlow allocates the public port at launch; your server reads it back from `PLAYFLOW_PORTS`. ## Step 2 — Create the PlayFab CloudScript function Log in to PlayFab, open your title, and under **Automation → Revisions (Legacy)** deploy a new revision containing the function below. It calls PlayFlow's start endpoint and forwards everything the server will need. Update the four variables at the top with your own values first. The `PlayFlow_BuildName` must match the build name you uploaded in Step 1. Keep this function server-side. It holds your PlayFlow **server key** and PlayFab **Title Secret** — the CloudScript is the trusted interface that keeps both hidden from players. ```javascript theme={null} var PlayFlow_ApiUrl = "https://api.computeflow.cloud/api"; var PlayFlow_ApiKey = "pf_your_server_key"; // PlayFlow server key — keep secret var PlayFlow_BuildName = "space-edge"; // the build NAME you uploaded var PlayFab_TitleSecret = "TITLE_SECRET"; // PlayFlow regions with approximate coordinates, for nearest-region selection. // Full list: https://docs.playflowcloud.com/fundamentals/regions var PlayFlow_Regions = [ { region: "us-east", lat: 40.7, lon: -74.0 }, { region: "us-south", lat: 32.8, lon: -96.8 }, { region: "us-west", lat: 34.0, lon: -118.2 }, { region: "eu-west", lat: 50.1, lon: 8.7 }, { region: "eu-north", lat: 59.3, lon: 18.1 }, { region: "eu-uk", lat: 51.5, lon: -0.1 }, { region: "ap-south", lat: 19.1, lon: 72.9 }, { region: "sea", lat: 1.35, lon: 103.8 }, { region: "ap-north", lat: 35.7, lon: 140.4 }, { region: "ap-southeast", lat: -33.9, lon: 151.2 }, { region: "south-america-brazil", lat: -23.5, lon: -46.6 }, { region: "south-africa", lat: -26.2, lon: 28.0 }, ]; function nearestRegion(points) { // Average the players' locations, then pick the closest PlayFlow region. if (!points.length) return "us-east"; var avgLat = points.reduce(function (s, p) { return s + p.lat; }, 0) / points.length; var avgLon = points.reduce(function (s, p) { return s + p.lon; }, 0) / points.length; var best = null, bestD = Infinity; PlayFlow_Regions.forEach(function (r) { var d = Math.pow(r.lat - avgLat, 2) + Math.pow(r.lon - avgLon, 2); if (d < bestD) { bestD = d; best = r.region; } }); return best; } handlers.StartPlayFlowDeployment = function (args, context) { var QueueName = context.playStreamEvent.Payload.QueueName; var MatchId = context.playStreamEvent.Payload.MatchId; var members = multiplayer.GetMatch({ QueueName: QueueName, MatchId: MatchId, EscapeObject: false, ReturnMemberAttributes: false, }).Members; var locations = []; var playerIds = []; members.forEach(function (member) { var profile = entity.GetProfile({ Entity: member.Entity }); var masterId = profile.Profile.Lineage.MasterPlayerAccountId; var loc = server.GetPlayerProfile({ PlayFabId: masterId, ProfileConstraints: { ShowLocations: true }, }); var l = loc.PlayerProfile.Locations && loc.PlayerProfile.Locations[0]; if (l) locations.push({ lat: l.Latitude, lon: l.Longitude }); playerIds.push(masterId); }); var region = nearestRegion(locations); var response = JSON.parse( http.request( PlayFlow_ApiUrl + "/v3/servers/start", "post", JSON.stringify({ name: "match-" + MatchId.slice(0, 20), region: region, version_tag: PlayFlow_BuildName, environment_variables: { PLAYFAB_MATCH_ID: MatchId, PLAYFAB_TITLE_SECRET: PlayFab_TitleSecret, PLAYFAB_MATCH_PLAYERS_ID_LIST: playerIds.join(","), }, }), "application/json", { "api-key": PlayFlow_ApiKey } ) ); log.info("PlayFlow server starting", response); }; ``` PlayFlow returns the `instance_id`, a `status` of `launching`, and the allocated `network_ports` **synchronously** from this call — the ports are reserved before the machine boots. This guide has the *server* publish the address (Step 4) so that connection info appears in Player Data exactly when the server is ready. If you'd rather publish from CloudScript, you can instead poll `GET /api/v3/servers/{instance_id}` until `status` is `running` and read `network_ports` from the response. ## Step 3 — Add the automation rule Under **Automation → Rules**, create a new rule: * **Event Type**: `playfab.matchmaking.match_found` * **Action** → **Type**: `Execute Entity CloudScript` * **CloudScript Function**: `StartPlayFlowDeployment` Now every completed match automatically launches a dedicated PlayFlow server. ## Step 4 — Publish the address from the server When PlayFlow starts your server, it injects these environment variables into the container (alongside any `environment_variables` you passed from CloudScript): | Variable | Description | | --------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | | `PLAYFLOW_PORTS` | JSON array of the server's public ports: `[{ "name", "internal_port", "external_port", "protocol", "host", "tls_enabled" }]` | | `PLAYFLOW_INSTANCE_ID` | This server's instance ID | | `PLAYFLOW_API_KEY` / `PLAYFLOW_API_URL` | Credentials + base URL to call the PlayFlow API back if needed | | `PLAYFLOW_REGION` | The region the server launched in | | Your custom vars | `PLAYFAB_MATCH_ID`, `PLAYFAB_TITLE_SECRET`, `PLAYFAB_MATCH_PLAYERS_ID_LIST` | On startup, read the public address for your named port out of `PLAYFLOW_PORTS`, then write `host:external_port` into each matched player's Player Data, keyed by the match ID. ```csharp PlayerDataUpdateService.cs theme={null} public class PlayerDataUpdateService : NetworkBehaviour { [SerializeField] private string ServerPortName = "game"; // must match your PlayFlow port config name private PlayfabRestApiService _playfab; public override void OnStartServer() { _playfab = FindObjectOfType(); string matchId = Environment.GetEnvironmentVariable("PLAYFAB_MATCH_ID"); string titleSecret = Environment.GetEnvironmentVariable("PLAYFAB_TITLE_SECRET"); string[] playerIds = Environment.GetEnvironmentVariable("PLAYFAB_MATCH_PLAYERS_ID_LIST")?.Split(','); string portsJson = Environment.GetEnvironmentVariable("PLAYFLOW_PORTS"); if (string.IsNullOrEmpty(portsJson)) { Debug.LogError("PLAYFLOW_PORTS not set — is this running on PlayFlow?"); return; } // PLAYFLOW_PORTS is a JSON array; find the port config by name. PlayFlowPort[] ports = JsonConvert.DeserializeObject(portsJson); PlayFlowPort game = Array.Find(ports, p => p.name == ServerPortName); if (game == null) { Debug.LogError($"No PlayFlow port named '{ServerPortName}'"); return; } string connection = $"{game.host}:{game.external_port}"; foreach (string playerId in playerIds) { if (!string.IsNullOrEmpty(playerId)) _playfab.UpdatePlayerData(titleSecret, playerId, matchId, connection); } } [Serializable] private class PlayFlowPort { public string name; public int external_port; public string host; public string protocol; public bool tls_enabled; } } ``` The PlayFab write itself is unchanged from any PlayFab server integration — authenticate with the Title Secret and call `Server/UpdateUserData`: ```csharp PlayfabRestApiService.cs (server) theme={null} public async void UpdatePlayerData(string secretKey, string playerId, string key, string value) { if (!_http.DefaultRequestHeaders.Contains("X-SecretKey")) _http.DefaultRequestHeaders.Add("X-SecretKey", secretKey); var data = new JObject { [key] = value }; var body = new JObject { ["PlayFabId"] = playerId, ["Data"] = data }; var content = new StringContent(body.ToString(), Encoding.UTF8, "application/json"); var response = await _http.PostAsync($"https://{TitleID}.playfabapi.com/Server/UpdateUserData", content); if (!response.IsSuccessStatusCode) Debug.Log($"Could not update Player Data for {playerId}: {(int)response.StatusCode}"); } ``` ## Step 5 — Connect from the client The client side is **pure PlayFab** and identical to any PlayFab matchmaking integration — PlayFlow isn't involved until the client reads the address. The flow: Authenticate the player with PlayFab (for example `LoginWithCustomID`) and store the entity + session tokens. Call `Match/CreateMatchmakingTicket` on your queue and keep the returned ticket ID. Poll `Match/GetMatchmakingTicket` every few seconds until `Status` is `Matched`, then store the `MatchId`. Poll `Client/GetUserData` with the `MatchId` as the key until the server has written it, then set your transport's address/port and connect. Retry for a few seconds — the server writes Player Data the moment it boots, which is typically a few seconds after the match. ```csharp Reading the address and connecting (client) theme={null} private async void StartConnectionAttempt() { var body = new JObject { ["Keys"] = new JArray { _matchId } }; var content = new StringContent(body.ToString(), Encoding.UTF8, "application/json"); while (true) { var res = await _http.PostAsync($"https://{TitleID}.playfabapi.com/Client/GetUserData", content); var json = JObject.Parse(await res.Content.ReadAsStringAsync()); var data = json["data"]?["Data"]; var entry = data?[_matchId]?["Value"]?.ToString(); if (!string.IsNullOrEmpty(entry)) { string address = entry.Split(':')[0]; if (ushort.TryParse(entry.Split(':')[1], out ushort port)) { InstanceFinder.TransportManager.Transport.SetClientAddress(address); InstanceFinder.TransportManager.Transport.SetPort(port); InstanceFinder.ClientManager.StartConnection(); } return; } await Task.Delay(3000); // server not ready yet — retry } } ``` Have the client remove its match entry from Player Data once connected (`Client/UpdateUserData` with `KeysToRemove`) so stale connection info doesn't accumulate. ## Step 6 — Build, upload, and test Produce a dedicated Linux server build with your netcode configured to start on headless and listen on the internal port that matches your PlayFlow port config. Upload the build under the same **name** you referenced as `PlayFlow_BuildName` in the CloudScript (`space-edge` here). See [Docker builds](/guides/docker-builds). Launch two PlayFab-authenticated clients and start matchmaking on both. When PlayFab matches them, your rule fires, PlayFlow boots the server, the server publishes its address, and both clients connect to the same deployment. ## Why this works well on PlayFlow * **The server is handed its own public address.** `PLAYFLOW_PORTS` contains the resolved `host` and `external_port` for every named port — no self-lookup or metadata call required. * **Readiness is automatic.** Because the server writes Player Data in `OnStartServer`, connection info only appears once the process is actually up. Clients that poll can't grab a dead address. * **The server can talk back to PlayFlow.** `PLAYFLOW_API_KEY` and `PLAYFLOW_API_URL` are injected too, so the server can stop itself when the match ends (`DELETE /api/v3/servers/{instance_id}`) or update its own metadata — no extra secrets to manage. * **Set a TTL as a safety net.** Pass a `ttl` (seconds) on `POST /servers/start` so an abandoned match can't leave a server running forever. See the [API reference](/fundamentals/api). When you're ready to simplify further, PlayFlow's [native matchmaking](/lobbies/matchmaking) removes the PlayFab round-trip entirely — the matchmaker launches the server and hands the connection details straight to your players over a real-time channel. # Pool Servers Source: https://docs.playflowcloud.com/guides/pool-servers Pre-warmed game servers to reduce launch latency (upcoming feature). # Pool Servers **Pool servers are not currently available.** This feature is in active development. Today, every server cold-starts on demand — there are no pre-warmed standby servers, no instant claim, and no idle billing discount, regardless of any `pool_config` you set. This page describes how pool servers are intended to work once the feature ships. Nothing on this page functions at runtime yet. Pool servers are a planned feature: pre-provisioned game servers that sit warm and ready to be claimed, so you can skip the on-demand launch wait when a match is found. This is intended for competitive matchmaking and any game where launch latency matters. Until pool servers ship, use the standard on-demand [server start](/fundamentals/api) flow — every server is created fresh on each start. ## How Pool Servers Will Work The flow below describes the intended design. None of these steps run today — the pool system is disabled, so configuration is stored but no standby servers are provisioned or claimed. In your project settings, you define which regions and compute sizes you want pre-provisioned, and how many servers to keep warm in each. PlayFlow launches and maintains the configured number of idle servers. These servers are running and ready to accept players, but are not yet assigned to any game session. When your game needs a server (for example, when matchmaking finds a match), you claim a pool server via the API. The claimed server becomes yours and you receive its connection details in the response, skipping the on-demand launch wait. After a server is claimed, PlayFlow launches a replacement to maintain your configured capacity. A reconciliation process keeps the pool at the target size. Pool servers are designed to use the same infrastructure and runtime environment as on-demand servers. The only difference is that they are pre-launched and waiting. ## Configuring Your Pool Pool configuration is stored via the project settings endpoint. (Storing it has no runtime effect today — no standby servers are provisioned until the feature ships.) Updating project settings requires a **server key** (`pf_...`). Client keys (`pfclient_...`) receive a `403`. Keep server keys server-side and never ship them in a game build. Use the project settings endpoint to configure your pool programmatically: ```bash theme={null} curl -X POST https://api.computeflow.cloud/api/v3/projects/settings \ -H "api-key: pf_YOUR_SERVER_KEY" \ -H "Content-Type: application/json" \ -d '{ "pool_config": { "enabled": true, "regions": { "us-east": { "small": 3, "medium": 2 }, "eu-west": { "small": 2 } } } }' ``` ### Configuration Reference The `pool_config` object has the following structure: | Field | Type | Description | | :-------------------- | :------ | :----------------------------------------------------------------- | | `enabled` | boolean | Whether pool provisioning is active for this project. | | `regions` | object | A map of region IDs to compute size configurations. | | `regions.` | object | A map of compute size names to the number of servers to keep warm. | Keep 3 small servers warm in US East: ```json theme={null} { "pool_config": { "enabled": true, "regions": { "us-east": { "small": 3 } } } } ``` Spread warm servers across multiple regions for global coverage: ```json theme={null} { "pool_config": { "enabled": true, "regions": { "us-east": { "small": 3, "medium": 2 }, "eu-west": { "small": 2 }, "ap-north": { "small": 1 } } } } ``` To disable the pool and stop provisioning idle servers: ```json theme={null} { "pool_config": { "enabled": false, "regions": {} } } ``` ## Claiming a Pool Server Once pool servers ship, you will start a server exactly as you do today via `POST /api/v3/servers/start`. If a matching pool server is available in the requested region and compute size, PlayFlow will assign it rather than launching a new machine. **Today this always launches a fresh on-demand server** — the request below is the standard start call and works now; the pool fast-path is what will change once the feature is enabled. `name` and `region` are required. Select a build with `version_tag` (the build **name**) and optional `version`. There is no `build_id` field on server start. ```bash theme={null} curl -X POST https://api.computeflow.cloud/api/v3/servers/start \ -H "api-key: pf_YOUR_SERVER_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "ranked-1", "region": "us-east", "version_tag": "default", "compute_size": "small" }' ``` Read the connection details from the response: use the `host` and `external_port` of each entry in `network_ports[]` rather than hardcoding a host or port. The proxy-allocated `external_port` differs from your internal port — never assume `7777` or any fixed value. When pool servers are eventually claimed, the response will additionally set `pool_claimed_at` and `is_pool_server: true` on the server record. When pool servers are available and none matches the requested region and size, PlayFlow will fall back to launching a new server on demand. Your game logic does not need to handle this distinction — the API response format is the same either way. ## Identifying Pool Servers Once the feature ships, pool servers will be visible in the server list alongside on-demand servers, identifiable by the `is_pool_server` field: ```bash theme={null} curl https://api.computeflow.cloud/api/v3/servers \ -H "api-key: pf_YOUR_SERVER_KEY" ``` While pooling is disabled, these fields stay dormant: every server is a cold-create, so `is_pool_server` is always `false` and `pool_claimed_at` is always `null`. In the response, pool servers will include: | Field | Description | | :---------------- | :--------------------------------------------------------------------------------------- | | `is_pool_server` | `true` for servers provisioned by the pool system. | | `pool_claimed_at` | Timestamp when the server was claimed. `null` if the server is still idle and unclaimed. | ## Billing Because pool servers do not run today, there is no separate pool billing. Every server you launch is an on-demand server billed at the standard rate for its compute size. The billing model for pooled standby servers has not been finalized and will be documented here when the feature ships. See [Pricing & Instance Types](/fundamentals/plan-instance-types) for the standard rates by compute size. ## Limits * **Maximum 10 servers** per region per compute size in the pool configuration * Pool reconciliation will run automatically to provision or remove servers to match the target count (not active while the feature is disabled) * Pool servers will follow the same plan limits as on-demand servers (Free plan: max 1 active server; Pro plan: unlimited servers) ## When Pool Servers Will Help * **Competitive matchmaking** where fast server availability is critical * **High-traffic games** with frequent match starts and predictable demand * **Tournament or event systems** where matches need to start promptly * **Games with short session times** where launch latency is a significant fraction of playtime * **Low-traffic games** where the on-demand launch wait is acceptable * **Development and testing** where launch speed is not a priority * **Long-session games** (e.g., survival or sandbox) where the initial startup time is negligible relative to session length *** ## Next Steps Choose regions for your pool based on where your players are located. Review compute sizes and pricing to plan your pool configuration. # Deploy from Unity Matchmaker Source: https://docs.playflowcloud.com/guides/ugs-matchmaker Keep your Unity Game Services (UGS) Matchmaker and deploy the match servers to PlayFlow via a custom allocator. Follow this guide if you want to keep the **Unity Game Services (UGS) Matchmaker** and use **PlayFlow** as your server host. When the matchmaker assembles a match, a small Cloud Code **allocator** you deploy calls PlayFlow to start a server and returns the connection details to the matched players. Prefer an all-in-one path? PlayFlow has native [matchmaking](/lobbies/matchmaking) with [Unity SDK support](/unity/matchmaking) — no UGS required. Use this guide when you specifically want to keep the Unity Matchmaker. Before getting started, we expect that: * You have a working UGS Matchmaker setup. Refer to [Unity's Matchmaker documentation](https://docs.unity.com/ugs/manual/matchmaker/manual/matchmaker-overview) for matchmaking topics. * You have a PlayFlow project and an uploaded **dedicated Linux server build**. See [Docker builds](/guides/docker-builds). Unlike Multiplay, there is no pre-built "PlayFlow allocator" in Unity's hosting-providers repo — you author a small Cloud Code allocator (below). It's just an HTTP call to PlayFlow, so this stays a lightweight, fully custom integration. ## How it works UGS Matchmaker groups players and invokes your allocator Cloud Code function with the matched player list. Your allocator calls `POST /api/v3/servers/start`. PlayFlow reserves the ports immediately and returns the `instance_id` and `network_ports`. The allocator waits for the server to reach `running`, then stores the `host:port` for the match (in UGS Cloud Save / your game data) keyed so the matched players can find it. Each client retrieves its assignment and connects. ## Step 1 — Store your PlayFlow key as a UGS secret In the [Unity Cloud dashboard](https://cloud.unity.com/), under **Administration → Secrets**, add: * `PLAYFLOW_API_KEY` — a PlayFlow **server key** (`pf_...`) from your project's API Keys. The allocator runs in Cloud Code (server-side), so the key is never exposed to clients. ## Step 2 — Region selection Edgegap places servers from player IP addresses; **PlayFlow takes an explicit `region`** instead (one of [13](/fundamentals/regions)) — there is no IP-based placement. Have each player attach a coarse location or preferred region as **matchmaking ticket custom data**, then pick a region in the allocator: ```csharp Attach region hint to the ticket theme={null} var ticketOptions = new CreateTicketOptions( queueName: "my-queue", attributes: new Dictionary { { "region_hint", "us-east" } // or a lat/long your allocator maps to a region } ); ``` If you send lat/long instead of a region name, reuse the `nearestRegion()` helper from the [PlayFab Bridge guide](/guides/playfab-bridge) to map the match's average location to one of PlayFlow's regions. ## Step 3 — The allocator Deploy a Cloud Code function that the matchmaker calls when a match is assembled. Its PlayFlow responsibilities are: 1. Start a server with the matched players and chosen region. 2. Wait until the server is `running`. 3. Store the connection details so clients can look them up. The PlayFlow calls are identical to any REST client: ```javascript Allocator (Cloud Code) — PlayFlow calls theme={null} const PLAYFLOW_API = "https://api.computeflow.cloud/api"; async function startPlayFlowServer({ apiKey, region, buildName, matchId, playerIds }) { // 1. Start the server const start = await fetch(`${PLAYFLOW_API}/v3/servers/start`, { method: "POST", headers: { "api-key": apiKey, "Content-Type": "application/json" }, body: JSON.stringify({ name: `match-${matchId}`, region, version_tag: buildName, // the build NAME you uploaded ttl: 3600, // safety net environment_variables: { UGS_MATCH_ID: matchId, UGS_PLAYER_IDS: playerIds.join(","), }, }), }).then((r) => r.json()); // 2. Wait for the server to be ready let instance = start; while (instance.status !== "running") { await new Promise((res) => setTimeout(res, 2000)); instance = await fetch(`${PLAYFLOW_API}/v3/servers/${start.instance_id}`, { headers: { "api-key": apiKey }, }).then((r) => r.json()); if (instance.status === "stopped") throw new Error("Server failed to start"); } // 3. Return the public address of the "game" port const game = instance.network_ports.find((p) => p.name === "game"); return { instanceId: instance.instance_id, host: game.host, port: game.external_port, }; } ``` Because PlayFlow returns `network_ports` **synchronously** from the start call, you don't need Edgegap's deployment-id-plus-webhook dance. The only wait is for `status` to flip to `running`. For very high match rates, kick off the poll asynchronously and store the assignment when it completes rather than blocking the allocator. Store `{ host, port, instanceId }` against the match (UGS Cloud Save, a database, or the matchmaker's assignment results) so the matched players — looked up by `UGS_PLAYER_IDS` — can retrieve it. For the **UGS-side contract** — how your allocator is registered, invoked by the matchmaker, and how assignment results are returned to clients — follow Unity's official [hosting-providers allocator template](https://github.com/Unity-Technologies/matchmaker-hosting-providers). This guide only covers the PlayFlow calls that go inside it. ## Step 4 — Retrieve the assignment on the client Clients fetch their assignment (via the matchmaker's standard assignment flow or by reading the game data your allocator wrote), then connect their transport to `host:port`: ```csharp Connect once assigned theme={null} InstanceFinder.TransportManager.Transport.SetClientAddress(host); InstanceFinder.TransportManager.Transport.SetPort(port); InstanceFinder.ClientManager.StartConnection(); ``` ## Step 5 — Clean up When the match ends, terminate the server so you stop paying for it: ```bash Terminate the server theme={null} curl -X DELETE "https://api.computeflow.cloud/api/v3/servers/?shutdown_reason=MATCH_ENDED" \ -H "api-key: pf_your_server_key" ``` The server can also stop itself — PlayFlow injects `PLAYFLOW_API_KEY`, `PLAYFLOW_API_URL`, and `PLAYFLOW_INSTANCE_ID` into the container, so your headless build can call `DELETE /v3/servers/$PLAYFLOW_INSTANCE_ID` when the last player disconnects. Setting a `ttl` at start time guarantees cleanup even if nothing calls delete. # Unity Modules Setup Source: https://docs.playflowcloud.com/guides/unity-modules Install the required Unity modules to build dedicated servers for PlayFlow. # Unity Modules Setup To build **Unity dedicated servers** for PlayFlow, you need to install specific Linux build modules. PlayFlow deploys servers on Linux infrastructure, so these modules are essential for creating compatible server builds. Without the proper Linux modules installed, Unity will not be able to build dedicated servers for PlayFlow deployment. ## Prerequisites * **Unity Hub** installed, so you can add build modules to your Unity installation. * The **PlayFlow Unity SDK** installed in your project (see below). ## Install the PlayFlow Unity SDK First, make sure you have the PlayFlow Unity SDK installed. The recommended method is the Unity Package Manager Git URL: ``` https://github.com/PlayFlowCloud/PlayFlow-Multiplayer-Unity-SDK.git ``` Step-by-step instructions for installing the PlayFlow Unity SDK via the Package Manager Git URL ## Required Unity Modules by Version The modules you need depend on your Unity version. Install the appropriate Linux build support modules: ### Unity 2021.2 and Above For Unity 2021.2+, install these modules through Unity Hub: Unity 2021.2+ Linux Modules **Required Modules:** * **Linux Build Support (IL2CPP)** - Essential for Linux server builds * **Linux Build Support (Mono)** - Alternative scripting backend support ### Unity 2020.x and Earlier For Unity versions older than 2021.2, install these modules: Unity 2020 Linux Modules **Required Modules:** * **Linux Build Support** - Core Linux build capability * **Linux Build Support (IL2CPP)** - IL2CPP scripting backend * **Linux Build Support (Mono)** - Mono scripting backend Unity versions older than 2021.2 have different module names but serve the same purpose for Linux server builds. ## How to Install Unity Modules ### Via Unity Hub 1. Open **Unity Hub** 2. Go to **Installs** tab 3. Click the **gear icon** next to your Unity version 4. Select **Add Modules** 5. Check the **Linux Build Support** options shown above 6. Click **Install** ### Via Unity Editor 1. Open Unity Editor 2. Go to **File → Build Settings** 3. Select **Linux** as target platform 4. If modules are missing, Unity will prompt you to install them 5. Click **Install with Unity Hub** ## Verify Installation To confirm your modules are installed correctly: 1. Open **File → Build Settings** 2. You should see **Linux** available in the Platform list 3. Select Linux and click **Switch Platform** 4. If successful, you're ready to build dedicated servers *** ## Next Steps Once your Unity modules are properly installed, you're ready to build and deploy your first server: Build and deploy your first dedicated server with PlayFlow Complete PlayFlow setup instructions Deploy browser-based multiplayer games Explore new features and improvements # WebGL Deployments Source: https://docs.playflowcloud.com/guides/webgl-deployments Learn how to configure your servers to accept connections from WebGL clients. # WebGL Deployments To allow players to connect to your game servers from web-based games (WebGL), you must configure your server's network port to accept secure connections. The process is simple and only requires enabling one extra setting. **Prerequisites** * A PlayFlow project with a build uploaded and ready * Access to the project **Settings** page in the dashboard * A WebGL client that connects over WebSockets ## The Key Requirement: TCP with TLS Web browsers require secure WebSocket connections (`wss://`) for WebGL games. To enable this, set your game server's port to use the **TCP** protocol and turn on the separate **TLS** option for that port. If your port uses `UDP`, or uses `TCP` without TLS enabled, WebGL clients will not be able to connect over `wss://`. ## How to Configure Your Server for WebGL Follow these steps in your PlayFlow project dashboard: Open the project **Settings** page in your dashboard and find the **Network Ports** section (under **Configuration**). For the port your game server uses, set the **Protocol** to **TCP**. The protocol control is a toggle between **UDP** and **TCP**. With TCP selected, a separate **TLS** checkbox appears for that port. Enable it. For example, if your server listens on port `8080`, your configuration should be: * **Port:** `8080` * **Protocol:** `TCP` * **TLS:** enabled Save your configuration. Any new servers you deploy for this project will now support WebGL connections. ## How It Works You don't need to change your server code. Your game server can continue to listen for unencrypted TCP traffic on its port. PlayFlow automatically handles the TLS encryption and termination at our infrastructure level. * **Your WebGL Client:** Connects to a secure `wss://` address. * **PlayFlow Infrastructure:** Receives the secure connection, handles the TLS, and forwards the traffic to your server as standard TCP. * **Your Game Server:** Receives standard TCP traffic, no TLS implementation needed. ## Client-Side Connection PlayFlow does not hand you a ready-made `wss://` URL — you build it from the connection details of your running server. Read the connection host and port from the server's API response (`GET /api/v3/servers/{id}`), from the `network_ports[]` array. Each entry includes the public `host` and the proxy-allocated `external_port`. These values are also shown on the server's detail page in the dashboard as a copyable `host:port` value, with a shield indicator when TLS is enabled. The external port is allocated by the proxy and differs from the internal port your server listens on. Never hardcode a port — always read `external_port` from the response. Construct your WebGL client's connection URL as: ```text theme={null} wss://: ``` *** ## Next Steps Deploy your first game server. Review the setup and installation process. # What's New in PlayFlow 2 Source: https://docs.playflowcloud.com/guides/whats-new-playflow-2 Discover the powerful new features and improvements in PlayFlow 2 that make multiplayer game development easier than ever. # What's New in PlayFlow 2 PlayFlow 2 represents a major leap forward in multiplayer game server management. We've rebuilt our platform from the ground up to deliver better performance, more features, and an even simpler developer experience. ## [Lobby & Matchmaking](#lobby-matchmaking-sdk) PlayFlow 2 introduces **built-in lobbies and matchmaking** - the feature game developers have been asking for most. It's a backend feature you access through the v3 REST API, and (for Unity projects) through the first-class Unity SDK. Configurable matchmaking with rule-based player matching (difference, equals, not\_equals, region, and expression rules) Verified player identity via custom JWT, PlayFab, or Steam providers ### What you get The lobby and matchmaking system provides: * **Lobby management** for player groups and game sessions, with public and invite-code private lobbies * **Rule-based matchmaking** with configurable rules and regions * **Player authentication** with custom JWT, PlayFab, and Steam providers * **Real-time lobby state** streamed to clients over a one-way SSE event stream * **Automatic server allocation** when a match is ready The backend is engine-agnostic REST, so any client that can speak HTTP can use it. Unity is the first-class SDK; other engines integrate directly against the REST API. See the [lobby & matchmaking guide](/lobbies/matchmaking) and the [API reference](/fundamentals/api). ## [Enhanced Performance & Infrastructure](#enhanced-performance-infrastructure) PlayFlow 2 delivers significantly better performance without increasing costs. Access to upgraded server hardware with better CPU performance and memory All performance improvements come at no additional cost to you ### New Orchestrator Technology Our **brand new orchestrator** has been built from the ground up to provide: * **Global deployment** across 13 regions worldwide, from us-east and eu-west to ap-south and south-america-brazil * **On-demand provisioning** — servers launch when you call the API and shut down on their TTL, so you only pay for what you run * **Improved reliability** with better error handling and recovery `region` is required when you start a server. Choose the region closest to your players for the lowest latency. See [Instance Types](/fundamentals/plan-instance-types) for the full region and compute-size list. ## [Built-in Monitoring & Logging](#built-in-monitoring-logging) No more juggling multiple tools. PlayFlow 2 includes comprehensive monitoring and logging right out of the box. All server logs are automatically collected and searchable from your dashboard Real-time metrics and performance dashboards for all your servers Monitor everything in one place: * **Server performance metrics** (CPU, memory, network) * **Player connection statistics** and session data * **Error tracking** and debugging information * **Custom logging** from your game server applications ## [Unlimited Projects & Build Management](#unlimited-projects-build-management) Say goodbye to project limitations. PlayFlow 2 lets you create **unlimited projects** to organize your games exactly how you want. Create as many projects as you need to categorize your different games and experiments All your builds are automatically preserved with full version history Perfect for: * **Multiple game titles** under one organization * **Experimental projects** and prototypes * **Different game versions** (mobile, PC, console) * **Build archiving** for long-term project maintenance ## [Organizations & Team Collaboration](#organizations-team-collaboration) PlayFlow 2 introduces **Organizations** - a powerful way to manage your game development teams and projects. Easily add and remove team members from your organization with role-based access control All team members can access and contribute to projects within your organization With organizations, you can: * **Invite team members** by email and manage their access * **Share projects** across your entire development team * **Centralize billing** and resource management * **Maintain consistent workflows** across all your game projects Organizations make it easy to scale your team from solo development to large studios, all while maintaining security and access control. ## [Designed for Scale](#designed-for-scale) Whether you're running a single test server or managing thousands of concurrent game sessions, PlayFlow 2 scales with your needs. Perfect for indie developers just getting started with multiplayer Seamlessly scales as your player base and team expand Launch as many servers as you need on demand — start each one with a single API call You launch and stop game servers on demand through the API (or dashboard) — there is no fleet autoscaler to configure. Your game or backend decides when to start a server and when to shut it down. ### Simplified Experience Despite all these powerful features, PlayFlow 2 remains the **easiest way to get started** with multiplayer game development: * **Start servers with a single API call** and have them running in minutes * **Intelligent defaults** handle complex configuration for you (`compute_size` defaults to `small`) * **Clear documentation** and examples for every feature * **Intuitive dashboard** that grows with your expertise ## [Migration from Previous Versions](#migration-from-previous-versions) Existing PlayFlow users can easily migrate to take advantage of all these new features: Follow the step-by-step guide to move your builds and configurations to PlayFlow 2 Migrate your servers without interrupting your players ## [Get Started with PlayFlow 2](#get-started-with-playflow-2) Ready to experience the future of multiplayer game development? Deploy your first PlayFlow 2 server in under 10 minutes Connect with other developers and get support on Discord *** **PlayFlow 2** - Building the future of multiplayer gaming, one server at a time. # PlayFlow Documentation Source: https://docs.playflowcloud.com/index Deploy, manage, and scale multiplayer game servers globally with PlayFlow.

Welcome to PlayFlow 👋

Welcome to the official documentation for PlayFlow, the easiest way to build, deploy, and scale multiplayer games globally. Learn how to create engaging multiplayer experiences and bring your game ideas to life.

PlayFlow Logo
***

Get Started

Make your first server deployment in minutes.

API Reference

Integrate and scale using our API and SDKs.

Dashboard

Manage servers and monitor players directly in your browser.

What's New

Learn about changes and new features in PlayFlow.

Migration Guide

Upgrade to the latest version of the PlayFlow SDK.

Unity SDK

Explore the features and capabilities now available in PlayFlow.

Join thousands of developers building on PlayFlow

Get help, share your games, and connect with other multiplayer developers

Join our Discord Community
# Introduction Source: https://docs.playflowcloud.com/introduction The easiest way to host and scale dedicated servers for your multiplayer game. Any engine, any networking library. # Multiplayer Servers, Made Simple. **You build the game. PlayFlow runs the servers.** Stop wrestling with complex infrastructure. PlayFlow is a platform that automatically hosts and scales your game's dedicated servers, letting you turn any game into a global multiplayer hit. A first-class Unity SDK, plus any engine — Godot, Unreal, or your own — via the REST API. Use FishNet, Mirror, Netcode, or any transport with a dedicated Linux server build. Deploy across 13 regions on 6 continents and scale from 10 to 100,000+ players. ## What PlayFlow Does For You PlayFlow provides the essential backend services that every multiplayer game needs, right out of the box. Upload your game build, and we handle the rest. Launch servers on demand, close to your players. Spin up servers exactly when you need them — one per match via matchmaking, or programmatically via the API — with no idle capacity to manage. Use our pre-built, customizable system to let players group up and find fair, low-latency matches. Run your servers 24/7 for MMOs, survival games, or any always-online experience. **Bring Your Own Netcode.** PlayFlow is not a networking library. It's a hosting platform that works with the tools you already know and love. You have full control over your game's code. ## Ready to Get Started? Deploy your first server in under 10 minutes and see how simple it is. Connect with thousands of developers, from hobbyists to studio teams. # Backfill Source: https://docs.playflowcloud.com/lobbies/backfill Route newly-queued players into a running match with open slots instead of always launching a new server — engine-managed, with an authoritative roster your game server polls to admit players. Backfill routes a newly-queued lobby into an **already-running match that has open slots** instead of always spinning up a fresh server. Players skip the launch wait and drop straight into a game in progress — ideal for battle royale late-join, persistent arenas, drop-in co-op, and any mode where a half-full match is better filled than a new one is started. **A match is a group of lobbies sharing one server.** Backfill adds *more* lobbies to that same `matchId` and the same running server, up to the match's capacity (`teams × playersPerTeam`). *** ## The engine-authoritative hybrid model PlayFlow runs backfill as an **engine-authoritative hybrid**: * **The engine owns backfill by default.** When a lobby queues for a backfill-enabled mode, the matcher first looks for a compatible open match. If it finds one, the lobby joins that running server; if not, it forms a fresh match as usual. The engine tracks capacity, per-team slots, the skill band, and the backfill window — you don't have to. * **Your game server admits players from an authoritative roster.** The engine maintains the single source of truth for who belongs in the match. Your server polls `GET /v3/servers/{instance_id}/roster` and admits connecting players against it. This is what fixes reconnect / "denied by proxy" edge cases — the roster, not the server's local memory, decides admission. * **Advanced servers can optionally take control.** A server that knows better when it can accept joiners (e.g., only between rounds) can open, close, or re-scope backfill itself via `PATCH /v3/servers/{instance_id}/backfill` and `POST /v3/servers/{instance_id}/backfill/lock`. You get real backfill for free by flipping one config flag. You reach for the server-authoritative controls only when you need them. *** ## Enabling backfill Backfill is configured per matchmaking mode in your **lobby config**. Add a `backfill` object to the mode: ```json theme={null} { "matchmaking": { "modes": { "5v5": { "teams": 2, "playersPerTeam": 5, "minPlayersPerTeam": 3, "timeout": 120, "maxPartySize": 5, "backfill": { "enabled": true, "windowSeconds": 300, "closeWhenFull": true, "skill": { "field": "mmr", "maxDifference": 150 } } } } } } ``` ### `backfill` fields | Field | Type | Default | Description | | :-------------- | :---------- | :------ | :-------------------------------------------------------------------------------------------------------------------------------- | | `enabled` | boolean | — | Master switch. When `true`, newly-queued lobbies can join an open match before a fresh server is launched. | | `windowSeconds` | integer ≥ 1 | `300` | How long after a match forms it stays open to joiners. After this window the match closes to backfill. | | `closeWhenFull` | boolean | `true` | Close backfill automatically once the match reaches capacity. | | `skill` | object | `null` | Optional skill gate. A joining lobby's average value on `field` must be within `maxDifference` of the running match's skill band. | Backfill only runs for modes where `enabled` is `true`. Modes without a `backfill` object behave **exactly as before** — no backfill path executes for them. ### Deprecated: `allowBackfill` The old boolean flag still works and is treated as a shorthand: ```json theme={null} { "allowBackfill": true } ``` is equivalent to: ```json theme={null} { "backfill": { "enabled": true, "windowSeconds": 300, "closeWhenFull": true } } ``` The structured `backfill` object **takes precedence** if both are present. Prefer `backfill` for new configs — the dashboard drops `allowBackfill` on the next edit. Existing projects on `allowBackfill: true` keep working unchanged and now get real backfill (not just candidate discovery). *** ## What happens when a lobby queues For a backfill-enabled mode, the matcher tries to backfill **before** forming a fresh match: The engine looks for a running match in the same project, lobby config, and mode with `status: in_game` and `backfill_open: true`, whose backfill window hasn't expired. The candidate match must be region-compatible, have remaining capacity ≥ the joining lobby's eligible players, and (if a skill gate is set) the lobby's average skill must fall inside the match's band. The engine atomically claims the open slots (assigning players to the teams with the largest deficit) and transitions the lobby to `in_game` with the running match's `matchId` and `server` connection info. The joined lobby receives the same `server.network_ports[]` as everyone already in the match — over the existing SSE stream — and connects immediately. No launch wait. If there's no compatible open match, or another queue racer claims the last slots first, the lobby falls through to normal fresh match formation — a new server launches as usual. When a match forms with backfill enabled, it opens to joiners for `windowSeconds` (or until it fills, when `closeWhenFull` is true). Players who **leave** during a match free their slots, and the engine reopens backfill if the match is still under capacity and inside its window. *** ## Server integration: admitting players Your game server is the authority on gameplay, but the **engine is the authority on the roster**. Your server should admit connecting players against the roster it polls from the engine — not against whatever it happened to see at launch. This is the single change that makes backfill (and reliable reconnects) work. ### Poll the roster ``` GET /api/v3/servers/{instance_id}/roster api-key: pf_your_server_key ``` Authenticate with your **project server API key** (`pf_...`) and use the server's own instance ID. At launch the platform injects these environment variables into your server container — read them rather than hardcoding: | Env var | Value | | :--------------------- | :--------------------------------------------------------------- | | `PLAYFLOW_INSTANCE_ID` | The server's own `instance_id` (use it to build the roster URL). | | `PLAYFLOW_API_KEY` | The project server API key (`pf_...`). | | `PLAYFLOW_API_URL` | The API base (e.g. `https://api.computeflow.cloud/api`). | | `PLAYFLOW_MATCH_ID` | The match this server is hosting. | Poll on an interval — every few seconds is plenty. **Response (200):** ```json theme={null} { "match_id": "match-abc", "instance_id": "srv-xyz", "status": "in_game", "capacity": 10, "current_players": 7, "backfill_open": true, "backfill_deadline": "2026-07-11T20:35:00Z", "roster_version": 4, "teams": { "team_1": { "capacity": 5, "players": [ { "playerId": "p1", "lobbyId": "lob-1" }, { "playerId": "p2", "lobbyId": "lob-1" } ] }, "team_2": { "capacity": 5, "players": [ { "playerId": "p9", "lobbyId": "lob-4" } ] } }, "roster": [ { "playerId": "p1", "team": "team_1", "lobbyId": "lob-1", "joinedAt": "2026-07-11T20:30:00Z" }, { "playerId": "p2", "team": "team_1", "lobbyId": "lob-1", "joinedAt": "2026-07-11T20:30:00Z" }, { "playerId": "p9", "team": "team_2", "lobbyId": "lob-4", "joinedAt": "2026-07-11T20:31:12Z" } ] } ``` `status` is one of `in_game`, `locked`, or `completed`. Keep admitting while it's `in_game`; once it's `locked` or `completed`, backfill is closed — stop admitting new joiners and treat the match as sealed. ### `roster_version` — cheap change detection `roster_version` is a monotonically increasing counter bumped on **every** roster change (a backfill joiner admitted, a player released, a backfill state toggle). Store the last version you saw and only re-diff your admission list when it changes: * Version unchanged → nothing to do. * Version increased → a backfill joiner arrived (or a player left). Reconcile your local player list with `roster`. ### Admission rule When a player connects (or **reconnects**), check whether their `playerId` is in `roster`: * **In the roster** → admit them, and place them on their assigned `team`. * **Not in the roster** → reject the connection. Because the roster is authoritative and refreshed on every change, a player who was admitted, dropped, and reconnects is still on the roster — so your server admits them instead of denying them at the proxy. Backfill and reconnect resilience are the same mechanism. ```csharp theme={null} using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.Networking; // Runs on the DEDICATED SERVER build. Polls the authoritative roster and // keeps a local admit-set the connection handler checks against. public class BackfillRoster : MonoBehaviour { // All injected into the server container at launch. string apiBase = System.Environment.GetEnvironmentVariable("PLAYFLOW_API_URL") ?? "https://api.computeflow.cloud/api"; string instanceId = System.Environment.GetEnvironmentVariable("PLAYFLOW_INSTANCE_ID"); string serverKey = System.Environment.GetEnvironmentVariable("PLAYFLOW_API_KEY"); // pf_... long lastVersion = -1; readonly Dictionary playerTeam = new(); // playerId -> team void Start() => StartCoroutine(PollLoop()); IEnumerator PollLoop() { var wait = new WaitForSeconds(3f); while (true) { using var req = UnityWebRequest.Get($"{apiBase}/v3/servers/{instanceId}/roster"); req.SetRequestHeader("api-key", serverKey); yield return req.SendWebRequest(); if (req.result == UnityWebRequest.Result.Success) { var roster = JsonUtility.FromJson(req.downloadHandler.text); if (roster.roster_version != lastVersion) { lastVersion = roster.roster_version; playerTeam.Clear(); foreach (var e in roster.roster) playerTeam[e.playerId] = e.team; Debug.Log($"Roster v{lastVersion}: {playerTeam.Count}/{roster.capacity} players"); } } yield return wait; } } // Call this from your transport's connection-approval callback. public bool IsAdmitted(string playerId, out string team) => playerTeam.TryGetValue(playerId, out team); } [System.Serializable] public class RosterEntry { public string playerId; public string team; public string lobbyId; public string joinedAt; } [System.Serializable] public class RosterResponse { public string match_id; public int capacity; public int current_players; public long roster_version; public RosterEntry[] roster; } ``` ```gdscript theme={null} # Autoload on the DEDICATED SERVER. Polls the authoritative roster and keeps # a player -> team map the connection handler checks before admitting. extends Node # All injected into the server container at launch. var api_base := OS.get_environment("PLAYFLOW_API_URL") var instance_id := OS.get_environment("PLAYFLOW_INSTANCE_ID") var server_key := OS.get_environment("PLAYFLOW_API_KEY") # pf_... var _last_version := -1 var _player_team := {} # playerId -> team var _http := HTTPRequest.new() func _ready() -> void: if api_base == "": api_base = "https://api.computeflow.cloud/api" add_child(_http) _http.request_completed.connect(_on_roster) var timer := Timer.new() timer.wait_time = 3.0 timer.timeout.connect(_poll) add_child(timer) timer.start() func _poll() -> void: _http.request("%s/v3/servers/%s/roster" % [api_base, instance_id], PackedStringArray(["api-key: " + server_key])) func _on_roster(_r, code, _h, body: PackedByteArray) -> void: if code != 200: return var data: Dictionary = JSON.parse_string(body.get_string_from_utf8()) if data.roster_version == _last_version: return _last_version = data.roster_version _player_team.clear() for e in data.roster: _player_team[e.playerId] = e.team # Call from your peer-connected / auth handler. func is_admitted(player_id: String) -> bool: return _player_team.has(player_id) ``` Pull is primary. The engine also best-effort mirrors the roster onto your instance so the endpoint is fast, but your server should treat `GET /roster` as the source of truth and poll it — don't rely on a push you might miss. *** ## Optional: server-authoritative control Most games never need this — the engine opens and closes backfill for you based on the window and capacity. But if your server has better knowledge of when it can accept joiners (e.g., only between rounds, or once a boss phase starts), it can take control. These endpoints require a **server API key** (`pf_...`). Client keys (`pfclient_...`) are allowed **unless** the project's `client_key_server_control` setting is `false`, in which case they are rejected with `403`. The read-only `GET /roster` endpoint accepts either key. ### Open or close backfill (and re-scope skill) ``` PATCH /api/v3/servers/{instance_id}/backfill api-key: pf_your_server_key Content-Type: application/json { "accepting": true, "skillBand": { "field": "mmr", "min": 1400, "max": 1600 } } ``` | Field | Type | Description | | :---------- | :------------- | :------------------------------------------------------------------------------------------------------------------------------------ | | `accepting` | boolean | `true` opens backfill; `false` closes it. | | `skillBand` | object \| null | Optional. Replace the match's skill band for joiners: `{ field, min, max }`. Omit or send `null` to leave the current band unchanged. | **Response (200):** ```json theme={null} { "match_id": "match-abc", "instance_id": "srv-xyz", "status": "in_game", "backfill_open": true, "backfill_deadline": "2026-07-11T20:35:00Z", "skill_band": { "field": "mmr", "min": 1400, "max": 1600 }, "roster_version": 5 } ``` ### Close backfill (shorthand) Close the match to further joiners — a convenience shorthand for `PATCH ... { "accepting": false }`: ``` POST /api/v3/servers/{instance_id}/backfill/lock api-key: pf_your_server_key ``` Returns the same `ServerBackfillState` shape as `PATCH`. Both bump `roster_version`, so a polling server sees the change on its next `GET /roster`. *** ## Behavior reference | Situation | Outcome | | :----------------------------------------- | :------------------------------------------------------------------- | | Lobby queues, compatible open match exists | Lobby joins the running server; no new server launches | | No open match / lost the claim race | Falls through to fresh match formation | | Match fills and `closeWhenFull` is true | Backfill closes automatically | | Backfill window (`windowSeconds`) elapses | Match closes to joiners | | Player leaves mid-match | Slot freed; backfill reopens if under capacity and inside the window | | Mode has no `backfill` / `enabled: false` | Backfill path never runs — behaves exactly as before | *** Modes, teams, skill rules, and how matches form. Stream the `in_game` transition and server connection info via SSE. # Lobby Lifecycle Source: https://docs.playflowcloud.com/lobbies/lifecycle The complete lifecycle of a PlayFlow lobby — from creation to cleanup. Creating, joining, leaving, host actions, and every edge case. A lobby progresses through several states during its lifetime. Understanding the lifecycle helps you design your game's UI and handle every edge case cleanly. ## Status Flow Lobbies never dead-end. They either stay alive (rematch-ready) or get deleted entirely. There's no "completed" terminal state. ```text theme={null} waiting ─────→ in_queue ─────→ matched ─────→ in_game ─────→ waiting │ │ │ ↑ (rematch-ready) │ │ │ │ │ │ └──→ match_found (if matchConfirmation enabled) │ │ │ │ │ └──→ in_game (all confirm) │ │ └──→ waiting (decline / timeout) │ │ │ └──→ waiting (timeout / cancel / no opponents) │ └──→ starting ─────→ in_game (host started game directly) │ └──→ waiting (server create failed) ``` | Status | Meaning | | :------------ | :------------------------------------------------------------------------------------------------ | | `waiting` | Open lobby, accepting players. The default resting state. | | `in_queue` | Matchmaking active, searching for opponents | | `starting` | Host triggered direct start, server provisioning | | `matched` | Claimed for a match, server being created | | `match_found` | Match proposed, waiting for player `POST /me/confirm-match` (only if `matchConfirmation.enabled`) | | `in_game` | Game server running, players connected | **Self-healing lobbies.** When the game server stops — whether the host clicks "End Match", the server crashes, or its TTL expires — the lobby automatically returns to `waiting` with all players retained. There is no `completed` status. Players can queue or start another match immediately. *** ## Creating a Lobby ```bash theme={null} POST /v3/lobbies/{config} x-player-id: host_player Content-Type: application/json { "name": "Friday Night CTF", "maxPlayers": 8, "region": "us-east", "isPrivate": false, "allowLateJoin": true, "settings": { "map": "forest", "mode": "ctf" }, "state": { "ready": true } } ``` The creator becomes the **host**. An invite code is generated automatically (even for public lobbies, so friends can share it). | Field | Required | Default | Notes | | :-------------- | :------- | :-------- | :------------------------------------------ | | `name` | Yes | - | Display name | | `maxPlayers` | No | `2` | 1–100 | | `region` | No | `us-east` | Game server region | | `isPrivate` | No | `false` | Hide from `GET /v3/lobbies/{config}` browse | | `allowLateJoin` | No | `true` | Can players join after game starts? | | `settings` | No | `{}` | Game-specific data passed to the server | | `state` | No | `null` | Host's initial player state | The host can only be in **one lobby at a time** per config. Trying to create a second returns `409 Player is already in a lobby`. ### Fresh lobby on reconnect Set `forceFresh: true` on create to atomically replace a lobby the caller already **hosts** in this config. The backend deletes the old lobby (stopping its game server best-effort) and then creates the new one — all in a single call. It's a no-op when the player has no prior lobby, so it's safe to always pass on reconnect. This replaces the old 3-call workaround (`TryReconnect` → detect stale host lobby → `DeleteLobby` → `CreateLobby`) with one request. ```bash theme={null} POST /v3/lobbies/{config} x-player-id: host_player Content-Type: application/json { "name": "Friday Night CTF", "maxPlayers": 8, "forceFresh": true } ``` Only lobbies the caller **hosts** are force-deleted. If the caller is merely a guest in someone else's lobby, the create still returns `409 Player is already in a lobby` — they need to leave that one first. *** ## Joining a Lobby Two ways: by **invite code** or by **lobby ID**. ```bash theme={null} POST /v3/lobbies/{config}/join x-player-id: new_player { "code": "MEOW-42", "state": { "team": "blue" } } ``` Best for "friend shares a code" flow. Works for both public and private lobbies. ```bash theme={null} POST /v3/lobbies/{config}/join x-player-id: new_player { "lobbyId": "a1b2c3d4-...", "state": { "team": "blue" } } ``` Best when the player browsed public lobbies and clicked one. ### Join Errors | Status | Error | Cause | | :----- | :------------------------------- | :------------------------------------------------ | | `404` | `Lobby not found` | Wrong ID/code | | `400` | `Lobby is full` | `currentPlayers >= maxPlayers` | | `400` | `Lobby is not accepting players` | `status: in_game` and `allowLateJoin: false` | | `409` | `Player is already in a lobby` | Player is in a different lobby in the same config | Joining is **idempotent** — if the player is already in the lobby, it returns the lobby unchanged (no error). *** ## Updating Player State Every player can update their own state. Use `PATCH /me`: ```bash theme={null} PATCH /v3/lobbies/{config}/me x-player-id: my_player { "state": { "ready": true, "character": "wizard", "mmr": 1450 } } ``` **State merges, not replaces.** Existing keys are preserved unless your update overwrites them. To clear a key, set it to `null`. State flows automatically into `matchmaking_data` when the lobby queues, so fields like `mmr` are used by matchmaking rules. *** ## Host Actions Only the **host** can perform these actions. Non-hosts get `403 Only the host can perform this action`. ### Update Lobby Settings ```bash theme={null} PATCH /v3/lobbies/{config}/me/settings x-player-id: host_player { "name": "New Name", "maxPlayers": 16, "isPrivate": true, "region": "eu-west", "settings": { "map": "desert" } } ``` Every field is optional — only include what you want to change. ### Kick a Player ```bash theme={null} DELETE /v3/lobbies/{config}/me/players/{playerId} x-player-id: host_player ``` The kicker is the host (from `x-player-id`). The `{playerId}` in the path is the target. Hosts can't kick themselves. Use `DELETE /me` to leave (which auto-transfers host). ### Start the Game Directly Skip matchmaking and launch a server immediately with the current players: ```bash theme={null} POST /v3/lobbies/{config}/me/start x-player-id: host_player ``` The response has `server.status: "launching"`. Within \~10-30s status transitions to `running` and clients can connect. Read the connection host and port from the server response's `network_ports[]` array — the external port is proxy-allocated and differs from your internal port. **`playflow.json` parity with matchmaking:** a directly-started server receives the same `custom_data` shape a matchmaker-launched one does — `mode`, `teams` (grouped from each player's `state.team`), `players`, `lobby_id`, `match_id`, and `version`. A directly-created lobby never enters the queue, so declare the mode yourself in lobby **`settings.mode`** (at create time, or later with `PATCH /me/settings`) and your game server reads it from `playflow.json` as `custom_data.mode`. If the lobby did queue at some point, the active matchmaking mode wins. ### End the Match (Rematch) ```bash theme={null} POST /v3/lobbies/{config}/me/end-match x-player-id: host_player ``` The server stops, the lobby returns to `waiting`, and all players stay in the lobby with the same invite code and settings. Great for "Play Again" buttons. If the game server stops on its own (crashes, hits TTL, or your game logic exits cleanly), the lobby **auto-heals** to `waiting` with the same effect — no call needed. `POST /me/end-match` is just the explicit, host-triggered version. *** ## Leaving Any player can leave at any time: ```bash theme={null} DELETE /v3/lobbies/{config}/me x-player-id: my_player ``` **What happens depends on who leaves:** | Scenario | Behavior | | :---------------------------------- | :------------------------------------------------------ | | Regular player leaves | Removed from `players`, `currentPlayers` decrements | | Host leaves (with others remaining) | Host auto-transfers to next player (default) | | Last player leaves | Lobby is deleted, returns `{"status": "lobby_deleted"}` | | Player leaves during matchmaking | Matchmaking auto-cancels, lobby returns to `waiting` | ### Host leave behavior What happens when the host leaves is controlled per lobby config via `hostLeaveBehavior`: | Value | Behavior | | :-------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------- | | `"promote"` (default) | The next player in the lobby becomes the new host. Remaining players stay put with the same invite code, settings, and state. | | `"delete"` | The entire lobby is destroyed. Every remaining player receives a `lobby_deleted` SSE event on `/me/events` and must create or join a new lobby. | **When to use each:** * **`"promote"`** — persistent rooms, squad play, party-based games where the lobby should survive individual churn. This is the default for every lobby config. * **`"delete"`** — one-shot private rooms, pickup games, host-authored sessions where the host's presence IS the room. Pairs well with `isPrivate: true` invite-only lobbies. Non-host players leaving never triggers delete — only the host walking out can collapse the lobby under `"delete"`. Example config JSON: ```json theme={null} { "id": "private-squad", "name": "Private Squad Room", "enabled": true, "timeout": 300, "hostLeaveBehavior": "delete", "inviteCodeConfig": { "type": "alphanumeric", "length": 6, "prefix": "SQD-" }, "serverSettings": { "serverSize": "small" } } ``` When `hostLeaveBehavior: "delete"` fires, any game server associated with the lobby is stopped best-effort — same cleanup path as the admin delete endpoint. *** ## Browsing Lobbies Anyone (even without a player-id) can browse public lobbies in a config: ```bash theme={null} GET /v3/lobbies/{config}?region=us-east&status=waiting&limit=20 ``` Returns a paginated list. Private lobbies (`isPrivate: true`) are excluded automatically. Query params: | Param | Purpose | | :------- | :------------------------------ | | `region` | Filter by region | | `status` | Filter by lobby status | | `limit` | Page size (default 50, max 100) | | `offset` | Pagination offset | *** ## Cleanup & Timeouts Lobbies don't stick around forever. The PlayFlow `lobby-sweep` cron runs continuously and handles: * **Expired waiting lobbies** — deleted after `timeout` seconds (default 5 min) with no activity * **Stale in-game lobbies** — in-game lobbies past 2 hours are cleaned up * **Heartbeat expiry** — if `heartbeat: true` is enabled in config, players that stop sending heartbeats are removed * **Matchmaking timeout** — lobbies in `in_queue` past the mode's timeout drop back to `waiting` For most games, **you don't need to send heartbeats**. The SSE connection itself is the heartbeat — as long as the player is connected to `/me/events`, they're alive. Only enable heartbeat in config if your players use HTTP polling instead of SSE. *** ## Heartbeat Set `heartbeat: true` on a lobby config to require connected players to ping the server periodically. Players who go silent get evicted by the `lobby-sweep` cron. ```json theme={null} { "heartbeat": true, "heartbeatTimeout": 30 } ``` * `heartbeat` — when `true`, every player must send heartbeats * `heartbeatTimeout` — seconds before a player is considered stale. Any player whose last heartbeat is older than this is removed on the next sweep. **When you need it:** HTTP-polling clients where the SSE connection at `/me/events` isn't available. Mobile background states, low-resource IoT clients, or environments that block long-lived connections. SSE is the better alternative. If your client can open `/me/events`, the stream itself is the liveness signal — see [Real-time Events](/lobbies/real-time) — and you can leave `heartbeat` off. *** ## Admin Delete Force-delete any lobby by ID. Removes all players and cancels any active matchmaking — regardless of who created the lobby. ```bash theme={null} curl -X DELETE https://api.computeflow.cloud/api/v3/lobbies/{config}/{id} \ -H "api-key: pf_your_server_key" ``` * No `x-player-id` required. This is a server-side admin operation. * `{config}` — the lobby config name (e.g. `"ranked_2v2"`). * `{id}` — the lobby UUID to delete. **Response (200):** ```json theme={null} { "status": "lobby_deleted", "id": "a3f1…-e8c2" } ``` **404** if no lobby with that ID exists in your project. Connected SDK clients in that lobby receive a `lobby_deleted` SSE event and disconnect cleanly. In contrast, `DELETE /{config}/me` is "leave lobby" — it only removes the caller, and when the caller is the host a new host is promoted automatically. Use admin delete when you actually mean destroy. Good uses: dashboard admin actions, anti-cheat or moderation tooling, periodic cleanup for lobbies your app determines are abandoned. *** ## Invite Codes `inviteCodeConfig` on a lobby config controls the codes PlayFlow generates when a lobby is created. Every new lobby (public **and** private) gets a code so friends can join by sharing it. ```json theme={null} { "inviteCodeConfig": { "type": "alphanumeric", "length": 6, "prefix": "PLAY-" } } ``` | Field | Values | Notes | | :------- | :-------------------------------------- | :-------------------------------------------------- | | `type` | `numeric`, `alphanumeric`, `alphabetic` | Character set for the generated suffix | | `length` | 1–16 | Length of the generated suffix (excluding prefix) | | `prefix` | any string | Optional. Prepended verbatim to the generated code. | With the example above a lobby might get the code `PLAY-K7Q2F1`. Players then join with: ```bash theme={null} POST /v3/lobbies/{config}/join { "code": "PLAY-K7Q2F1" } ``` *** ## Error Codes Every lobby error returns the same JSON shape — a human-readable `error` message, a `detail` (the same message), and the HTTP `status`: ```json theme={null} { "error": "Lobby is full", "detail": "Lobby is full", "status": 400 } ``` There is **no** machine-readable error-code enum. Branch on the HTTP `status` (and, if you must, the exact `error` string) — do not hard-code a `code` field, because the API never emits one. The messages you can expect, by status: | HTTP | `error` message | Meaning | | :---- | :------------------------------------------------- | :------------------------------------------------- | | `400` | `Lobby is full` | Capacity exceeded (`currentPlayers >= maxPlayers`) | | `400` | `Lobby is not accepting players` | In-game with no late-join | | `400` | `Invalid action for current lobby state` | E.g., host trying to self-kick | | `403` | `Only the host can perform this action` | Non-host tried a host-only action | | `403` | `This lobby is private; join with its invite code` | Tried to join a private lobby by ID | | `404` | `Lobby not found` | Lobby doesn't exist | | `404` | `Player not found in lobby` | Player isn't in the lobby | | `409` | `Player is already in a lobby` | Player is already in another lobby in this config | | `409` | `Game is already running` | Started a game that's already in progress | | `409` | `Invalid lobby state transition` | Wrong status for this operation | # Matchmaking Source: https://docs.playflowcloud.com/lobbies/matchmaking Skill-based matchmaking, asymmetric teams, auto-expanding MMR buckets, queue stats, and region selection — all through one endpoint. PlayFlow's matchmaking system finds compatible opponents for your players and launches a shared game server. It supports symmetric teams, asymmetric roles, skill-based filtering, region preferences, and handles concurrency safely. **A matchmaking game is just a group of lobbies pointing to the same server.** When two lobbies match, they both transition to `in_game` with the same `matchId` and the same `server` connection info. **Prerequisites.** Authenticate every request with the `api-key` header (a client key `pfclient_...` is safe to ship in game builds). The examples below use a raw `x-player-id` header, which only works when your project has no player-auth provider configured. If you enable JWT, PlayFab, or Steam player auth, client keys must send a verified `x-player-token` instead — otherwise these calls return `401`. See [Player authentication](/guides/player-authentication) for setup. ## The Full Flow One or more players join a lobby (as a party). `POST /v3/lobbies/{config}/me/matchmaking` with a `mode` name. Lobby status → `in_queue`. `matchmaking.mode` populates. Queue stats begin streaming via SSE. Other lobbies in the same config + mode are evaluated. Rules applied (MMR, region, party size). Candidates sorted by wait time. All matched lobbies atomically claim each other. Status → `matched`. One dedicated server starts. All matched lobbies get the same `matchId` and `server.network_ports[]`. Clients read `server.network_ports[0].host:external_port` from their lobby and connect to the game server. *** ## Configuring Matchmaking Matchmaking modes are defined in your **lobby config** in the PlayFlow dashboard. Each config can have multiple modes: ```json theme={null} { "id": "ranked", "name": "ranked", "enabled": true, "timeout": 300, "serverSettings": { "serverSize": "small", "gameBuild": "default" }, "matchmaking": { "modes": { "1v1": { "teams": 2, "playersPerTeam": 1, "minPlayersPerTeam": 1, "timeout": 60, "maxPartySize": 1, "allowBackfill": false, "rules": [ { "type": "difference", "field": "mmr", "maxDifference": 100 } ] }, "5v5": { "teams": 2, "playersPerTeam": 5, "minPlayersPerTeam": 3, "timeout": 120, "maxPartySize": 5, "allowBackfill": true } } } } ``` **No config? No problem.** If you don't set up matchmaking in the dashboard, the `/me/matchmaking` endpoint returns a `400` asking you to configure modes. All *lobby* operations still work without config (they use defaults). *** ## Starting Matchmaking ```bash theme={null} POST /v3/lobbies/ranked/me/matchmaking x-player-id: host_player Content-Type: application/json { "mode": "5v5" } ``` **Response** — lobby transitions to `in_queue` with queue stats: ```json theme={null} { "status": "in_queue", "matchmaking": { "mode": "5v5", "startedAt": "2026-04-08T20:30:00Z", "queueStats": { "playersSearching": 42, "lobbiesInQueue": 8, "avgWaitSeconds": 12.3 } }, ... } ``` Only the **host** can start matchmaking. Non-hosts get `403 Not host`. ### When a Match Is Found When the system matches your lobby with others, the SSE stream emits an update: ``` event: lobby_updated data: { "status": "in_game", "matchId": "abc-123", "server": { "instance_id": "srv-xyz", "status": "launching", "region": "us-east", "network_ports": [{"name":"game_udp","host":"203.0.113.42","external_port":30234,"internal_port":7770,"protocol":"udp","tls_enabled":false}] }, ... } ``` All matched lobbies get the **same** `matchId` and **same** `server`. Clients connect to `server.network_ports[0].host:external_port`. The `server` block is the same shape as `GET /v3/servers/{instance_id}` — server is a primitive the lobby wraps around. ### Cancel Matchmaking ```bash theme={null} DELETE /v3/lobbies/ranked/me/matchmaking x-player-id: host_player ``` Lobby returns to `waiting`. *** ## Match Confirmation CS2-style "Accept Match" flow. For ranked or competitive modes, you often want players to accept a match before the server launches. PlayFlow supports this natively — add `matchConfirmation` to the mode: ```json theme={null} { "mode": "ranked_5v5", "matchConfirmation": { "enabled": true, "timeoutSeconds": 10 } } ``` ### How it works 1. Matchmaker finds a match → all lobbies transition `in_queue` → **`match_found`**. **Server is NOT launched yet.** 2. Each lobby's response now includes a confirmation block: ```json theme={null} { "status": "match_found", "matchmaking": { "confirmation": { "deadline": "2026-04-18T15:30:00Z", "confirmed": false } } } ``` 3. Players accept or decline: * **Accept:** `POST /v3/lobbies/{config}/me/confirm-match` * **Decline:** `DELETE /v3/lobbies/{config}/me/confirm-match` 4. When **every** matched lobby accepts → server launches → `in_game`. 5. If **any** lobby declines OR the deadline passes → **all participating lobbies return to `waiting`** (matchmaking state cleared). Players re-queue when ready. **Who confirms?** Any player in a lobby can accept or decline on behalf of their party. Parties confirm as a unit (one click per party, not per player). Declining or timing out moves players to `waiting`, not back to `in_queue`. This prevents accidental re-matching. Players must explicitly press matchmake again. ### Full flow over SSE The client sees the whole flow in real-time: ``` # 1. Match found, awaiting acceptance event: lobby_updated data: { "status": "match_found", "matchmaking": { "confirmation": { "deadline": "...", "confirmed": false } }} # 2. This player accepts event: lobby_updated data: { "status": "match_found", "matchmaking": { "confirmation": { "deadline": "...", "confirmed": true } }} # 3. Last player accepts → server launches event: lobby_updated data: { "status": "in_game", "server": { "status": "launching", "network_ports": [] }} ``` *** ## Rematch (keep the same lobby) When a match ends, the host can recycle the lobby for another round instead of making players re-queue: ```bash theme={null} POST /v3/lobbies/ranked/me/end-match x-player-id: host_player ``` This: * Transitions lobby from `in_game` → `waiting` * Stops the current game server * Keeps all players, invite code, and settings * Ready for `/me/start` or `/me/matchmaking` again If the game server naturally shuts down (TTL or crash), the lobby **automatically self-heals** to `waiting` the same way — no manual call needed. *** ## Team Modes ### Symmetric teams Traditional team structure — most competitive games. N teams of M players, everyone equal. ```json theme={null} { "teams": 2, "playersPerTeam": 5, "minPlayersPerTeam": 3 } ``` * `teams` — how many teams to form * `playersPerTeam` — target size (matchmaker tries to fill to this) * `minPlayersPerTeam` — minimum required to start (for partial matches) **Examples:** | Mode | Config | | :--------------------- | :----------------------------------------------------- | | 1v1 ranked | `teams: 2, playersPerTeam: 1` | | 2v2 duos | `teams: 2, playersPerTeam: 2` | | 5v5 competitive | `teams: 2, playersPerTeam: 5` | | 100-player FFA | `teams: 1, playersPerTeam: 100, minPlayersPerTeam: 20` | | 50-squad battle royale | `teams: 50, playersPerTeam: 2, minTeams: 10` | ### Asymmetric teams Role-based team structure for games like *Dead by Daylight* (1 killer vs 4 survivors) or *Evolve* (1 monster vs 4 hunters): ```json theme={null} { "teams": 2, "playersPerTeam": 3, "minPlayersPerTeam": 2, "teamComposition": { "monsters": { "boss": 1 }, "hunters": { "medic": 1, "assault": 2, "support": 1 } }, "excludedFromQueue": ["spectator"] } ``` Players specify their role in `state.role`: ```bash theme={null} PATCH /v3/lobbies/hunt/me x-player-id: p1 { "state": { "role": "boss" } } ``` Or a preference list (higher priority first): ```bash theme={null} { "state": { "role": ["medic", "support", "fill"] } } ``` The matchmaker: * Places players with the most specific role first (`boss` before `fill`) * Keeps party members together on the same team * Uses `"fill"` as a wildcard (assigns wherever there's an open slot) * Excludes spectators (or any role in `excludedFromQueue`) *** ## Matchmaking Rules Rules decide which lobbies are allowed to match with each other. Add them to a mode under `rules`: ```json theme={null} { "mode": "ranked_2v2", "rules": [ { "type": "difference", "field": "mmr", "maxDifference": 100 } ] } ``` **All rules must pass** for two lobbies to match. If any rule rejects, the matcher keeps looking. Numeric state fields are averaged across party members. So a party with players at MMR 1400 and 1600 shows up as 1500 to the matcher. String fields (e.g. `gameVersion`) are propagated only if all party members agree on the same value. ### The 5 rule types Numeric tolerance with auto-expanding buckets. Skill matching. Both sides must share a field value (version, map). Values must differ (anti-rematch). Acceptable-region overlap (≥ N shared). CEL escape hatch for custom logic. ### Skill matching The `difference` rule pairs players whose state differs by no more than a threshold, with buckets that widen over time. ```json theme={null} { "type": "difference", "field": "mmr", "maxDifference": 100 } ``` Matches when `|lobbyA.mmr - lobbyB.mmr| <= maxDifference`. **Works with any numeric field**: `mmr`, `elo`, `level`, `rank`, `kd_ratio`. **The magic**: as players wait longer, the bucket grows automatically. No extra config. Formula: `effectiveMax = maxDifference × (1 + longestWaitTime / modeTimeout)` Using `maxDifference: 100, timeout: 60s`: | Wait time | Effective range | | :-------- | :---------------- | | 0s | ±100 MMR (strict) | | 30s | ±150 MMR | | 60s | ±200 MMR | | 120s | ±300 MMR | This is how Riot, Valve, and Blizzard all do it — they just never made you configure it. ### Equals rule The `equals` rule pairs players only when a specific state field matches exactly on both sides. ```json theme={null} { "type": "equals", "field": "gameVersion" } ``` Matches only if `lobbyA.gameVersion == lobbyB.gameVersion`. Use for: game version pinning, map selection, platform gating. ### Not-equals rule The `not_equals` rule pairs players only when a state field has different values on each side. ```json theme={null} { "type": "not_equals", "field": "lastOpponent" } ``` Matches only when the field values differ. Use for: anti-rematch (last two opponents shouldn't be the same), team-mixing, forced rotation. ### Region rule The `region` rule pairs lobbies only when their acceptable-region lists overlap by at least `minOverlap` entries. ```json theme={null} { "type": "region", "minOverlap": 1 } ``` Matches only if both lobbies share at least `minOverlap` entries in their `acceptableRegions` list. Players set regions via their state: ```bash theme={null} PATCH /v3/lobbies/ranked/me { "state": { "regions": ["us-east", "us-west"] } } ``` If `acceptableRegions` is empty, the lobby is "open to any region" (wildcard — always passes). ### Expression rule The `expression` rule is a CEL escape hatch for logic the other four primitives don't cover. Uses [CEL](https://cel.dev/) (Common Expression Language). ```json theme={null} { "type": "expression", "cel": "a.state.mmr > 1000 && b.state.mmr > 1000" } ``` **Scope:** you have `a` and `b` (the two lobby tickets) and `ctx` (mode config). Each ticket exposes `id`, `partySize`, `waitSeconds`, `region`, `acceptableRegions`, `state`, `metadata`. **More CEL examples:** ```json theme={null} // High-tier games only (hidden MMR gating) { "type": "expression", "cel": "a.state.rank == b.state.rank && a.state.mmr > 2000" } // Anti-smurf: only match if both accounts are at least a week old { "type": "expression", "cel": "a.state.accountAgeDays >= 7 && b.state.accountAgeDays >= 7" } // Prefer opponents who have waited longer (priority boost) { "type": "expression", "cel": "a.waitSeconds + b.waitSeconds > 30" } ``` ### Multiple rules — all must pass Stack rules together: ```json theme={null} { "rules": [ { "type": "difference", "field": "mmr", "maxDifference": 100 }, { "type": "equals", "field": "gameVersion" }, { "type": "region", "minOverlap": 1 } ] } ``` A candidate pair must satisfy **every** rule to match. *** ## Dynamic Version Selection Shipping a new game version usually means a rolling update — stop old matches, cut the fleet over to the new build, and hope nothing breaks for players mid-session. That's infrastructure overhead you pay on every release. Set `useVersionFromState: "gameVersion"` on a mode. Your client sends `gameVersion` in its player state when queueing, an `equals` rule on the same field keeps matched players on the same version, and the matchmaker launches whichever build has that `name`. No rolling updates, multiple versions live simultaneously in the same project. ### Config ```json theme={null} { "id": "ranked", "name": "ranked", "enabled": true, "serverSettings": { "serverSize": "small", "gameBuild": "1.2.3" }, "matchmaking": { "modes": { "5v5": { "teams": 2, "playersPerTeam": 5, "useVersionFromState": "gameVersion", "rules": [ { "type": "equals", "field": "gameVersion" }, { "type": "difference", "field": "mmr", "maxDifference": 100 } ] } } } } ``` `useVersionFromState` maps to a build's `name` (e.g. `1.2.3`), not its numeric version. `serverSettings.gameBuild` acts as the fallback when the field is missing from every matched player's state. ### Unity SDK Send the version in player state before you queue: ```csharp theme={null} using System.Collections.Generic; using PlayFlow; using UnityEngine; public class VersionedMatchmaking : MonoBehaviour { public void SetMatchmakingState(int playerMMR) { var matchmakingData = new Dictionary { { "gameVersion", Application.version }, // e.g. "1.2.3" { "mmr", playerMMR }, { "regions", new List { "us-east", "us-west" } } }; PlayFlowLobbyManagerV2.Instance.UpdatePlayerState(matchmakingData, onSuccess: (lobby) => Debug.Log($"Queueing as v{Application.version}"), onError: (error) => Debug.LogError($"State update failed: {error}") ); } } ``` ### Prerequisites * A build whose `name` matches the state value must exist and be `ready` * Every matched player must send the field in their state — add a client-side check with a sensible fallback * An `equals` rule on the same field is required (the dashboard and API reject the config otherwise) ### Failure modes | Condition | Outcome | | :---------------------------------------------------- | :--------------------------------------- | | Field missing from state | Match fails, players return to `waiting` | | Build with that `name` doesn't exist or isn't `ready` | Match fails, players return to `waiting` | | No `equals` rule on the same field | Config save rejected (`400`) | Gate access to old versions by deleting (or unpublishing) the old build once your player base has upgraded. The matchmaker will refuse matches that resolve to missing builds, forcing clients onto a supported version. *** ## Region Matching Players specify their acceptable regions in state (or PlayFlow falls back to the lobby's region): ```bash theme={null} PATCH /v3/lobbies/ranked/me { "state": { "regions": ["us-east", "us-west"] } } ``` The matchmaker: 1. Intersects all matched lobbies' region lists 2. Within the intersection, counts weighted votes (1/(index+1) by preference order) 3. Picks the highest-voted region for the server If lobbies have no overlap, the match doesn't form — they're considered incompatible. *** ## Queue Stats While in `in_queue`, your lobby response includes live queue stats: ```json theme={null} { "matchmaking": { "mode": "5v5", "startedAt": "...", "queueStats": { "playersSearching": 42, "lobbiesInQueue": 8, "avgWaitSeconds": 12.3 } } } ``` Via **SSE**, you get push updates every 10 seconds: ``` event: queue_stats data: { "playersSearching": 45, "lobbiesInQueue": 9, "avgWaitSeconds": 11.8 } ``` Use this to show your players "12s estimated wait" or "42 searching now" in your UI. *** ## Backfill For long-running persistent games (e.g., battle royale with late-join), enable backfill so newly-queued lobbies join an **already-running match with open slots** instead of always launching a new server: ```json theme={null} { "backfill": { "enabled": true, "windowSeconds": 300, "closeWhenFull": true, "skill": { "field": "mmr", "maxDifference": 150 } } } ``` When backfill is enabled, the matchmaker adds new lobbies to **existing in-progress games** that have open slots — not just create new matches. Players join the running server and jump straight into the action. Your game server admits them from an authoritative roster it polls from the engine (which also fixes reconnect / "denied by proxy" edge cases). The older `"allowBackfill": true` flag still works as a shorthand for `{ "backfill": { "enabled": true } }`, but prefer the structured object. Full config, the engine-authoritative hybrid model, and the server integration guide (roster polling + admission). *** ## Party Size Limits Prevent a 5-stack from queueing for a 1v1: ```json theme={null} { "maxPartySize": 1 } ``` Lobbies with more than `maxPartySize` eligible players are rejected from queueing. *** ## Matchmaking Timeout The `timeout` field on a matchmaking mode caps how long a player will wait in queue before the system gives up. ```json theme={null} { "timeout": 60 } ``` When the timeout elapses, the lobby transitions back to `waiting`, delivered as a normal `lobby_updated` SSE event — players are **not** re-queued automatically. Players must explicitly press matchmake again. **Typical values:** * `30` — casual / quick play * `60` – `120` — ranked * `180`+ — large FFAs or niche modes where the candidate pool is thin *** ## Battle Royale Battle royale modes are not a separate feature — they are just a particular shape of the existing `teams` / `playersPerTeam` fields. There is nothing special to enable on the backend. **Squad BR** uses the Teams format: many small squads fight in one match. A `{ "teams": 25, "playersPerTeam": 4 }` mode fills one 100-player server from 25 four-player lobbies. **Solo BR** uses the Free-for-all format: `{ "teams": 100, "playersPerTeam": 1 }` fills a 100-player match from 100 solo lobbies. ### Anchor-pairwise matching When N lobbies come together to form one match, rules are evaluated **pairwise against the anchor** (the first lobby in the queue), not against a running average. For a 25-squad BR that means 24 anchor-vs-candidate evaluations — each candidate must be compatible with the anchor. This matters because two admitted lobbies can legally be up to `2 × maxDifference` apart from each other, even though each is within `maxDifference` of the anchor. ### Rule recommendations * **Skill (`difference` on `mmr`)** — works, but tune `maxDifference` on the conservative side. Remember two admitted squads can be `2 × maxDifference` apart. * **`equals` on `gameVersion`** — every lobby must match the anchor exactly. Recommended. * **`region`** — critical for BR. One cross-continent squad ruins the server for everyone. Do **not** enable `matchConfirmation` (accept match) on BR modes. 100 players × "click accept" means any single holdout cancels the match. Accept-match is for 1v1 and small team matches, not large rooms. ### Party semantics reminder Numeric fields in a lobby's `state` (like `mmr`) are averaged **within** a party when it joins matchmaking together. Across parties, each party carries its own averaged value — the matchmaker compares those averages against the anchor. ### Example: 25-squad BR ```json theme={null} { "matchmaking": { "modes": { "squads": { "teams": 25, "playersPerTeam": 4, "timeout": 180, "rules": [ { "type": "difference", "field": "mmr", "maxDifference": 150 }, { "type": "equals", "field": "gameVersion" }, { "type": "region" } ] } } } } ``` *** ## How Matchmaking Runs **Matchmaking runs inline on queue entry.** When lobby B calls `POST /me/matchmaking`, the system immediately scans for compatible lobbies. If A is already queued, B matches with A within \~100ms. No polling loops, no worker delay. A QStash-driven cron runs every 60s as a safety net (catches edge cases like simultaneous-entry races). **Concurrency safety:** The matchmaker uses Postgres `FOR UPDATE SKIP LOCKED` + a claim-then-create pattern. Two simultaneous match attempts can never claim the same lobbies or launch duplicate servers. *** ## Error Cases | Status | Cause | | :------------------------ | :------------------------------------------------------- | | `400 Mode not found` | The `mode` you passed isn't defined in your lobby config | | `400 Party size exceeded` | Your lobby has more players than `maxPartySize` | | `400 Already in queue` | Lobby is already in `in_queue` status | | `403 Not host` | Non-host tried to start/cancel matchmaking | | `409 Invalid state` | Lobby is `in_game` or otherwise not in `waiting` | This table lists causes and approximate messages, not verbatim error strings. Errors return `{ error, detail, status }` with no machine-readable `code` field — branch on the HTTP **status**, not the exact message text. *** ## Full Example: Ranked 1v1 ```bash theme={null} # Host creates a lobby (with MMR in state) curl -X POST "https://api.computeflow.cloud/api/v3/lobbies/ranked" \ -H "api-key: pfclient_..." \ -H "x-player-id: p1" \ -d '{"name":"1v1","maxPlayers":1,"state":{"mmr":1500}}' # Host queues for matchmaking curl -X POST "https://api.computeflow.cloud/api/v3/lobbies/ranked/me/matchmaking" \ -H "api-key: pfclient_..." \ -H "x-player-id: p1" \ -d '{"mode":"1v1"}' # Separately, another player does the same — they match automatically # Both get the same matchId and server info curl "https://api.computeflow.cloud/api/v3/lobbies/ranked/me" \ -H "api-key: pfclient_..." \ -H "x-player-id: p1" # → { "status": "in_game", "server": { "network_ports": [{"host": "...", "external_port": 30234, "protocol": "udp", "tls_enabled": false}] } } # Read host + external_port straight from network_ports[] — never hardcode a port. ``` Stream matchmaking progress and queue stats via SSE. Full schemas for the matchmaking endpoints. # Lobbies & Matchmaking Overview Source: https://docs.playflowcloud.com/lobbies/overview Understand how PlayFlow lobbies and matchmaking work, and how to pick the right pattern for your game. PlayFlow's lobby system is the bridge between **"my players want to play together"** and **"here's your dedicated game server."** It handles everything in between: creating rooms, sharing invite codes, queueing for matchmaking, forming matches, and launching game servers. This is **V3**, PlayFlow's primary lobby API. It's a clean, player-centric REST API with SSE for real-time updates. If you're migrating from the older NestJS lobby system at `api.scale.computeflow.cloud`, that system continues to work — V3 is the new recommended path. ## Core Concepts ### Lobbies A **lobby** is a virtual room where players gather before a game starts. Every lobby has: * A **host** — the player who created it (has admin powers: kick, settings, start game) * A list of **players** with their individual state (ready, team, loadout, MMR, etc.) * **Settings** — game-specific data (map, mode, difficulty) that gets passed to the game server * A **status**: `waiting`, `in_queue`, `in_game`, etc. * Optional **invite code** for sharing with friends ### Lobby Configs A **lobby config** is a reusable template for lobbies. You define it once (in the dashboard or via API) and reference it by name in your code: * `casual` — 8 players, quick play, no MMR * `ranked_2v2` — 4 players, MMR-based matchmaking, 2 teams of 2 * `monster_hunt` — asymmetric, 1 monster vs 5 hunters Each config specifies server size, build, matchmaking modes, rules, and more. **No dashboard config needed to get started.** Any config name in the URL path works — PlayFlow falls back to sensible defaults (2 players, 5-minute timeout, small server size, "default" build) when no matching config exists. ### Matchmaking **Matchmaking** is when players queue up and the system finds them opponents. A matchmaking game is just a **group of lobbies grouped together pointing to the same game server**. When two (or more) lobbies enter the queue and match: 1. They all transition from `in_queue` to `in_game` 2. One dedicated game server is launched 3. All matched lobbies get the same `matchId` and server connection info 4. Players connect to the server and play Matchmaking supports: * **Traditional teams** — 2v2, 5v5, 100-player FFA, 50-duo battle royale * **Asymmetric roles** — 1 boss vs 5 hunters, custom team compositions * **5 rule primitives** — `difference` (skill buckets), `equals` / `not_equals` (version, anti-rematch), `region` (overlap), `expression` (CEL for anything custom) * **Auto-expanding buckets** — MMR ranges grow automatically as wait time grows * **Region-aware** — weighted voting across player region preferences * **Match confirmation** — CS2/LoL "Accept Match" dialog with configurable timeout * **Rematch** — host can end a match and keep the same players in the lobby * **Self-healing** — lobbies never dead-end, always recover when a server stops * **Backfill** — new players can join matches already in progress *** ## The Two Core Flows Most games use one of these patterns. Pick the one that fits your game. **"Just put me in a game."** The player presses a button, gets connected to a server within seconds. `POST /v3/lobbies/ranked` — instant, even with no friends. `POST /v3/lobbies/ranked/me/matchmaking` with a mode name. Another queueing lobby matches within seconds. All matched lobbies get a populated `server.network_ports[]`. Connect your game client to `host:external_port`. **Best for:** Ranked ladders, competitive play, quick play modes, Counter-Strike/League of Legends-style matchmaking. **"Create a game, share a code, friends join, start playing."** `POST /v3/lobbies/casual` — returns an invite code like `MEOW-42`. `POST /v3/lobbies/casual/join` with `{"code": "MEOW-42"}`. Each player sets their state: `PATCH /v3/lobbies/casual/me` with `{"state": {"ready": true, "team": "blue"}}`. `POST /v3/lobbies/casual/me/start` — launches a dedicated server immediately. **Best for:** Party games, Among Us-style lobbies, Minecraft-style private worlds, custom matches. You can combine both. A lobby can host friends, then optionally queue for matchmaking to find more players. *** ## Authentication Every request has two headers: | Header | Purpose | Example | | :------------ | :------------------------------- | :--------------------------- | | `api-key` | Your project's API key | `pfclient_abc123...` | | `x-player-id` | Unique identifier for the player | `player_12345` or any string | `x-player-id` is **not authenticated** by default — it's just an identifier, and PlayFlow trusts whatever value you send. For Phase 0 / prototyping, a random UUID per session is fine. **No player auth is required** to start using lobbies. Ship an MVP with random player IDs, add real auth later when you need it. When you need trusted identity, PlayFlow can verify players for you. Configure a player-auth provider — custom JWT, PlayFab, or Steam — and clients send an `x-player-token` header that PlayFlow verifies; the verified identity is used and `x-player-id` is ignored. See [Player Authentication](/guides/player-authentication) for setup. *** ## Response Shape Every lobby endpoint returns the same consistent shape: ```json theme={null} { "id": "a1b2c3d4-...", "code": "MEOW-42", "config": "ranked", "status": "waiting", "host": "player1", "maxPlayers": 8, "currentPlayers": 2, "region": "us-east", "isPrivate": false, "allowLateJoin": true, "settings": { "map": "forest" }, "players": [ { "id": "player1", "state": { "ready": true }, "isHost": true }, { "id": "player2", "state": { "ready": false }, "isHost": false } ], "server": null, "matchmaking": null, "matchId": null, "createdAt": "2026-04-01T00:00:00Z", "updatedAt": "2026-04-01T00:00:01Z" } ``` When matchmaking is active, `matchmaking` populates with `{ mode, startedAt, queueStats, confirmation }` — `confirmation` is only set while the lobby status is `match_found` (the accept/decline phase). When a game server is running, `server` mirrors the `GET /v3/servers/{id}` shape exactly, including `instance_id`, `status`, `region`, and `network_ports[]`. Read the connection host and port from `server.network_ports[]` (see Step 4 above) — never hardcode a port. *** ## Custom Properties `customProperties` on a lobby config is arbitrary JSON-serializable metadata attached to every lobby of that type. PlayFlow itself never reads these values — they are surfaced to game clients in the lobby response so you can drive filters, flags, and UI labels. ```json theme={null} { "customProperties": { "gameMode": "ctf", "mapRotation": ["forest", "desert", "ice"], "crossplayEnabled": true } } ``` Use this for whatever your game needs: game modes, map rotations, skill floor labels, feature flags, regional overrides. Anything you want visible to clients when they browse lobbies. *** ## What's Next Get a working lobby in 5 minutes with copy-paste curl commands. Create, join, update your own state, kick, automatic host handoff, start games. Configure modes, skill-based rules, asymmetric teams, expanding buckets. Subscribe to SSE for live lobby updates and queue stats. Every endpoint with request/response schemas and try-it-out. # Lobbies Quickstart Source: https://docs.playflowcloud.com/lobbies/quickstart Go from zero to a working multiplayer lobby in 5 minutes using nothing but curl. This guide gets you a working lobby in under 5 minutes. No dashboard setup. No player auth. Just curl. By the end, you'll have created a lobby, joined it with a second player, updated player state, and torn it all down. ## Prerequisites * A PlayFlow project (free tier works — [sign up here](https://app.playflowcloud.com)) * Your **Client API Key** (from Project Settings → starts with `pfclient_`) Use the **client** key (`pfclient_*`) for anything a game client can do. Use the **server** key (`pf_*`) only for trusted backend services or admin operations. This quickstart assumes no player-auth provider is configured on the project (the free-tier default), so the `x-player-id` header names the player. If you've set up JWT, PlayFab, or Steam auth, client-key calls must send a valid `x-player-token` instead — see the [player authentication guide](/guides/player-authentication). *** ## 1. Create a Lobby A lobby is created by a "host" player. The host is whoever sends the `x-player-id` header on the create request. ```bash theme={null} curl -X POST "https://api.computeflow.cloud/api/v3/lobbies/default" \ -H "api-key: pfclient_YOUR_KEY" \ -H "x-player-id: player1" \ -H "Content-Type: application/json" \ -d '{ "name": "My First Lobby", "maxPlayers": 4 }' ``` **Response:** ```json theme={null} { "id": "a1b2c3d4-...", "code": "MEOW-42", "config": "default", "status": "waiting", "host": "player1", "currentPlayers": 1, "maxPlayers": 4, "players": [ { "id": "player1", "state": null, "isHost": true } ], ... } ``` **You just made a lobby.** No dashboard config was needed — PlayFlow used defaults. Note the `code` field: that's the invite code your players share. ### About `{config}` The `default` in the URL is a **lobby config name**. If no config with that name exists in your dashboard, PlayFlow uses sensible defaults: * `timeout: 300s` * `serverSize: small` * Build: latest `default` `maxPlayers` isn't a config default — it comes from the create request body (omit it and you get `2`). Set it explicitly on each create call, as shown above. For production games, you'll create named configs in the dashboard (`casual`, `ranked_2v2`, etc.) with custom matchmaking rules and server overrides. But you don't need to yet. *** ## 2. Join as a Second Player Share the invite code `MEOW-42` with your friend. They call: ```bash theme={null} curl -X POST "https://api.computeflow.cloud/api/v3/lobbies/default/join" \ -H "api-key: pfclient_YOUR_KEY" \ -H "x-player-id: player2" \ -H "Content-Type: application/json" \ -d '{ "code": "MEOW-42", "state": { "team": "blue", "ready": false } }' ``` They could also join by **lobby ID** if they have it: ```bash theme={null} curl -X POST "https://api.computeflow.cloud/api/v3/lobbies/default/join" \ -H "api-key: pfclient_YOUR_KEY" \ -H "x-player-id: player2" \ -H "Content-Type: application/json" \ -d '{ "lobbyId": "a1b2c3d4-..." }' ``` The response is the full lobby, now with 2 players: ```json theme={null} { "id": "a1b2c3d4-...", "currentPlayers": 2, "players": [ { "id": "player1", "state": null, "isHost": true }, { "id": "player2", "state": { "team": "blue", "ready": false }, "isHost": false } ], ... } ``` *** ## 3. Check Your Current Lobby Any player can check which lobby they're in using `/me`: ```bash theme={null} curl "https://api.computeflow.cloud/api/v3/lobbies/default/me" \ -H "api-key: pfclient_YOUR_KEY" \ -H "x-player-id: player2" ``` Returns the full lobby state. If you're not in a lobby, returns `404`. `/me` is the player-centric primitive throughout the API. Instead of tracking lobby IDs in your game, just use `/me` endpoints — PlayFlow finds your lobby automatically from the `x-player-id` header. *** ## 4. Update Player State Ready up, pick a character, set your MMR — anything goes in player state. The server merges your updates into existing state. ```bash theme={null} curl -X PATCH "https://api.computeflow.cloud/api/v3/lobbies/default/me" \ -H "api-key: pfclient_YOUR_KEY" \ -H "x-player-id: player2" \ -H "Content-Type: application/json" \ -d '{ "state": { "ready": true, "loadout": "sniper", "mmr": 1500 } }' ``` The response shows `player2`'s updated state: ```json theme={null} { "players": [ { "id": "player1", "state": null, "isHost": true }, { "id": "player2", "state": { "team": "blue", "ready": true, "loadout": "sniper", "mmr": 1500 }, "isHost": false } ], ... } ``` State updates **merge** — existing keys are preserved unless overwritten. Setting `ready: true` doesn't wipe out `team: blue`. *** ## 5. Start the Game When ready, the host launches a game server directly: ```bash theme={null} curl -X POST "https://api.computeflow.cloud/api/v3/lobbies/default/me/start" \ -H "api-key: pfclient_YOUR_KEY" \ -H "x-player-id: player1" ``` The response now has `server` populated: ```json theme={null} { "status": "in_game", "server": { "instance_id": "srv-abc123", "status": "launching", "region": "us-east", "network_ports": [ { "name": "game_udp", "host": "203.0.113.42", "external_port": 30234, "internal_port": 7770, "protocol": "udp", "tls_enabled": false } ] }, ... } ``` Use `server.network_ports[0].host` and `server.network_ports[0].external_port` to connect your game client. The `server` block is the same shape as `GET /v3/servers/{instance_id}`. The server transitions from `launching` → `running` in typically 10-30 seconds. Only the **host** can start the game. If a non-host tries, they get a `403` with the message "Only the host can perform this action". Lobby errors return `{error, detail, status}` — branch on the HTTP status, not the message text. *** ## 6. Leave (Cleanup) Every player leaves with: ```bash theme={null} curl -X DELETE "https://api.computeflow.cloud/api/v3/lobbies/default/me" \ -H "api-key: pfclient_YOUR_KEY" \ -H "x-player-id: player2" ``` When the **last player** leaves, the lobby auto-deletes. The response is `{"status": "lobby_deleted"}`. If the **host** leaves while others remain, host transfers to the next player automatically. *** ## That's It You've now exercised the full lobby lifecycle using just 6 API calls. The full API adds matchmaking, host-only actions (kick, settings), invite code joining, lobby browsing, real-time SSE updates, and more — but this is the 80% of what most games need. Host-only actions (kick, settings, transfer host), leaving behavior, error handling. Skip the manual "start" — let PlayFlow find opponents automatically. Stop polling. Subscribe to live events with SSE. Every endpoint with full schemas and try-it-out. # Real-time Events Source: https://docs.playflowcloud.com/lobbies/real-time Subscribe to live lobby updates with Server-Sent Events (SSE). Stop polling. Get instant notifications when players join, matches form, servers launch. PlayFlow provides a single SSE endpoint per player that streams everything you need: lobby updates, queue stats, match notifications, and server status changes. No polling required. ## The Endpoint ```bash theme={null} GET /v3/lobbies/{config}/me/events x-player-id: {player_id} api-key: {key} Accept: text/event-stream ``` * **One connection per player** is enough — all events for their current lobby flow through it * The connection stays open indefinitely (with keep-alive pings every 30s) * When the player leaves the lobby, close the connection **Player auth:** If your project has a player-auth provider configured (`custom_jwt`, `playfab`, or `steam`), client-key (`pfclient_*`) requests must also send an `x-player-token: {verified token}` header — `x-player-id` alone only works when the provider is `none` or when you use a server key (`pf_*`). Without it, every request below returns `401`. See [Player Authentication](/guides/player-authentication) for setup. The SSE connection doubles as a **heartbeat**. As long as the stream is open, PlayFlow knows the player is alive. You don't need a separate heartbeat endpoint unless you're not using SSE (e.g., polling clients on legacy platforms). *** ## Event Types | Event | When it fires | Data | | :-------------- | :--------------------------- | :----------------------------------------------------- | | `connected` | Right after connection opens | Full lobby state | | `lobby_updated` | Any time the lobby changes | Full lobby state | | `queue_stats` | Every 10s while `in_queue` | `{ playersSearching, lobbiesInQueue, avgWaitSeconds }` | | `lobby_deleted` | Lobby was deleted | `{ id, reason }` | | `ping` | Every 30s (keep-alive) | `{ ts }` (epoch ms) | ### Event: `connected` Fires immediately on connection. Gives you the full current state — no need to call `GET /me` first. ``` event: connected data: {"id":"abc-123","status":"waiting","host":"p1","players":[...],...} ``` ### Event: `lobby_updated` Fires whenever the lobby changes: * A player joins or leaves * Host kicks someone * A player updates their state * Host changes settings * Host starts matchmaking / game * Match found → status becomes `in_game` * Game server status changes (launching → running) ``` event: lobby_updated data: {"id":"abc-123","status":"in_game","server":{"status":"running","network_ports":[{"name":"game_udp","host":"1.2.3.4","external_port":30234,"internal_port":7770,"protocol":"udp","tls_enabled":false}]},...} ``` **Key usage:** When `status` becomes `in_game` and `server.status` becomes `running`, read `host` and `external_port` from `server.network_ports[]` (never assume a fixed port — the external port is proxy-allocated) and connect your game client. ### Event: `queue_stats` Only fires while `status: in_queue`. Emitted every 10 seconds. ``` event: queue_stats data: {"playersSearching":42,"lobbiesInQueue":8,"avgWaitSeconds":12.3} ``` Use this to show live "42 searching, \~12s wait" in your matchmaking UI. ### Event: `lobby_deleted` Fires when the lobby is deleted (last player left, host deleted it, or cleanup timeout). ``` event: lobby_deleted data: {"id":"abc-123","reason":"match_ended"} ``` The `reason` field tells you why the lobby closed, so you can differentiate a completed match from a timeout: | `reason` | Meaning | | :------------------ | :---------------------------------------------------------------- | | `match_ended` | The game finished (lobby was `in_game`) | | `closed_or_expired` | Closed before play — last player left, cleanup timeout, or expiry | | `closed` | Closed for any other reason | Close the connection on this event — there's nothing left to subscribe to. ### Event: `ping` Keep-alive every 30 seconds. Carries a `{ ts }` payload (epoch milliseconds). You can ignore these in your handler — they exist only to keep the connection alive through proxies and load balancers. ``` event: ping data: {"ts":1752345678901} ``` *** ## Client Examples ```javascript theme={null} const playerId = "player_123"; const config = "ranked"; const apiKey = "pfclient_..."; // Note: EventSource doesn't support custom headers directly. // Pass the api-key and player-id via query params or use a polyfill. const url = `https://api.computeflow.cloud/api/v3/lobbies/${config}/me/events`; // Use fetch + ReadableStream for full header support: const res = await fetch(url, { headers: { "api-key": apiKey, "x-player-id": playerId, "Accept": "text/event-stream", }, }); const reader = res.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const events = buffer.split("\n\n"); buffer = events.pop() ?? ""; for (const block of events) { const eventMatch = block.match(/^event: (.+)$/m); const dataMatch = block.match(/^data: (.+)$/m); if (!eventMatch || !dataMatch) continue; const eventType = eventMatch[1]; const data = JSON.parse(dataMatch[1]); switch (eventType) { case "connected": case "lobby_updated": console.log("Lobby:", data); if (data.status === "in_game" && data.server?.status === "running") { const port = data.server.network_ports[0]; connectToGameServer(port.host, port.external_port); } break; case "queue_stats": updateQueueUI(data.playersSearching, data.avgWaitSeconds); break; case "lobby_deleted": console.log("Lobby deleted"); return; } } } ``` ```csharp theme={null} using System.Collections; using UnityEngine; using UnityEngine.Networking; public class LobbyEventStream : MonoBehaviour { [SerializeField] string apiKey = "pfclient_..."; [SerializeField] string playerId = "player_123"; [SerializeField] string config = "ranked"; UnityWebRequest request; public IEnumerator Connect() { var url = $"https://api.computeflow.cloud/api/v3/lobbies/{config}/me/events"; request = UnityWebRequest.Get(url); request.SetRequestHeader("api-key", apiKey); request.SetRequestHeader("x-player-id", playerId); request.SetRequestHeader("Accept", "text/event-stream"); request.downloadHandler = new SSEDownloadHandler(OnEvent); yield return request.SendWebRequest(); } void OnEvent(string eventType, string data) { switch (eventType) { case "connected": case "lobby_updated": var lobby = JsonUtility.FromJson(data); HandleLobbyUpdate(lobby); break; case "queue_stats": var stats = JsonUtility.FromJson(data); UpdateQueueUI(stats); break; case "lobby_deleted": CleanupAndGoToMenu(); break; } } void HandleLobbyUpdate(Lobby lobby) { if (lobby.status == "in_game" && lobby.server?.status == "running") { var port = lobby.server.network_ports[0]; // Connect your netcode (Mirror, Fishnet, Netcode for GameObjects...) NetworkManager.singleton.StartClient(port.host, port.external_port); } } } ``` The PlayFlow Unity SDK ships with an SSE handler. You don't need to write this from scratch. ```gdscript theme={null} extends Node var api_key = "pfclient_..." var player_id = "player_123" var config = "ranked" var http_client: HTTPClient func connect_events(): http_client = HTTPClient.new() http_client.connect_to_host("api.computeflow.cloud", 443, TLSOptions.client()) while http_client.get_status() == HTTPClient.STATUS_CONNECTING or http_client.get_status() == HTTPClient.STATUS_RESOLVING: http_client.poll() await get_tree().process_frame var headers = [ "api-key: " + api_key, "x-player-id: " + player_id, "Accept: text/event-stream", ] http_client.request(HTTPClient.METHOD_GET, "/api/v3/lobbies/%s/me/events" % config, headers) # Process stream (simplified) while http_client.get_status() == HTTPClient.STATUS_BODY: http_client.poll() var chunk = http_client.read_response_body_chunk() if chunk.size() > 0: process_sse_chunk(chunk.get_string_from_utf8()) await get_tree().process_frame ``` ```bash theme={null} curl -N \ -H "api-key: pfclient_..." \ -H "x-player-id: test_player" \ -H "Accept: text/event-stream" \ "https://api.computeflow.cloud/api/v3/lobbies/default/me/events" ``` Output (live): ``` event: connected data: {"id":"abc-123","status":"waiting",...} event: lobby_updated data: {"id":"abc-123","status":"waiting","currentPlayers":2,...} event: ping data: {"ts":1752345678901} ``` Useful for debugging — watch events live as you trigger state changes from another terminal. *** ## Handling Disconnects SSE connections can drop due to network issues, proxy timeouts, or server restarts. Your client should: 1. **Detect disconnect** — the read loop exits or the connection errors 2. **Reconnect automatically** — with exponential backoff (1s, 2s, 4s, max 30s) 3. **Replay state on reconnect** — the `connected` event sends the current full state, so you can recover cleanly While disconnected, you **won't receive events**. You might miss a match, a kick, or a server ready event. Your reconnect logic should fetch `GET /me` on reconnect to ensure state consistency, OR rely on the `connected` event's initial state. *** ## Polling as a Fallback If SSE isn't available (e.g., your platform blocks streaming), poll `GET /me` instead: ```bash theme={null} # Every 2-5 seconds curl "https://api.computeflow.cloud/api/v3/lobbies/{config}/me" \ -H "api-key: pfclient_..." \ -H "x-player-id: {player}" ``` When polling, enable `heartbeat: true` in your lobby config and send a heartbeat on each tick (well under `heartbeatTimeout` seconds). Without SSE keeping the player implicitly alive, the `lobby-sweep` cron will otherwise evict inactive players: ```bash theme={null} POST /v3/lobbies/{config}/me/heartbeat x-player-id: {player} ``` *** ## The Full Matchmaking Flow with SSE This is what a player's event stream looks like during a matchmaking session: ``` # 1. Connect event: connected data: {"status":"waiting","players":[{"id":"p1","isHost":true}],...} # 2. Player starts matchmaking event: lobby_updated data: {"status":"in_queue","matchmaking":{"mode":"5v5","queueStats":{...}}} # 3. Queue stats stream every 10s event: queue_stats data: {"playersSearching":42,"avgWaitSeconds":12.3} event: queue_stats data: {"playersSearching":45,"avgWaitSeconds":11.8} # 4. Match found! Server launching event: lobby_updated data: {"status":"in_game","matchId":"m_abc","server":{"status":"launching","network_ports":[]}} # Note: if match confirmation is enabled for the mode, an intermediate # lobby_updated with "status":"match_found" fires first — each player must # call POST /me/confirm-match before the lobby advances to "in_game". # 5. Server ready event: lobby_updated data: {"status":"in_game","server":{"status":"running","network_ports":[{"name":"game_udp","host":"1.2.3.4","external_port":30234,"internal_port":7770,"protocol":"udp","tls_enabled":false}]}} # → Client now connects to 1.2.3.4:30234 (external_port) # 6. Game ends, lobby cleanup event: lobby_deleted data: {"id":"abc-123","reason":"match_ended"} ``` That's the entire competitive matchmaking experience — queue, find opponents, launch server, connect — delivered through one SSE stream. How matchmaking finds opponents and forms matches. Full SSE endpoint reference. # Quick Start - Fish-Net Source: https://docs.playflowcloud.com/quickstart/fishnet Step-by-step tutorial for getting up and running with PlayFlow Cloud # Getting Started with PlayFlow