How a player moves from the Funday play shell into a Nakama match, and how the host, game, and runtime keep one shared state.

Audience marker: The first half of this guide contains “Start here” material for game authors integrating multiplayer. The second half contains “Maintainership detail” material for platform developers changing RPCs, runtime handlers, or the play shell.

Start here — the five truths

The five truths

  1. The /play/[id] shell owns the lobby drawer, Nakama connection, and session injection (funday:session-inject). Embedded games communicate through the bridge and never invoke .authenticateDevice() or manage their own connections.
  2. A game is multiplayer only when its manifest (funday-plugin.json) permits more than one player; the registry is generated deterministically from these manifests.
  3. The canonical match search entry point is find_match_v3; it either returns an existing compatible match ID or creates a new match and returns its ID; it never joins, and POST does not return a no-match branch. Shell joins afterward.
  4. Nakama is authoritative for membership, capacity limits, match lifecycle, and gameplay messaging; the game must not invent a competing lobby state.
  5. Match state returns to iframe games through named bridge events (funday:match-joined, funday:match-start, funday:match-state, funday:lobby-state, funday:match-settings, funday:match-error), preserving byte-for-byte binary fidelity (Uint8Array) in both directions.
If you need to…ReadThen verify
Integrate a gameRecipes (Make an existing game multiplayer)Two-session browser happy path
Change matching rulesContracts (Matching request and result)Match RPC rejection behavior
Debug a stuck playerFailure modes and player-visible recoveryDrawer UI reflects disconnected state
Audit a titleShip checklistAudit linkage evidence

Choose the right path

flowchart TD
    A[Does the game support >1 player?]
    A -- no --> B[Single-player; no lobby action]
    A -- yes --> C[Open/use lobby drawer]

    C --> D[Is a specific match/invite requested?]
    D -- yes --> E[Validate then join requested match]
    D -- no --> F[Call find_match_v3]

    F --> H[find_match_v3 returns an existing or newly created Match ID]
    H --> I[Shell joins match and synchronizes]
    E --> I

    I --> J[Is this a dedicated-server title?]
    J -- yes --> K[Route to Agones allocation/readiness]
    J -- no --> L[Route to authoritative Nakama handler]

    K --> M[Bridge match state to game]
    L --> M

Match Authorization

A Match ID is not authorization; validate membership before joining or issuing session state.

Runtime Topology

The Nakama runtime exposes several ports and components in the environment:

  • HTTP / WebSocket API: 7350
  • gRPC API: 7349
  • Console: 7351
  • Prometheus Metrics: 9100

The runtime requires a Postgres persistence layer and executes custom authoritative match handlers (Lua/TypeScript). For dedicated server sessions, it coordinates handoff via the Agones allocator.

Operational Truth (INTENDED vs LIVE)

GitOps intended state: Argo apps declare Vault (gitops/argocd-apps/vault-app.yaml), ESO (gitops/argocd-apps/external-secrets-app.yaml), and Nakama at gitops/platform/base/nakama/ with replicas: 3 in deployment.yaml. Live-cluster claim (audit trail; re-verify with kubectl/Argo before acting): production cutover remains blocked on a single-node observation — Vault/ESO not live, StorageClass cannot satisfy durable HA Vault storage. GitOps presence ≠ live readiness. Re-verified 2026-07-21. Do not rotate credentials, remove legacy Secrets, or retire the existing Nakama workload until an authorized migration is verified with fresh cluster evidence.

Lifecycle — one state, four participants

The sequence below illustrates the lifecycle path: Player → Play shell / GameDrawer → Nakama socket + RPC → authoritative match handler → Play shell → embedded/native game.

sequenceDiagram
    autonumber
    participant P as Player
    participant S as Play Shell (Drawer)
    participant N as Nakama (RPC/Runtime)
    participant G as Game Plugin

    P->>S: Play route injects session / waits for socket
    P->>S: Opens Lobby
    S->>N: Lists state / receives presence & chat
    P->>S: Selects or finds match
    S->>N: Calls find_match_v3
    N-->>S: Validates inputs, creates/selects match
    S->>N: Joins/subscribes to match socket
    N-->>S: Updates shared lobby/match state
    S->>N: Calls find_match_v3 (exact modes: quick | host | ai)
    P->>S: Host starts match (when rules permit)
    S->>G: Emits funday:match-start
    G->>N: Sends gameplay actions via bridge
    N-->>G: Broadcasts authoritative state
    P->>S: Leave / disconnect
    S->>N: Performs cleanup, updates presence
    S->>G: Tells game the resulting state
