Brewser Docs
Multiplayer

WebSockets Quick Start

Real-time rooms, shared state and presence over wss://ws.brewser.io

Protocol: v1.1 · Endpoint: wss://ws.brewser.io

Brewser WebSockets provides simple real-time communication between clients in the same app + room. Use it for multiplayer games, shared state, presence, chat, collaboration, live dashboards, and other real-time applications.

It is a plain WebSocket endpoint, so it works the same in Brewser on the Switch and in a desktop browser: nothing here depends on a Brewser-only API. See Networking in Apps for what the runtime's network stack reaches.


1. The basic idea

Every connection belongs to:

App → Room → Clients

For example:

app = my-game
room = lobby-123

Three players connecting to the same app and room can communicate with each other:

my-game / lobby-123

 ┌──────────┐
 │ Player 1 │
 └────┬─────┘
      │
 ┌────▼─────┐
 │ Brewser  │
 │ WebSocket│
 └────┬─────┘
      │
 ┌────┴─────────────┐
 │                  │
 ▼                  ▼
Player 2          Player 3

Clients in different rooms are isolated from each other.


2. Connect

Use a WebSocket URL containing the app and room:

const app = "my-game";
const room = "lobby-123";

const ws = new WebSocket(
    `wss://ws.brewser.io?app=${encodeURIComponent(app)}&room=${encodeURIComponent(room)}`
);

The app and room names may contain:

  • letters
  • numbers
  • _
  • .
  • -

Maximum length is 64 characters.

For example:

wss://ws.brewser.io?app=my-game&room=lobby-123

3. Wait for joined

After connecting, the server sends a system/joined message.

You will receive information such as:

{
    "v": 1,
    "channel": "system",
    "op": "joined",
    "clientId": "abc123",
    "app": "my-game",
    "room": "lobby-123",
    "roomUuid": "server-generated-uuid",
    "members": 3,
    "maxMembers": 32,
    "seq": 12,
    "stateRevision": 4,
    "stateOwner": null,
    "state": {},
    "protocol": {
        "major": 1,
        "minor": 1,
        "capabilities": [
            "hello",
            "message_ids",
            "sequence_numbers",
            "acks",
            "reconnect_hint",
            "room_ttl",
            "state_ownership",
            "shutdown_event"
        ]
    }
}

The most useful fields are:

FieldPurpose
clientIdYour unique ID for this connection
roomUuidUnique ID of this particular room instance
membersCurrent number of clients
stateCurrent shared room state
stateRevisionCurrent state version
stateOwnerClient currently owning authoritative state
protocolProtocol version and capabilities

Important: clientId changes when you reconnect.


4. Send hello

A hello message lets your application tell the server which optional protocol features it understands.

It is recommended for all clients, particularly clients that mostly listen for events.

ws.send(JSON.stringify({
    v: 1,
    channel: "system",
    op: "hello",
    id: "hello-1",
    data: {
        client: "my-game/1.0",
        capabilities: [
            "acks",
            "sequence_numbers",
            "reconnect_hint"
        ]
    }
}));

The server responds with system/welcome:

{
    "v": 1,
    "channel": "system",
    "op": "welcome",
    "clientId": "abc123",
    "protocol": {
        "major": 1,
        "minor": 1
    },
    "capabilities": [
        "acks",
        "sequence_numbers",
        "reconnect_hint"
    ],
    "serverTime": 1760000000000,
    "replyTo": "hello-1"
}

The returned capabilities are the features available to both your client and the server.


5. Send application messages

Most applications will primarily use the app channel.

You can define your own operations.

For example:

ws.send(JSON.stringify({
    v: 1,
    channel: "app",
    op: "player_move",
    id: "move-123",
    data: {
        x: 10,
        y: 20
    }
}));

The server forwards it to the other clients in the room.

They receive:

{
    "v": 1,
    "channel": "app",
    "op": "message",
    "clientId": "abc123",
    "event": "player_move",
    "data": {
        "x": 10,
        "y": 20
    },
    "messageId": "move-123",
    "seq": 13,
    "serverTime": 1760000000000
}

Notice:

op       → "message"
event    → "player_move"

Your application defines the meaning of event and data.

For example:

player_move
player_attack
chat_message
game_start
player_ready
item_pickup

There is no fixed list of application operations.


6. Use message IDs

It is strongly recommended to give important outgoing messages an id.

For example:

{
    "v": 1,
    "channel": "app",
    "op": "player_ready",
    "id": "ready-42",
    "data": {
        "ready": true
    }
}

IDs allow the server to acknowledge messages.

The server can respond with:

