#Multiplayer

Multiplayer is a shared arcade capability, like accounts and leaderboards. Your game gets rooms (named channels), presence (who's here), and invites (with a notification inbox) over a single WebSocket, with no per-game socket server. Authoritative game logic stays server-side in your existing backend module (see saving game data), so the transport is generic but the rules aren't trusted to the client.

#Rooms

A room is a named channel scoped to your game slug (so match:abc in Starship and in Sightlines never collide). Auth is automatic: the SDK gets a short-lived token from the API (the prod socket is cross-origin, so the session cookie can't reach it). Signed-out users can't open the socket.

const room = await Arcade.realtime.join("match:abc123");

room.on("presence", (members) => renderLobby(members)); // [{ accountId, name }, …]
room.on("message", ({ from, data }) => applyOpponentEvent(from, data));
room.on("close", () => showReconnecting());

room.send({ t: "cursor", card: 2, tile: [1, 3] });  // ephemeral broadcast to everyone else
room.match({ t: "play", card: 2, tile: [1, 3] });   // server-authoritative action → your backend's realtimeMatch
room.members;       // current members
room.close();       // leave

join() resolves once connected. It auto-reconnects (capped backoff), re-joins on a dropped socket, and keepalive-pings for you. send() broadcasts to other members (not echoed back); match() routes to your server-authoritative backend (below), whose replies come back as ordinary "message" events you render.

#Invites + notifications

Invites land in the recipient's inbox and drive the notification badge in the user menu. Two ways to start a match map onto this:

  • Share a URL: create a room id, link to e.g. /games/<slug>/?join=<roomId>; the other player opens it and Arcade.realtime.join("match:<roomId>").
  • Invite by name/email: typeahead with Arcade.players.search, then Arcade.invites.send.
const matches = await Arcade.players.search("ann");   // [{ accountId, name }, …]
await Arcade.invites.send({ to: matches[0].accountId, kind: "match",
  payload: { room: "abc123", url: "/games/sightlines/?join=abc123" } });

Arcade.invites.onChange((items) => setBadge(items.length)); // live inbox → badge
const inbox = await Arcade.invites.inbox();
await Arcade.invites.accept(id);  // or .decline(id), then open payload.url / join the room

The inbox is cross-game (keyed to the player, not the game), so an invite to any game reaches the player wherever they are.

#Drop-in notifications widget

You don't have to build the bell yourself. Arcade.notifications.mount(el) renders a self-contained bell + badge + dropdown (it injects its own styles), driven by the cross-game inbox. One line per game:

const unmount = Arcade.notifications.mount(document.querySelector("#arcade-notif"), {
  // optional: handle your own game's invites in-app instead of navigating
  onAccept: (invite) => joinMatch(invite.payload.room),
});

Accepting navigates to invite.payload.url by default (so a cross-game invite takes the player to the right game), or calls onAccept(invite) if you pass it. Call the returned function to unmount (e.g. before re-rendering your nav).

#Server-authoritative logic

Generic rooms relay messages, but match rules must not be trusted to clients. A game opts into authority by exporting realtimeMatch(ctx, data) from its backend module (packages/api/src/games/<slug>.mjs): the relay routes match actions to it with ctx = { room, roomKey, from, fromConnId, members, sendTo, broadcast }. The module validates + applies the move (Sightlines reuses its deterministic engine, the same one that verifies CPU matches) and pushes the resulting state to the room via ctx.sendTo/ctx.broadcast. Clients render state; they never decide outcomes.

#Local vs production

Same SDK both places; only the transport swaps:

  • Local: a ws server attached to the preview/dev API; rooms + presence live in-process.
  • Production: an API Gateway WebSocket API; connections + rooms in DynamoDB, broadcast via the API Gateway management API.

The SDK discovers the socket URL and its auth token via GET /api/realtime/info, so you never hardcode either. In prod the socket is the raw API Gateway wss://…execute-api… URL (reached directly, not through CloudFront).

See also: accounts · leaderboards · saving game data.

View docs/guides/multiplayer.md on GitHub ↗