Who owns what?Responsibilities
Play shellDrawer UI, URL intent consumption, socket ownership, bridge communication.
Nakama runtimeValidation, match selection/creation, authority, presence.
Game pluginRules, UI, action handling within its registered mode.
Dedicated server / AgonesAllocation and readiness only (when applicable).

Contracts you must preserve

4.1 Entry and eligibility

The shell MUST expose multiplayer only for a game mode that declares it. The runtime registry (GAME_CONFIGS) is generated and checked in CI based on game manifests (funday-plugin.json); manual backend arrays are prohibited.

Field/eventMeaningOwner/consumer
maxPlayers / session.modeDefines capacity and mode capability (nakama-match, agones-server-list, etc.)Manifest / Shell
?join= / ?autostart=URL intentShell router
socketStore.readyNakama connection readinessShell

Relevant Manifest Fields Excerpt (funday-plugin.json):

{
  "id": "connect4",
  "integrationType": "iframe-themeable",
  "metadata": {
    "maxPlayers": 2
  },
  "backend": {
    "matchHandler": "server/match_handler.lua",
    "nakamaProxy": "connect4_match"
  }
}

Note: If a session.mode block is absent (as above), it resolves to a default networking mode through the shell’s session policy.

See the Play Shell Cheatsheet for visual shell geometries and component structure.

4.2 Matching request and result

find_match_v3 is the canonical matchmaking boundary; callers MUST pass validated game/mode intent (quick, host, ai, or roam) and consume its documented result. Unknown fields/modes, oversized bodies, and caller-raised capacities are rejected before runtime creation with exact HTTP semantics (400, 401, 403, 404, 409, 413, 422, 502, 503).

ContractImplementationOutcome
POST /api/matchesCalls find_match_v3 with {gameId, settings, mode, withAI, queueType}Returns {success, match_id}
GET /api/matchesCalls nk.matchList with label.game:<id> label.open:true -label.queueType:ranked for casual (label.queueType:ranked for ranked). No + prefixes — a +-prefixed term silently misses live lobbies. An explicit mode appends label.mode:<mode> for every mode except quickReturns MatchEntry[], each entry carrying its own mode (throws 502 on upstream failure)

Mode policies

MODE_POLICIES in nakama-modules/find_match_rpc.ts is the single place where a mode’s matching behaviour is declared. Each mode states whether it reuses an open match, whether the label query is mode-scoped, whether the match auto-starts, and whether it uses the roam rendezvous.

ModeReuses an open matchMode-scoped labelAuto-startRendezvous
quickyesnoyesno
hostno (always creates)nonono
aino (always creates)noyesno
roamyesyesnoyes

Discoverable modes (public browse)

DISCOVERABLE_MATCH_MODES in games/_platform/contracts/matchmaking.ts is the public-browser allowlist: quick, host, roam. ai is excluded because find_match_v3 stamps withAI: true on those labels and bots occupy the free seats, so listing one as a joinable lobby misrepresents its capacity.

A GET without a mode parameter is a browse: the route intersects the game’s manifest-declared modes with DISCOVERABLE_MATCH_MODES and widens only in the post-filter. Nakama’s indexed-label syntax is whitespace-conjunctive with no OR, so the emitted query carries no label.mode term at all. A GET with a mode parameter keeps single-mode behaviour and the mode-scoped query.

Browse guarantees, all enforced in frontend/src/routes/api/matches/+server.ts:

  • every returned entry carries its own mode;
  • labels with withAI: true are dropped — a bot-filled match is never advertised as joinable;
  • entries with size >= max_size are dropped, so a stale open label cannot advertise a full match;
  • entries are deduplicated by match_id.

Defaulting a missing mode to quick is the historical defect this policy replaced: every mode: "host" lobby was filtered out of the very browser that exists to surface it.

Roam rendezvous — one park, atomically

