Multiplayer

Requirements

Rooms, matchmaking and message relay already run on our server. Your game connects, gets put in a room with whoever else is there, and sends messages. There is nothing to deploy, no signalling server to run, and no third-party account.

Multiplayer is for signed-in players. Connecting needs a ticket, and a ticket can only be minted against a session. A signed-out player is asked to log in before your game starts, so by the time your code runs there is a real account behind it. Your game cannot opt out of this, and does not have to check for it.

What you get

  • Matchmaking. Connect with no arguments and you land in the oldest room that still has space, so players gather in one game instead of scattering one-per-room and each waiting alone.
  • Named rooms. Pass a room name and you join exactly that room. This is how "play with a friend" works: one player shares a code, the other types it in.
  • Relay. Anything you send reaches the other players in your room, with the sender's identity stamped by the server.
  • Presence. Join and leave events, and the current player list.

Getting started

  • Tick Multiplayer on your game in the developer portal. Until that flag is set the ticket endpoint refuses and SlopMP.connect() rejects.
  • Load the client in your game.
<script src="/mp/client.js"></script>

The whole API

const mp = await SlopMP.connect();        // or { room: 'abc123', cap: 4 }

mp.me        // { id, name }   who you are
mp.room      // the room you landed in
mp.players   // [{ id, name }] everyone here

mp.on('ready',   (mp) => {});             // also resolves the promise
mp.on('join',    (player, players) => {});
mp.on('leave',   (player, players) => {});
mp.on('message', (data, from) => {});     // from is { id, name }
mp.on('close',   (code) => {});
mp.on('error',   () => {});

mp.send({ x: 10, y: 4 });                 // everyone else in the room
mp.send({ deal: cards }, playerId);       // one player
mp.close();

Names come with the connection. mp.me.name, the names in mp.players and the from.name on every message are the signed-in accounts, stamped by the server. Show those rather than asking a player to type a name, and never trust a name a client puts in its own message. Account names are text somebody chose, so escape them before they reach innerHTML.

SlopMP.available() tells you whether a ticket is present. Use it to show a "sign in to play together" state instead of attempting a connection you know will fail.

How a connection is made

Your game never authenticates itself. It runs sandboxed on an opaque origin, so it has no cookies and sends Origin: null, which our API rejects on purpose. The portal does the authenticating and hands your game a ticket.

  1. The portal asks the server for a ticket, using the player's session.
  2. The ticket is passed into your frame on the URL as ?mp=.... The client reads it for you.
  3. Your game opens a socket to /mp/connect with that ticket.
  4. The server redeems the ticket, deletes it, asks the lobby which room has space, and joins you to that room.
  5. You receive welcome with your identity and the player list, then join, leave and msg as they happen.

Under the hood

Two Durable Objects do the work. A Lobby, one per game, does nothing but answer "which room should this player join". A MatchRoom, one per room, holds that room's sockets and relays between them. Because there is exactly one instance of each room, it is safe to keep game state there without locking.

Both use the WebSocket Hibernation API: between messages the object is evicted from memory while the sockets stay open at the edge. A room with nobody talking costs nothing, which is why turn-based games are extremely cheap to run here, and why a game that polls on a timer is not. Send on change, not on an interval.

A window that is not on screen

Browsers stop requestAnimationFrame in a hidden tab. If your game's clock is the animation frame and one player puts that window behind another, their game stops — and if that player was the one simulating, it stops for both of you while the socket stays wide open. It looks exactly like a server that quit.

Incoming socket messages are not throttled that way. Step your simulation from them as well as from the frame, using the real elapsed time, and a host keeps running on the other player's traffic while its own window is buried. Cap the step you take after a long gap so a tab that was away for a minute does not catch up in one lurch.

Wire protocol

You only need this if you are writing your own client instead of using /mp/client.js. Everything is JSON text frames.

What you send

FrameMeaning
{ data }Broadcast to everyone else in the room.
{ data, to }Send to one player id.
{ type: "ping", t }Round-trip check, answered with pong carrying the same t.

What you receive

FrameMeaning
{ type: "welcome", you, room, players }First frame after joining.
{ type: "join", player, players }Somebody arrived.
{ type: "leave", player }Somebody left or dropped.
{ type: "msg", from, data }A relayed message. from is assigned by the server.
{ type: "pong", t }Reply to a ping.

Limits and defaults

ThingValueNotes
Room capacity8Set with cap. Minimum 2, maximum 32. A full room is refused.
Message size64 KBOversized frames are dropped silently. Send small and often.
Ticket lifetime2 minutesSingle use, redeemed and deleted on connect.
Room name64 charactersLetters, digits, _ and -. Anything else is stripped.
Matchmaking window10 minutesA room that has not reported in that long is not offered to new players.
Room list pruning30 minutesAbandoned rooms drop out of the lobby's list.

What the server will not do

It relays, it does not referee. Whatever you pass to send arrives unchanged. The server never inspects your game state, so it cannot stop a modified client from claiming it rolled a six. For anything competitive, nominate one player as host and have them validate moves, or verify each move on every client.

The one thing you can trust is identity. from is the signed-in account that sent the frame, not a value the client supplied, so you can rely on who said something even when you cannot rely on what they said.

A ticket is also single use, so a dropped socket cannot silently reconnect from inside your game. Handle close by telling the player what happened rather than appearing to hang.

A worked example

F-16 Dogfight is on the site and does everything on this page: matchmaking, a room code for friends, prediction on the player who is not simulating, and a host that hands the room back when it leaves. It is a single HTML file with no dependencies beyond /mp/client.js, so reading it is a reasonable way to start.

It takes the division of labour described above: one player simulates the fight and sends the state twenty times a second, the other sends its stick inputs and predicts its own jet. Whoever was in the room first takes that job, which is what the roster in the welcome is for.

Testing it

Open your game in two browser windows while signed in. Each window gets its own ticket and its own socket, and the second should appear in the first as a join. Keep both windows visible, side by side rather than stacked: a browser stops requestAnimationFrame in a hidden tab, so a game behind another window freezes while its socket stays open. For named rooms, pass the same room from both and confirm they meet; pass different names and confirm they do not.

If connect() rejects immediately, the usual cause is the Multiplayer flag not being set on the game, or the player not being signed in.