Multiplayer

Turn-based challenges through shared game state, and realtime rooms for live play with invites owned by the platform.

Remix has two multiplayer models. Challenges are turn-based: players take turns pushing a shared game state, and the platform notifies whoever is up. Realtime rooms seat up to 12 players for live play, and the platform handles the room, the invites and the connections.

Turn-Based Challenges

Challenges run in the Remix app on iOS and Android and on remix.gg. A challenge session has several players in GameInfo and a usersTurnId naming who the platform is waiting on. Trust it over your own inference; the platform rejects state saves from anyone else.

const info = await sdk.ready()
const me = info.player.id
const myTurn = info.usersTurnId === me

Push shared state after a move. The first id in alertUserIds takes the turn, and every id must belong to a player in the challenge:

sdk.multiplayer.actions.saveGameState({
  gameState: { moves },
  alertUserIds: [nextPlayerId],
})

Receive other players' moves, validate them, and refute anything invalid:

sdk.onGameStateUpdated((update) => {
  if (!update) return resetBoard()
  try {
    applyMoves(update.gameState.moves)
  } catch {
    sdk.multiplayer.actions.refuteGameState({ gameStateId: update.id })
  }
})

End the match with every player's score:

sdk.multiplayer.actions.gameOver({
  scores: [{ playerId: a, score: 1 }, { playerId: b, score: 0 }],
})

Use the player ids from GameInfo; they are the only ids the platform accepts.

Realtime Rooms

sdk.realtime gives your game rooms, players and messages, and the platform runs everything underneath. Rooms work on Remix Desktop, remix.gg and the Remix mobile app.

const room = await sdk.realtime.createRoom()        // take the first seat
// or
const room = await sdk.realtime.joinRoom(code)      // join by invite code

sdk.realtime.room   // the live room, or null
room.selfId
room.peers          // live roster, self excluded, in seat order
room.isHost         // this player owns the room
room.isAuthority    // this player's device runs the match

room.send({ t: 'start', at })                       // to everyone, reliable and ordered
room.send(snapshot, { reliable: false })            // per-frame state, never resent
room.sendTo(userId, { t: 'ready' })                 // to one player

room.onMessage((fromUserId, data) => handle(fromUserId, data))
room.onPeerJoin((peer) => addRacer(peer))
room.onPeerLeave((peer) => removeRacer(peer))
// reason: 'left' | 'expired' | 'offline' | 'closed' | 'kicked'
room.onEnded((reason) => showLobby(reason))
// transport trouble worth telling the player; the room keeps trying
room.onError((message) => toast(message))

room.invite()       // opens the platform's invite flow
room.kick(userId)   // owner only; that player's room ends with 'kicked'
room.leave()

A message is any JSON-serializable value, or an ArrayBuffer or typed array, which arrives as an ArrayBuffer. Sends are reliable and ordered by default, for lobby state, countdowns and results; pass { reliable: false } for the frequent state stream, where a late packet is worth less than the next one. send and sendTo return false when the message was dropped for at least one player, and every room on… call returns a function that unsubscribes.

room.code is an invite credential, not something to show players. Call room.invite() from a player action and the platform shares the invite. It has no delivery result, so wait for onPeerJoin before saying a friend joined.

Mount the lobby from sdk.realtime.onRoom, not only from your own create or join call: accepting an invite boots the game already seated, and onRoom replays a room seated before you subscribed. onRoomError is where a failed platform join (full, expired, wrong game) surfaces.

createRoom and joinRoom reject with a RealtimeRoomError whose code is room_not_found, room_full, game_mismatch (an invite for another game), no_game_id (the game is not published yet) or join_failed. Rooms need a signed-in player; a guest's request fails with join_failed and the platform offers sign-in.

One live room at a time: a new create or join leaves the current room. A room seats up to 12 players, so a thirteenth join fails with room_full. Seat order (oldest first) is the deterministic tiebreak when peers disagree.

The previews in the Studio and Remix Desktop answer createRoom and joinRoom with a local room that has no other players, so you can build the lobby there. Play real rooms in the published game.

Authority

The room owner (hostUserId, isHost) runs the lobby and is the only player who can kick. When the owner leaves, the oldest seat takes over and onHostChange fires. The authority (authorityId, isAuthority) is the player whose device runs the match, usually someone else. Simulate on isAuthority, never on isHost.

Call room.electAuthority() from your lobby's Start action once everyone is seated. It resolves with the id of the best-connected player, as the SDK measures it, and every player hears onAuthorityChange; it rejects if the room ends or the vote does not settle. authorityId is null before the first election and while the SDK replaces an authority who left. Pause on null, and have the new authority resynchronize the match, because match state does not carry over. room.quality(userId) reports this device's round trip and loss to a player, for a connection indicator.

Route guest inputs to the authority with sendTo. Guests predict locally, the authority settles outcomes, snapshots flow back. Keep wall time, simulation ticks, input sequence and packet sequence separate, and never let a network stall stretch a physics step. A rules engine that runs identically on every peer is the foundation; the transport is the easy part.

There is no creator-owned server. Anything that needs a trusted referee runs on the authority's device, which is still a player's device.