List→create is non-atomic, so two clients asking for roam in the same instant both saw an empty list and both created a park. That is how two players who raced together could return to different parks. roam therefore publishes its shard through a compare-and-swap rendezvous record (system-owned storage, collection match_rendezvous, key <gameId>:<mode>:<queueType>):

  1. Read the record. If it names a match that is still real, open, same game/mode/queue, and below capacity, return that match and create nothing.

  2. Otherwise create, then publish with the version observed during the read (* when no record existed).

  3. A version conflict means another request won. The loser adopts the winner and never re-writes the record. A match that exists (matchGet) is enough to adopt: the winner was created milliseconds ago, so its discovery label may not be indexed yet, and demanding a queryable label made both racers keep their own parks. The loser’s fresh empty shard is left to its own empty-match timeout.

  4. A request carrying excludeMatchIds wants isolation: it MUST NOT be answered with the recorded park and MUST NOT repoint the record at its private shard.

The record owner is not optional throws on userId: '' for storage reads. That failure was swallowed by the rendezvous' own fallback (rendezvous read unavailable; creating match without rendezvous), so the feature looked deployed while every request silently took the plain create path — and unit tests could not see it because they mock storage. Use the platform's zero-UUID system owner (00000000-0000-0000-0000-000000000000), and confirm the branch actually ran in the Nakama logs (rendezvous published / SUCCESS rendezvous_match) before believing it.

Nakama’s JS runtime

Availability is preferred over convergence when storage itself fails: a read/write error returns the freshly created match rather than failing the request, so convergence is best-effort during a Nakama storage outage and atomic otherwise. Game runtimes MUST still keep an empty-shard grace long enough to cover a full round, because the rendezvous points at a live match, not a resurrected one (Theme Dash uses THEME_DASH_ROAM_EMPTY_TIMEOUT_TICKS, 300s).

Correlation Safety

An X-Correlation-ID header is required at the BFF and flows through Nakama RPCs and Agones logs to trace match lifecycles without logging sensitive payloads or identity.

Trust Boundary

Do not treat client-provided game, match, host, or membership fields as authority. Validate them in the runtime. Never instruct iframe games to create a Nakama client or authenticate directly.

4.3 Lifecycle and bridge events

The host MUST translate authoritative shell state into namespaced bridge events; iframe games never import host stores directly.

EventMeaningDirection / Emitter
funday:session-injectPlayer identity establishedShell → Game
funday:lobby-statePre-game participant stateShell → Game
funday:match-joinedMatch connection successfulShell → Game
funday:match-startHost initiated the gameShell → Game
funday:match-stateAuthoritative match-state delivery carrying {matchId, opcode, state, raw}Shell → Game
game:readyGame finished loadingGame → Shell
game:error / game:closeGame encountered failure/exitGame → Shell

Iframe FundayBridge Recipe:

<script src="/games/assets/_sdk/funday-bridge.umd.js"></script>
<script>
  let currentMatchId = null
 
  // Note: the UMD bundle exposes the constructor on a `.FundayBridge` namespace.
  const bridge = new window.FundayBridge.FundayBridge({
    onMatchJoined: (payload) => {
      currentMatchId = payload.matchId
    },
    onMatchStart: (payload) => {
      // MatchStartPayload: { matchId, players, startedBy, settings }
      console.log("Match started with players:", payload.players)
      startGame(payload.matchId, payload.players)
    },
    onMatchState: (payload) => {
      // payload: { matchId, opcode, state, raw }
      // `raw` preserves the original Uint8Array.
    },
    onMatchLeft: (payload) => {
      currentMatchId = null
    },
    onMatchError: (payload) => {
      console.error("Match error:", payload.error)
    },
  })
 
  bridge.init()
  bridge.ready()
 
  function sendBinaryUpdate(bytes) {
    if (currentMatchId) {
      bridge.sendMatchState(currentMatchId, 100, bytes)
    }
  }
</script>

See Bridge for the complete event vocabulary.

4.4 Ownership, authorization, and cleanup

Join, start, leave, reconnect, and disconnect are state transitions with server-side authorization and explicit shell/game synchronization.

