#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 andArcade.realtime.join("match:<roomId>"). - Invite by name/email: typeahead with
Arcade.players.search, thenArcade.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
wsserver 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.