{
    "v": 1,
    "channel": "app",
    "op": "ack",
    "clientId": "abc123",
    "event": "player_ready",
    "seq": 14,
    "replyTo": "ready-42",
    "serverTime": 1760000000000
}

A simple recommendation:

Give every important outgoing message a unique ID.

UUIDs are a good choice.


7. Read shared room state

Brewser provides a simple server-managed shared state system.

Request the current state:

ws.send(JSON.stringify({
    v: 1,
    channel: "state",
    op: "get",
    id: "state-get-1"
}));

The server responds:

{
    "v": 1,
    "channel": "state",
    "op": "state",
    "clientId": "abc123",
    "state": {
        "players": {},
        "score": 100
    },
    "revision": 4,
    "authoritative": false,
    "stateOwner": null,
    "replyTo": "state-get-1"
}

State is always a JSON object.


8. Update shared state

State updates use a revision number.

Suppose the current revision is:

4

Send:

ws.send(JSON.stringify({
    v: 1,
    channel: "state",
    op: "set",
    id: "state-set-1",
    revision: 4,
    state: {
        "players": {
            "abc123": {
                "x": 10,
                "y": 20
            }
        },
        "score": 100
    }
}));

If successful, the server broadcasts the new state with:

revision: 5

The next update must use:

revision: 5

This prevents two clients from accidentally overwriting each other's changes.

In simple terms

Get state
   ↓
revision 4
   ↓
Modify state
   ↓
Set state using revision 4
   ↓
Server accepts
   ↓
revision becomes 5

If somebody changed the state first, your revision will no longer match and the server returns a state/conflict.


9. Authoritative state ownership

For games and simulations, one client can become the state owner.

Request ownership by setting:

authoritative: true

Example:

ws.send(JSON.stringify({
    v: 1,
    channel: "state",
    op: "set",
    revision: 5,
    authoritative: true,
    state: {
        "players": {},
        "score": 150
    }
}));

That client becomes the state owner.

Only the owner can subsequently modify the state while ownership remains active.

This is useful for:

  • multiplayer games
  • simulations
  • game servers
  • shared worlds
  • authoritative physics

To give up ownership:

ws.send(JSON.stringify({
    v: 1,
    channel: "state",
    op: "release",
    id: "release-1"
}));

If the owner disconnects, ownership is automatically released.

The state itself remains until the room is deleted.


10. Presence

The server automatically tells clients when somebody joins or leaves.

Example:

{
    "v": 1,
    "channel": "system",
    "op": "presence",
    "event": "join",
    "clientId": "xyz789",
    "members": 4,
    "seq": 20
}

Or:

{
    "v": 1,
    "channel": "system",
    "op": "presence",
    "event": "leave",
    "clientId": "xyz789",
    "members": 3,
    "seq": 21
}

This is enough to build a basic player list without implementing your own presence system.


11. Getting the member list

You can request the current members:

ws.send(JSON.stringify({
    v: 1,
    channel: "system",
    op: "members",
    id: "members-1"
}));

The response contains:

{
    "v": 1,
    "channel": "system",
    "op": "members",
    "members": [
        {
            "clientId": "abc123",
            "connectedAt": 1760000000000,
            "isStateOwner": true,
            "client": "my-game/1.0"
        }
    ],
    "count": 1,
    "replyTo": "members-1"
}

12. Sequence numbers

Broadcast events contain a room sequence number:

"seq": 42

Sequence numbers allow you to detect gaps.

For example:

Received: 40
Received: 41
Received: 45

You know that events 42–44 were missed.

Brewser does not provide event replay.

If your application detects a gap, you should resynchronize using your own application logic.

For example:

Sequence gap
     ↓
Request current state
     ↓
Rebuild local state

For games, this is often the simplest approach.


13. Reconnecting

Connections can disappear because of:

  • network problems
  • server shutdown
  • room limits
  • rate limits
  • slow clients
  • temporary server capacity

Your application should reconnect when appropriate.

Use exponential backoff with jitter rather than reconnecting continuously.

Example strategy:

1 second
2 seconds
4 seconds
8 seconds
16 seconds
30 seconds maximum

If the server provides:

reconnectAfterMs

treat that as the minimum suggested delay.

Some common cases:

Close codeMeaningSuggested action
4011Server shutting downReconnect after ~5s
4012Server capacityRetry later
4008Rate limitedWait ~60s
4009Room fullRetry after ~10s
4014App capacityRetry after ~30s
4015Room expiredReconnect after ~1s
4010Slow clientReduce traffic before reconnecting
4013Handshake timeoutFix handshake/client logic