TransitionAuthorized actorRuntime actionShell-visible outcome
JoinAny valid playerServer performs authoritative admission check; late-slot race loss returns ERR_MATCH_UNAVAILABLE.Bounded UI retry (max 3), then drawer update
ReadyValid playerRecords player readiness (Opcode 5)UI marks player Ready
StartMatch Creator / HostValidates host (Opcode 10, 12s guard), locks match, triggers dedicated allocation if configuredEmits funday:match-start
LeaveConnected playerRemoves presence, updates count. Dedicated GameServer hook fires actual release.Removes player from drawer
GameplayActive playerBroadcasts game-specific opcodesGame viewport updates
ReconnectDropped playerShell refreshes session identity and reconnects socket (same user ID).Emits lobby/match state

Recipes

Make an existing game multiplayer

Goal: Convert a single-player title to use authoritative matchmaking.

  1. Confirm backend.matchHandler, backend.nakamaProxy, and session.mode capacity in funday-plugin.json.
  2. Declare and consume the dock lobby affordance inside your game plugin.
  3. Wait for socketStore.ready to be true in the shell.
  4. Use the canonical matching RPC (find_match_v3) via the drawer UI.
  5. Consume bridge lifecycle events (funday:match-start) to initialize the game.
  6. Verify: Test two browser sessions and ensure the second player receives the exact same match state.

Add a matchmaking mode or constraint

Goal: Introduce new matching rules (e.g., ranked, custom modifiers).

  1. Define server-authoritative match criteria (labels) in your runtime handler.
  2. Update the POST /api/matches and RPC logic to handle the new constraint (queueType, etc.).
  3. Define strict acceptance and no-match response payloads.
  4. Map the response to the drawer feedback UI (e.g., “Waiting for players”).
  5. Verify: Exercise joining with compatible players (success) and incompatible players (rejection).

Support invite, practice, or reconnect intent

Goal: Handle deep links and connection drops gracefully.

  1. Resolve the URL intent (?join=) once in the shell router.
  2. Validate the target match and membership server-side via RPC.
  3. Join/rejoin the match or show a clear recovery action (e.g., “Match expired”).
  4. Clear the URL intent immediately so that a page refresh does not replay it.
  5. Verify: Disconnect mid-match, reload the page, and observe correct session rehydration.

Dedicated-server title: hand off after matching

Goal: Use Nakama for matchmaking, but play on an Agones dedicated pod.

  1. Define a dedicated server flow (see session mode matrix below).
  2. Call matchmaking to find a match.
  3. The runtime allocates a dedicated server (via agones-allocator.ts).
  4. The shell connects to the returned IP/Port for gameplay.
  5. Verify: Inspect the allocation readiness and ensure the client connects to the pod, not Nakama’s socket, for gameplay ticks.

Session Mode Decision Matrix

Funday games declare a session.mode in their manifest to dictate how the shell handles networking and matchmaking. These states are verified in sessionPolicy.ts and frontend/src/lib/types/index.ts.

Session ModeDefault TargetBehavior & Verification
nakama-matchsvelte-component & iframe-themeableUses usesTrackedMatchSessions(); shell tracks an authoritative Nakama match. Defaults to allowSoloAutostart: true.
agones-server-listdedicated-serverRaw server allocation without tracking a formal Nakama match. Defaults to allowSoloAutostart: false.
dedicated-match-firste.g., AgarUses usesTrackedMatchSessions(). Shell tracks the match in Nakama, then performs Agones server allocation.
dedicated-native-lobbye.g., SisterBrawlVerifiable via usesNativeDedicatedLobby(). Shell matchmaking is completely suppressed; the native game owns the lobby UX.

Failure modes and player-visible recovery

SymptomLikely boundaryPlayer-safe responseDeveloper check
Socket is not readyAuth / socket bootstrapDisabled loading action, clear reconnect state. Never a silent click.Inspect socketStore.ready and auth.
Match RPC rejects intentRequest / runtime authorization”This match is unavailable” plus return to lobby.Validate input and membership labels.
No compatible matchMatching policy / capacityWaiting/refresh affordance with clear state.Inspect matching criteria and active presences.
Join race lost / unavailableAdmission auth”Retrying” phase (distinct candidates), up to 3 times.Inspect ERR_MATCH_UNAVAILABLE signal in logs.
List failures / 502Upstream / NakamaExplicit 502 / Service Unavailable error (not “0 matches”). The drawer sets discoveryFailed and renders “Couldn’t load matches” with a “Retry loading matches” action instead of “No open matches”.Check match_list_unavailable in the BFF response; a failed unfiltered-list fallback logs warn Unfiltered match listing fallback failed with correlationId and gameId.
Host cannot startLifecycle rule / required playersCommunicate missing requirement (e.g., “Need 2 players”).Inspect participant count and host auth.
Disconnect mid-matchSocket / session rehydrationReconnecting state, then rejoin or explicit expired-match.Inspect match existence and cleanup.
Dedicated server not readyAllocator / game-server boundary”Preparing server” state with cancel option.Inspect allocation/readiness, not generic Nakama error.