After reconnecting, you receive a new clientId.


14. Heartbeats

You do not normally need to implement your own heartbeat.

The server automatically uses WebSocket ping/pong.

The server:

Ping every ~15 seconds
     ↓
Wait up to ~10 seconds
     ↓
No response → disconnect

Browser WebSocket clients automatically handle protocol-level pong responses.


15. Important limits

You don't need to memorize these, but applications should stay comfortably below them.

LimitMaximum
Connections256
Connections per IP8
Connections per app128
Members per room32
Rooms1024
Rooms per app256
App/system message24 KiB
State message64 KiB
Messages/client/second80
Room outbound8 MiB/s
Global outbound32 MiB/s

For a normal multiplayer game, these limits should be generous.


16. Room lifetime

Rooms are created automatically when the first client joins.

A room can remain alive for up to:

12 hours

Its state is retained while the room exists.

An empty room is removed after approximately:

60 minutes

The server also removes rooms that reach their maximum lifetime.

Room state is in-memory.

A server restart therefore removes all rooms and their state.

Do not use Brewser room state as permanent storage.


17. Minimal client example

This is enough to get a basic application communicating:

const ws = new WebSocket(
    "wss://ws.brewser.io?app=my-game&room=lobby"
);

ws.addEventListener("open", () => {
    console.log("Connected");

    ws.send(JSON.stringify({
        v: 1,
        channel: "system",
        op: "hello",
        id: "hello-1",
        data: {
            client: "my-game/1.0",
            capabilities: [
                "acks",
                "sequence_numbers",
                "reconnect_hint"
            ]
        }
    }));
});

ws.addEventListener("message", (event) => {
    const message = JSON.parse(event.data);

    console.log("Received:", message);

    if (message.channel === "system") {
        if (message.op === "joined") {
            console.log("My client ID:", message.clientId);
        }

        if (message.op === "presence") {
            console.log(
                message.event,
                message.clientId
            );
        }
    }

    if (message.channel === "app") {
        if (message.op === "message") {
            console.log(
                message.event,
                message.data
            );
        }
    }

    if (message.channel === "state") {
        if (message.op === "state") {
            console.log(
                "State revision:",
                message.revision
            );
        }
    }
});

ws.addEventListener("close", (event) => {
    console.log(
        "Disconnected:",
        event.code,
        event.reason
    );
});

ws.addEventListener("error", (error) => {
    console.error("WebSocket error:", error);
});

18. The three channels

For most developers, you only need to remember this:

system

Brewser-managed functionality:

hello
room_info
members
presence
error
shutdown

app

Your application's real-time messages:

player_move
chat_message
game_start
player_attack
...

state

Shared server-managed state:

get
set
release

Think of it as:

┌──────────────────────────────┐
│        Brewser Room          │
│                              │
│  system → connection/presence│
│  app    → your messages      │
│  state  → shared state       │
│                              │
└──────────────────────────────┘

For a typical multiplayer application:

                  Brewser
                     │
          ┌──────────┴──────────┐
          │                     │
       system                  state
          │                     │
    presence/users       shared game state
          │                     │
          └──────────┬──────────┘
                     │
                    app
                     │
             game-specific events

Use:

  • system for connection and presence
  • app for gameplay/events
  • state for shared state that needs server-side revision control

Don't put everything into state.

For example, player movement is usually better as:

app/player_move

rather than repeatedly replacing the entire game state.


20. That's all you need to start

A new Brewser WebSocket application can start with just five concepts:

1. Connect to an app + room
2. Send hello
3. Listen for messages
4. Send app events
5. Reconnect when disconnected

The most important example is:

// Connect
const ws = new WebSocket(
    "wss://ws.brewser.io?app=my-game&room=lobby"
);

// Send an application event
ws.send(JSON.stringify({
    v: 1,
    channel: "app",
    op: "player_move",
    id: "move-1",
    data: {
        x: 10,
        y: 20
    }
}));

Everything else is there when your application needs it.


Quick reference

Endpoint
wss://ws.brewser.io?app=<app>&room=<room>

Channels
system
app
state

Basic envelope
{
    v: 1,
    channel: "...",
    op: "..."
}

Application message
{
    v: 1,
    channel: "app",
    op: "my_event",
    id: "unique-id",
    data: {}
}

Shared state
{
    v: 1,
    channel: "state",
    op: "set",
    revision: 0,
    state: {}
}

Request current state
{
    v: 1,
    channel: "state",
    op: "get",
    id: "state-1"
}

Start with app messages. Add state, presence, acknowledgements, and ownership only when your application actually needs them.

On this page