Ship checklist

Game author

  • Capacity and mode are correctly declared in manifest.
  • No duplicate lobby UI exists inside the game viewport.
  • Lobby action is visually discoverable.
  • All named bridge inputs (funday:match-joined, funday:match-start) are handled.
  • Loading, no-match, reconnect, and leave UIs are visible.
  • Keyboard labels and status announcements are present for accessibility.
  • Two-session happy path manually exercised.
  • One disconnect/reconnect path manually exercised.

Maintainer / reviewer

  • RPC strictly validates identity and the requested target.
  • Server absolutely owns the state transition.
  • Host and start authorization rules are fully covered.
  • Payload and event names match the canonical bridge reference.
  • Single-player regression checked (lobby does not interfere).
  • Dedicated path is explicitly opted in via manifest.
  • Observability and audit evidence are captured.

Official baseline and the Funday adaptation

Funday is built upon standard Nakama and Agones primitives but enforces specific architectural constraints. The official documentation describes the underlying technology, but does not prove or reflect Funday’s exact runtime behavior or trust boundaries:

ConceptThe Funday Adaptation
MatchmakingWraps listing and creation in the synchronous find_match_v3 RPC rather than using Nakama ticket matchmaking.
Join capacityList→join is non-atomic. matchJoinAttempt is the definitive capacity and authorization gate; callers require late-rejection recovery.
Authoritative handlersMust explicitly own and implement capacity, join-in-progress logic, idle/empty termination, and generic validation.
Auth & transportThe canonical path sources identity and the socket connection from the platform shell. Direct game authentication is audited drift.
Dedicated allocationImplemented as a deliberate Agones handoff rather than native Nakama logic.

Repository Source Map

For deep architectural reference, consult these core files:

  • BFF/API: frontend/src/routes/api/matches/+server.ts
  • RPC: nakama-modules/find_match_rpc.ts
  • Generic Server Logic: games/_platform/server/generic_match.ts
  • Game Registry: games/_platform/server/game_registry.generated.ts (generated)
  • Shell State: frontend/src/lib/stores/lobbyState.svelte.ts
  • Drawer Matchmaking: frontend/src/lib/components/games/drawer/useDrawerMatchmaking.svelte.ts
  • Bridge SDK: games/_sdk/funday-bridge.ts
Audit questionEvidence to captureCanonical reference
Are game capabilities valid?Manifest mode / capacityGame Plugins
Does the RPC register correctly?RPC and runtime registrationNakama Multiplayer Audit (2026-07-15)
Are events firing properly?Bridge delivery payloadsBridge
Is the UI matching state?Shell drawer statePlay Shell Cheatsheet

Source Boundary

This page documents the platform contract. Game-specific exceptions belong beside the game’s integration/audit material and must link back here.


Integration Evidence & Operations

The platform architecture has repository and isolated integration proof; that is distinct from an unperformed production cutover:

  • Test Harness: Fresh Nakama and Postgres instances execute npm run test:integration:nakama.
  • Offline/Online: Browser disconnect/reconnect and dedicated allocation smoke paths remain required operational validation before production closure.
  • Operator Runbooks: Production secret-plane and deployment changes require durable storage, Vault/ESO provisioning, and an authorized migration window.

For the dated findings, current local evidence, and explicit production blockers, see the Nakama Multiplayer Audit (2026-07-15).

Historical supporting

Ask Docs

AI assistant to help answer questions about the documentation. Answers are read-only and cite docs/source.

Hi! How can I help you with the documentation today? Answers are read-only and cite docs/source.

Ctrl+Enter to send